add bitwarden export

This commit is contained in:
2026-09-03 10:58:42 -04:00
parent 8baf578559
commit 7c04370e48
5 changed files with 276 additions and 0 deletions

85
entries/bitwarden.go Normal file
View File

@@ -0,0 +1,85 @@
package entries
import "time"
const bitwardenLoginItemType = 1
// BitwardenExport represents Bitwarden's plaintext JSON vault export format.
// MaxWarden does not have folders, so every item is exported at the vault root.
type BitwardenExport struct {
Encrypted bool `json:"encrypted"`
Folders []any `json:"folders"`
Items []BitwardenItem `json:"items"`
}
type BitwardenItem struct {
PasswordHistory []any `json:"passwordHistory"`
RevisionDate string `json:"revisionDate"`
CreationDate string `json:"creationDate"`
DeletedDate *string `json:"deletedDate"`
ID string `json:"id"`
OrganizationID *string `json:"organizationId"`
FolderID *string `json:"folderId"`
Type int `json:"type"`
Reprompt int `json:"reprompt"`
Name string `json:"name"`
Notes string `json:"notes"`
Favorite bool `json:"favorite"`
Login BitwardenLogin `json:"login"`
CollectionIDs []string `json:"collectionIds"`
}
type BitwardenLogin struct {
URIs []BitwardenURI `json:"uris"`
Username string `json:"username"`
Password string `json:"password"`
TOTP *string `json:"totp"`
}
type BitwardenURI struct {
Match *int `json:"match"`
URI string `json:"uri"`
}
func NewBitwardenExport(secrets []Secret) BitwardenExport {
items := make([]BitwardenItem, 0, len(secrets))
for _, secret := range secrets {
uris := make([]BitwardenURI, 0, 1)
if secret.URL != "" {
uris = append(uris, BitwardenURI{URI: secret.URL})
}
items = append(items, BitwardenItem{
PasswordHistory: nil,
RevisionDate: bitwardenTime(secret.Modified),
CreationDate: bitwardenTime(secret.Created),
ID: secret.ID,
Type: bitwardenLoginItemType,
Reprompt: 0,
Name: secret.Description,
Notes: secret.Notes,
Favorite: false,
Login: BitwardenLogin{
URIs: uris,
Username: secret.Username,
Password: secret.Password,
},
CollectionIDs: nil,
})
}
return BitwardenExport{
Encrypted: false,
Folders: make([]any, 0),
Items: items,
}
}
func bitwardenTime(value time.Time) string {
if value.IsZero() {
return ""
}
return value.UTC().Format(time.RFC3339Nano)
}

81
entries/bitwarden_test.go Normal file
View File

@@ -0,0 +1,81 @@
package entries
import (
"encoding/json"
"testing"
"time"
)
func TestNewBitwardenExport(t *testing.T) {
created := time.Date(2026, time.September, 3, 12, 30, 0, 0, time.FixedZone("EDT", -4*60*60))
modified := created.Add(15 * time.Minute)
export := NewBitwardenExport([]Secret{{
ID: "c381ca2b-0f53-4b05-b209-f2eb11f05e19",
Description: "Example",
URL: "https://example.com/login",
Notes: "A note",
Username: "person@example.com",
Password: "correct horse battery staple",
Created: created,
Modified: modified,
}})
if export.Encrypted {
t.Fatal("plaintext Bitwarden export must set encrypted to false")
}
if export.Folders == nil || len(export.Folders) != 0 {
t.Fatalf("expected a non-nil empty folders array, got %#v", export.Folders)
}
if len(export.Items) != 1 {
t.Fatalf("expected one item, got %d", len(export.Items))
}
item := export.Items[0]
if item.Type != bitwardenLoginItemType {
t.Fatalf("expected login item type %d, got %d", bitwardenLoginItemType, item.Type)
}
if item.Name != "Example" || item.Notes != "A note" {
t.Fatalf("item metadata was not mapped: %#v", item)
}
if item.Login.Username != "person@example.com" || item.Login.Password != "correct horse battery staple" {
t.Fatalf("login credentials were not mapped: %#v", item.Login)
}
if len(item.Login.URIs) != 1 || item.Login.URIs[0].URI != "https://example.com/login" {
t.Fatalf("login URI was not mapped: %#v", item.Login.URIs)
}
if item.CreationDate != "2026-09-03T16:30:00Z" || item.RevisionDate != "2026-09-03T16:45:00Z" {
t.Fatalf("timestamps were not normalized to UTC: %#v", item)
}
encoded, err := json.Marshal(export)
if err != nil {
t.Fatalf("marshal export: %v", err)
}
var document map[string]any
if err := json.Unmarshal(encoded, &document); err != nil {
t.Fatalf("unmarshal export: %v", err)
}
if _, ok := document["encrypted"]; !ok {
t.Fatal("export is missing encrypted field")
}
if _, ok := document["folders"]; !ok {
t.Fatal("export is missing folders field")
}
if _, ok := document["items"]; !ok {
t.Fatal("export is missing items field")
}
}
func TestNewBitwardenExportWithoutURLOrDates(t *testing.T) {
export := NewBitwardenExport([]Secret{{Description: "No URL"}})
item := export.Items[0]
if item.Login.URIs == nil || len(item.Login.URIs) != 0 {
t.Fatalf("expected an empty URI array, got %#v", item.Login.URIs)
}
if item.CreationDate != "" || item.RevisionDate != "" {
t.Fatalf("zero timestamps should be empty strings: %#v", item)
}
}