add bitwarden export
This commit is contained in:
@@ -18,6 +18,7 @@ func mapRoutes(mux *http.ServeMux) {
|
|||||||
mux.HandleFunc("/app", id(sess(app.VaultHandler), true))
|
mux.HandleFunc("/app", id(sess(app.VaultHandler), true))
|
||||||
mux.HandleFunc("/app/generator", id(sess(app.GeneratorHandler), true))
|
mux.HandleFunc("/app/generator", id(sess(app.GeneratorHandler), true))
|
||||||
mux.HandleFunc("/app/generator-hx", id(sess(app.GeneratorHxHandler), 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/vault-hx", id(sess(app.VaultHxHandler), true))
|
||||||
mux.HandleFunc("/app/delete/{id}", id(sess(app.DeleteHandler), true))
|
mux.HandleFunc("/app/delete/{id}", id(sess(app.DeleteHandler), true))
|
||||||
mux.HandleFunc("/app/editor/add", id(sess(app.EditorHandler), true))
|
mux.HandleFunc("/app/editor/add", id(sess(app.EditorHandler), true))
|
||||||
|
|||||||
85
entries/bitwarden.go
Normal file
85
entries/bitwarden.go
Normal 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
81
entries/bitwarden_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
108
handlers/app/export_bitwarden.go
Normal file
108
handlers/app/export_bitwarden.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
@@ -28,6 +28,7 @@ var NavGroups = []NavGroup{
|
|||||||
Title: "Tools",
|
Title: "Tools",
|
||||||
SubGroup: []NavGroup{
|
SubGroup: []NavGroup{
|
||||||
{SectionId: LAYOUT_SECTION_TOOLS, Title: "Generator", URL: "/app/generator"},
|
{SectionId: LAYOUT_SECTION_TOOLS, Title: "Generator", URL: "/app/generator"},
|
||||||
|
{SectionId: LAYOUT_SECTION_TOOLS, Title: "Export", URL: "/app/export/bitwarden"},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user