diff --git a/cmd/server/routes.go b/cmd/server/routes.go index fb12bda..14fe63f 100644 --- a/cmd/server/routes.go +++ b/cmd/server/routes.go @@ -18,6 +18,7 @@ func mapRoutes(mux *http.ServeMux) { mux.HandleFunc("/app", id(sess(app.VaultHandler), true)) mux.HandleFunc("/app/generator", id(sess(app.GeneratorHandler), true)) mux.HandleFunc("/app/generator-hx", id(sess(app.GeneratorHxHandler), true)) + mux.HandleFunc("/app/export/bitwarden", id(sess(app.ExportBitwardenHandler), true)) mux.HandleFunc("/app/vault-hx", id(sess(app.VaultHxHandler), true)) mux.HandleFunc("/app/delete/{id}", id(sess(app.DeleteHandler), true)) mux.HandleFunc("/app/editor/add", id(sess(app.EditorHandler), true)) diff --git a/entries/bitwarden.go b/entries/bitwarden.go new file mode 100644 index 0000000..d7e8cb3 --- /dev/null +++ b/entries/bitwarden.go @@ -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) +} diff --git a/entries/bitwarden_test.go b/entries/bitwarden_test.go new file mode 100644 index 0000000..ac97d81 --- /dev/null +++ b/entries/bitwarden_test.go @@ -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) + } +} diff --git a/handlers/app/export_bitwarden.go b/handlers/app/export_bitwarden.go new file mode 100644 index 0000000..ff84b49 --- /dev/null +++ b/handlers/app/export_bitwarden.go @@ -0,0 +1,108 @@ +package app + +import ( + "encoding/json" + "maxwarden/entries" + "maxwarden/middleware" + "maxwarden/query" + "maxwarden/security" + . "maxwarden/ui" + "maxwarden/users" + "net/http" + "time" + + . "maragu.dev/gomponents" + . "maragu.dev/gomponents/html" +) + +func ExportBitwardenHandler(w http.ResponseWriter, r *http.Request) { + identity := middleware.GetIdentity(r) + session := middleware.GetSession(r) + + renderView := func(errorMessage string) { + AppLayout("Export Vault", *identity, session, + Card( + Heading("Bitwarden JSON export"), + P(Text("Download your MaxWarden vault as a plaintext JSON file that can be imported into Bitwarden.")), + P( + InlineStyle("$me { margin-top: $3; color: $color(red-700); font-weight: var(--font-weight-semibold); }"), + Text("Warning: the downloaded file contains all usernames and passwords in plaintext. Store it securely and delete it when you are finished."), + ), + If(errorMessage != "", + P( + InlineStyle("$me { margin-top: $3; color: $color(red-700); }"), + Text(errorMessage), + ), + ), + Form( + InlineStyle("$me { margin-top: $5; }"), + Action("/app/export/bitwarden"), + Method(http.MethodPost), + FormLabel(Text("Confirm your master key")), + FormInput( + Type("password"), + Name("password"), + AutoComplete("current-password"), + Required(), + ), + Br(), + ButtonUIDanger(Type("submit"), Text("Download plaintext export")), + ), + ), + ).Render(w) + } + + switch r.Method { + case http.MethodGet: + renderView("") + return + case http.MethodPost: + // Continue below. + default: + w.Header().Set("Allow", http.MethodGet+", "+http.MethodPost) + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + r.Body = http.MaxBytesReader(w, r.Body, 1<<20) + if err := r.ParseForm(); err != nil { + http.Error(w, "Invalid export request", http.StatusBadRequest) + return + } + + user, err := users.FetchById(identity.UserID) + if err != nil { + http.Error(w, "Unable to verify account", http.StatusInternalServerError) + return + } + + if !security.ComparePasswords(r.Form.Get("password"), user.Password) { + w.WriteHeader(http.StatusUnauthorized) + renderView("The master key is incorrect.") + return + } + + secretList, err := entries.Filter(entries.EntryFilter{ + Filter: query.NewFilterFromSearch(map[string]string{}), + UserId: identity.UserID, + MasterKey: identity.MasterKey, + }) + if err != nil { + http.Error(w, "Unable to decrypt vault", http.StatusInternalServerError) + return + } + + document, err := json.MarshalIndent(entries.NewBitwardenExport(secretList), "", " ") + if err != nil { + http.Error(w, "Unable to create export", http.StatusInternalServerError) + return + } + + filename := "maxwarden-bitwarden-export-" + time.Now().UTC().Format("2006-01-02") + ".json" + w.Header().Set("Cache-Control", "no-store") + w.Header().Set("Content-Disposition", `attachment; filename="`+filename+`"`) + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.Header().Set("Pragma", "no-cache") + w.Header().Set("X-Content-Type-Options", "nosniff") + w.Write(document) +} diff --git a/ui/app_layout.go b/ui/app_layout.go index e4ad50b..24f8d29 100644 --- a/ui/app_layout.go +++ b/ui/app_layout.go @@ -28,6 +28,7 @@ var NavGroups = []NavGroup{ Title: "Tools", SubGroup: []NavGroup{ {SectionId: LAYOUT_SECTION_TOOLS, Title: "Generator", URL: "/app/generator"}, + {SectionId: LAYOUT_SECTION_TOOLS, Title: "Export", URL: "/app/export/bitwarden"}, }, }, }