From 44bba18149e62f9e00fb3a0a9b811c6b2fbd2d3f Mon Sep 17 00:00:00 2001 From: Joe Julian Date: Sun, 29 Mar 2026 11:23:54 -0700 Subject: [PATCH] Add UI master key setup and change flows --- README.md | 3 +- appstate/state.go | 19 +++ appstate/state_test.go | 25 ++++ docs/kdbx-compatibility.md | 2 + main.go | 287 ++++++++++++++++++++++--------------- main_test.go | 253 ++++++++++++++++++++++++++++++++ session/session.go | 66 +++++++-- session/session_test.go | 62 ++++++++ ui_forms.go | 92 +++++++++--- vault/masterkey.go | 13 ++ 10 files changed, 675 insertions(+), 147 deletions(-) create mode 100644 vault/masterkey.go diff --git a/README.md b/README.md index 8c2029f..757da0e 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,8 @@ KeePassGO is a Go-based KeePass-compatible password manager prototype targeting ## Current Capabilities - KDBX load and save -- password, key-file, and composite master-key support in the storage layer +- password-only, key-file-only, and composite master-key flows through the desktop product UI +- master-key changes for existing vault sessions - WebDAV-backed open and save support in the session layer - password generation profiles - gRPC integration surface for trusted automation diff --git a/appstate/state.go b/appstate/state.go index 2df49bf..cd15381 100644 --- a/appstate/state.go +++ b/appstate/state.go @@ -32,6 +32,11 @@ type LockableSession interface { Unlock(vault.MasterKey) error } +type MasterKeyChangeableSession interface { + CurrentSession + ChangeMasterKey(vault.MasterKey) error +} + type SaveableSession interface { CurrentSession Save() error @@ -419,6 +424,20 @@ func (s *State) Unlock(key vault.MasterKey) error { return session.Unlock(key) } +func (s *State) ChangeMasterKey(key vault.MasterKey) error { + session, ok := s.Session.(MasterKeyChangeableSession) + if !ok { + return fmt.Errorf("session does not support master key changes") + } + + if err := session.ChangeMasterKey(key); err != nil { + return err + } + + s.Dirty = true + return nil +} + func (s *State) EnterGroup(name string) { s.CurrentPath = append(append([]string(nil), s.CurrentPath...), name) s.SelectedEntryID = "" diff --git a/appstate/state_test.go b/appstate/state_test.go index 7f678fa..a234619 100644 --- a/appstate/state_test.go +++ b/appstate/state_test.go @@ -696,6 +696,25 @@ func TestUnlockRestoresVaultVisibility(t *testing.T) { } } +func TestChangeMasterKeyMarksStateDirty(t *testing.T) { + t.Parallel() + + sess := &lifecycleStubSession{} + state := State{Session: sess} + + key := vault.MasterKey{Password: "correct horse battery staple"} + if err := state.ChangeMasterKey(key); err != nil { + t.Fatalf("ChangeMasterKey() error = %v", err) + } + + if got := sess.changedKey; got.Password != key.Password { + t.Fatalf("changedKey = %#v, want %#v", got, key) + } + if !state.Dirty { + t.Fatal("Dirty = false, want true after ChangeMasterKey") + } +} + func TestEnterGroupAppendsPathAndClearsSelection(t *testing.T) { t.Parallel() @@ -929,6 +948,7 @@ type lifecycleStubSession struct { openPath string saveAsPath string remotePath string + changedKey vault.MasterKey } func (s *lifecycleStubSession) Current() (vault.Model, error) { @@ -954,3 +974,8 @@ func (s *lifecycleStubSession) OpenRemote(_ webdav.Client, path string, _ vault. s.remotePath = path return nil } + +func (s *lifecycleStubSession) ChangeMasterKey(key vault.MasterKey) error { + s.changedKey = key + return nil +} diff --git a/docs/kdbx-compatibility.md b/docs/kdbx-compatibility.md index 4ad3d16..5e8b178 100644 --- a/docs/kdbx-compatibility.md +++ b/docs/kdbx-compatibility.md @@ -5,6 +5,8 @@ KeePassGO supports the following KDBX security workflows today: - open and save password-only vaults - open and save key-file-only vaults - open and save composite password-plus-key-file vaults +- select the active master-key mode in the product UI for create, open, and unlock flows +- change an existing session to a new master-key mode before saving - preserve the original opened vault's KDBX format version during save - preserve the original opened vault's cipher selection during save - preserve the original opened vault's KDF selection during save diff --git a/main.go b/main.go index bac47a3..a52d8df 100644 --- a/main.go +++ b/main.go @@ -1,111 +1,116 @@ package main import ( - "fmt" "flag" + "fmt" "image" "image/color" "os" "strings" + "gioui.org/app" + "gioui.org/gesture" + "gioui.org/io/pointer" + "gioui.org/layout" + "gioui.org/op" + "gioui.org/op/clip" + "gioui.org/op/paint" + "gioui.org/unit" + "gioui.org/widget" + "gioui.org/widget/material" "git.julianfamily.org/keepassgo/appstate" "git.julianfamily.org/keepassgo/clipboard" "git.julianfamily.org/keepassgo/session" "git.julianfamily.org/keepassgo/vault" "git.julianfamily.org/keepassgo/webdav" - "gioui.org/app" - "gioui.org/gesture" - "gioui.org/io/pointer" - "gioui.org/op/clip" - "gioui.org/op/paint" - "gioui.org/layout" - "gioui.org/op" - "gioui.org/unit" - "gioui.org/widget" - "gioui.org/widget/material" "golang.org/x/exp/shiny/materialdesign/icons" ) type entry = vault.Entry type ui struct { - mode string - theme *material.Theme - search widget.Editor - vaultPath widget.Editor - saveAsPath widget.Editor - remoteBaseURL widget.Editor - remotePath widget.Editor - remoteUsername widget.Editor - remotePassword widget.Editor - masterPassword widget.Editor - keyFilePath widget.Editor - entryID widget.Editor - entryTitle widget.Editor - entryUsername widget.Editor - entryPassword widget.Editor - entryURL widget.Editor - entryNotes widget.Editor - entryTags widget.Editor - entryPath widget.Editor - entryFields widget.Editor - historyIndex widget.Editor - groupName widget.Editor - passwordProfile widget.Editor - attachmentName widget.Editor - attachmentPath widget.Editor - exportAttachmentPath widget.Editor - list widget.List - detailList widget.List - copyUser widget.Clickable - copyPass widget.Clickable - copyURL widget.Clickable - openURL widget.Clickable - lockVault widget.Clickable - unlockVault widget.Clickable - createVault widget.Clickable - openVault widget.Clickable - saveVault widget.Clickable - saveAsVault widget.Clickable - openRemote widget.Clickable - addEntry widget.Clickable - saveEntry widget.Clickable - duplicateEntry widget.Clickable - deleteEntry widget.Clickable - restoreEntry widget.Clickable - saveTemplate widget.Clickable - deleteTemplate widget.Clickable - instantiateTemplate widget.Clickable - addAttachment widget.Clickable - removeAttachment widget.Clickable - exportAttachment widget.Clickable - restoreHistory widget.Clickable - generatePassword widget.Clickable - createGroup widget.Clickable - renameGroup widget.Clickable - deleteGroup widget.Clickable - togglePasswordInline widget.Clickable - showEntries widget.Clickable - showTemplates widget.Clickable - showRecycle widget.Clickable - entryClicks []widget.Clickable - breadcrumbs []widget.Clickable - groupClicks []widget.Clickable - state appstate.State - visible []entry - currentPath []string - showPassword bool - togglePassword widget.Clickable - phoneSplit widget.Float - splitDrag gesture.Drag - splitBase float32 - splitStartY float32 - phoneSpan int - eyeIcon *widget.Icon - eyeOffIcon *widget.Icon - copyIcon *widget.Icon - statusMessage string - errorMessage string + mode string + theme *material.Theme + search widget.Editor + vaultPath widget.Editor + saveAsPath widget.Editor + remoteBaseURL widget.Editor + remotePath widget.Editor + remoteUsername widget.Editor + remotePassword widget.Editor + masterPassword widget.Editor + keyFilePath widget.Editor + entryID widget.Editor + entryTitle widget.Editor + entryUsername widget.Editor + entryPassword widget.Editor + entryURL widget.Editor + entryNotes widget.Editor + entryTags widget.Editor + entryPath widget.Editor + entryFields widget.Editor + historyIndex widget.Editor + groupName widget.Editor + passwordProfile widget.Editor + attachmentName widget.Editor + attachmentPath widget.Editor + exportAttachmentPath widget.Editor + list widget.List + detailList widget.List + copyUser widget.Clickable + copyPass widget.Clickable + copyURL widget.Clickable + openURL widget.Clickable + lockVault widget.Clickable + unlockVault widget.Clickable + createVault widget.Clickable + openVault widget.Clickable + saveVault widget.Clickable + saveAsVault widget.Clickable + openRemote widget.Clickable + changeMasterKey widget.Clickable + addEntry widget.Clickable + saveEntry widget.Clickable + duplicateEntry widget.Clickable + deleteEntry widget.Clickable + restoreEntry widget.Clickable + saveTemplate widget.Clickable + deleteTemplate widget.Clickable + instantiateTemplate widget.Clickable + addAttachment widget.Clickable + removeAttachment widget.Clickable + exportAttachment widget.Clickable + restoreHistory widget.Clickable + generatePassword widget.Clickable + createGroup widget.Clickable + renameGroup widget.Clickable + deleteGroup widget.Clickable + togglePasswordInline widget.Clickable + showEntries widget.Clickable + showTemplates widget.Clickable + showRecycle widget.Clickable + masterKeyPasswordOnly widget.Clickable + masterKeyKeyFileOnly widget.Clickable + masterKeyComposite widget.Clickable + entryClicks []widget.Clickable + breadcrumbs []widget.Clickable + groupClicks []widget.Clickable + state appstate.State + masterKeyMode vault.MasterKeyMode + visible []entry + currentPath []string + showPassword bool + togglePassword widget.Clickable + phoneSplit widget.Float + splitDrag gesture.Drag + splitBase float32 + splitStartY float32 + phoneSpan int + eyeIcon *widget.Icon + eyeOffIcon *widget.Icon + copyIcon *widget.Icon + statusMessage string + errorMessage string } var ( @@ -143,28 +148,28 @@ func newUIWithState(mode string, sess appstate.CurrentSession) *ui { SingleLine: true, Submit: false, }, - vaultPath: widget.Editor{SingleLine: true, Submit: false}, - saveAsPath: widget.Editor{SingleLine: true, Submit: false}, - remoteBaseURL: widget.Editor{SingleLine: true, Submit: false}, - remotePath: widget.Editor{SingleLine: true, Submit: false}, - remoteUsername: widget.Editor{SingleLine: true, Submit: false}, - remotePassword: widget.Editor{SingleLine: true, Submit: false}, - masterPassword: widget.Editor{SingleLine: true, Submit: false}, - keyFilePath: widget.Editor{SingleLine: true, Submit: false}, - entryID: widget.Editor{SingleLine: true, Submit: false}, - entryTitle: widget.Editor{SingleLine: true, Submit: false}, - entryUsername: widget.Editor{SingleLine: true, Submit: false}, - entryPassword: widget.Editor{SingleLine: true, Submit: false}, - entryURL: widget.Editor{SingleLine: true, Submit: false}, - entryNotes: widget.Editor{SingleLine: false, Submit: false}, - entryTags: widget.Editor{SingleLine: true, Submit: false}, - entryPath: widget.Editor{SingleLine: true, Submit: false}, - entryFields: widget.Editor{SingleLine: false, Submit: false}, - historyIndex: widget.Editor{SingleLine: true, Submit: false}, - groupName: widget.Editor{SingleLine: true, Submit: false}, - passwordProfile: widget.Editor{SingleLine: true, Submit: false}, - attachmentName: widget.Editor{SingleLine: true, Submit: false}, - attachmentPath: widget.Editor{SingleLine: true, Submit: false}, + vaultPath: widget.Editor{SingleLine: true, Submit: false}, + saveAsPath: widget.Editor{SingleLine: true, Submit: false}, + remoteBaseURL: widget.Editor{SingleLine: true, Submit: false}, + remotePath: widget.Editor{SingleLine: true, Submit: false}, + remoteUsername: widget.Editor{SingleLine: true, Submit: false}, + remotePassword: widget.Editor{SingleLine: true, Submit: false}, + masterPassword: widget.Editor{SingleLine: true, Submit: false}, + keyFilePath: widget.Editor{SingleLine: true, Submit: false}, + entryID: widget.Editor{SingleLine: true, Submit: false}, + entryTitle: widget.Editor{SingleLine: true, Submit: false}, + entryUsername: widget.Editor{SingleLine: true, Submit: false}, + entryPassword: widget.Editor{SingleLine: true, Submit: false}, + entryURL: widget.Editor{SingleLine: true, Submit: false}, + entryNotes: widget.Editor{SingleLine: false, Submit: false}, + entryTags: widget.Editor{SingleLine: true, Submit: false}, + entryPath: widget.Editor{SingleLine: true, Submit: false}, + entryFields: widget.Editor{SingleLine: false, Submit: false}, + historyIndex: widget.Editor{SingleLine: true, Submit: false}, + groupName: widget.Editor{SingleLine: true, Submit: false}, + passwordProfile: widget.Editor{SingleLine: true, Submit: false}, + attachmentName: widget.Editor{SingleLine: true, Submit: false}, + attachmentPath: widget.Editor{SingleLine: true, Submit: false}, exportAttachmentPath: widget.Editor{SingleLine: true, Submit: false}, list: widget.List{ List: layout.List{Axis: layout.Vertical}, @@ -172,7 +177,8 @@ func newUIWithState(mode string, sess appstate.CurrentSession) *ui { detailList: widget.List{ List: layout.List{Axis: layout.Vertical}, }, - state: appstate.State{}, + state: appstate.State{}, + masterKeyMode: vault.MasterKeyModePasswordOnly, } u.state.Session = sess u.phoneSplit.Value = 0.46 @@ -263,19 +269,44 @@ func (u *ui) selectedEntry() (entry, bool) { } func (u *ui) currentMasterKey() (vault.MasterKey, error) { - key := vault.MasterKey{Password: u.masterPassword.Text()} - + password := u.masterPassword.Text() path := strings.TrimSpace(u.keyFilePath.Text()) - if path == "" { - return key, nil + + switch u.masterKeyMode { + case vault.MasterKeyModeKeyFileOnly: + if path == "" { + return vault.MasterKey{}, fmt.Errorf("key file is required") + } + case vault.MasterKeyModePasswordAndKeyFile: + if password == "" { + return vault.MasterKey{}, fmt.Errorf("master password is required") + } + if path == "" { + return vault.MasterKey{}, fmt.Errorf("key file is required") + } + default: + if password == "" { + return vault.MasterKey{}, fmt.Errorf("master password is required") + } + return vault.MasterKey{Password: password}, nil } content, err := os.ReadFile(path) if err != nil { return vault.MasterKey{}, fmt.Errorf("read key file: %w", err) } - key.KeyFileData = content - return key, nil + if len(content) == 0 { + return vault.MasterKey{}, fmt.Errorf("key file is empty") + } + + return vault.MasterKey{ + Password: password, + KeyFileData: content, + }, nil +} + +func (u *ui) setMasterKeyMode(mode vault.MasterKeyMode) { + u.masterKeyMode = mode } func (u *ui) createVaultAction() error { @@ -359,6 +390,14 @@ func (u *ui) unlockAction() error { return nil } +func (u *ui) changeMasterKeyAction() error { + key, err := u.currentMasterKey() + if err != nil { + return err + } + return u.state.ChangeMasterKey(key) +} + func (u *ui) runAction(label string, action func() error) { if err := action(); err != nil { u.errorMessage = err.Error() @@ -392,9 +431,21 @@ func (u *ui) layout(gtx layout.Context) layout.Dimensions { for u.openRemote.Clicked(gtx) { u.runAction("open remote vault", u.openRemoteAction) } + for u.changeMasterKey.Clicked(gtx) { + u.runAction("change master key", u.changeMasterKeyAction) + } for u.unlockVault.Clicked(gtx) { u.runAction("unlock vault", u.unlockAction) } + for u.masterKeyPasswordOnly.Clicked(gtx) { + u.setMasterKeyMode(vault.MasterKeyModePasswordOnly) + } + for u.masterKeyKeyFileOnly.Clicked(gtx) { + u.setMasterKeyMode(vault.MasterKeyModeKeyFileOnly) + } + for u.masterKeyComposite.Clicked(gtx) { + u.setMasterKeyMode(vault.MasterKeyModePasswordAndKeyFile) + } for u.showEntries.Clicked(gtx) { u.showEntriesSection() } @@ -757,7 +808,7 @@ func (u *ui) phoneSlider(gtx layout.Context) layout.Dimensions { y := (gtx.Constraints.Min.Y - handleH) / 2 paint.FillShape(gtx.Ops, color.NRGBA{R: 214, G: 208, B: 197, A: 255}, clip.Rect{Min: image.Pt(0, y+1), Max: image.Pt(gtx.Constraints.Min.X, y+2)}.Op()) paint.FillShape(gtx.Ops, accentColor, clip.RRect{ - Rect: image.Rectangle{Min: image.Pt(x, y), Max: image.Pt(x + handleW, y + handleH)}, + Rect: image.Rectangle{Min: image.Pt(x, y), Max: image.Pt(x+handleW, y+handleH)}, NE: 2, NW: 2, SE: 2, SW: 2, }.Op(gtx.Ops)) return layout.Dimensions{Size: gtx.Constraints.Min} diff --git a/main_test.go b/main_test.go index 8c2e3a8..39b18e3 100644 --- a/main_test.go +++ b/main_test.go @@ -154,6 +154,258 @@ func TestUILifecycleActionsCreateSaveOpenLockAndUnlockLocalVault(t *testing.T) { } } +func TestUIMasterKeyModesCreateOpenAndUnlockLocalVault(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + mode vault.MasterKeyMode + password string + keyFileData []byte + }{ + { + name: "password only", + mode: vault.MasterKeyModePasswordOnly, + password: "correct horse battery staple", + }, + { + name: "key file only", + mode: vault.MasterKeyModeKeyFileOnly, + keyFileData: []byte("key-file-only-material"), + }, + { + name: "composite", + mode: vault.MasterKeyModePasswordAndKeyFile, + password: "correct horse battery staple", + keyFileData: []byte("composite-key-material"), + }, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + keyFile := "" + if len(tt.keyFileData) > 0 { + keyFile = filepath.Join(t.TempDir(), "master.key") + if err := os.WriteFile(keyFile, tt.keyFileData, 0o600); err != nil { + t.Fatalf("WriteFile(master.key) error = %v", err) + } + } + + u := newUIWithSession("desktop", &session.Manager{}) + u.setMasterKeyMode(tt.mode) + u.masterPassword.SetText(tt.password) + u.keyFilePath.SetText(keyFile) + + if err := u.createVaultAction(); err != nil { + t.Fatalf("createVaultAction() error = %v", err) + } + if err := u.state.UpsertEntry(vault.Entry{ + ID: "vault-console", + Title: "Vault Console", + Username: "dannyocean", + Password: "token-1", + URL: "https://vault.crew.example.invalid", + Path: []string{"Root", "Internet"}, + }); err != nil { + t.Fatalf("UpsertEntry() error = %v", err) + } + + path := filepath.Join(t.TempDir(), "keepassgo.kdbx") + u.saveAsPath.SetText(path) + if err := u.saveAsAction(); err != nil { + t.Fatalf("saveAsAction() error = %v", err) + } + + if err := u.lockAction(); err != nil { + t.Fatalf("lockAction() error = %v", err) + } + if err := u.unlockAction(); err != nil { + t.Fatalf("unlockAction() error = %v", err) + } + + reopened := newUIWithSession("desktop", &session.Manager{}) + reopened.setMasterKeyMode(tt.mode) + reopened.masterPassword.SetText(tt.password) + reopened.keyFilePath.SetText(keyFile) + reopened.vaultPath.SetText(path) + if err := reopened.openVaultAction(); err != nil { + t.Fatalf("openVaultAction() error = %v", err) + } + reopened.currentPath = []string{"Root", "Internet"} + reopened.filter() + if got := reopened.filteredTitles(); !slices.Equal(got, []string{"Vault Console"}) { + t.Fatalf("reopened filteredTitles() = %v, want [Vault Console]", got) + } + }) + } +} + +func TestUIChangeMasterKeyModeForExistingVault(t *testing.T) { + t.Parallel() + + updated := filepath.Join(t.TempDir(), "updated.key") + if err := os.WriteFile(updated, []byte("updated-key"), 0o600); err != nil { + t.Fatalf("WriteFile(updated.key) error = %v", err) + } + + u := newUIWithSession("desktop", &session.Manager{}) + u.setMasterKeyMode(vault.MasterKeyModePasswordOnly) + u.masterPassword.SetText("old-password") + if err := u.createVaultAction(); err != nil { + t.Fatalf("createVaultAction() error = %v", err) + } + if err := u.state.UpsertEntry(vault.Entry{ + ID: "vault-console", + Title: "Vault Console", + Path: []string{"Root", "Internet"}, + }); err != nil { + t.Fatalf("UpsertEntry() error = %v", err) + } + + path := filepath.Join(t.TempDir(), "keepassgo.kdbx") + u.saveAsPath.SetText(path) + if err := u.saveAsAction(); err != nil { + t.Fatalf("saveAsAction() error = %v", err) + } + + u.setMasterKeyMode(vault.MasterKeyModePasswordAndKeyFile) + u.masterPassword.SetText("new-password") + u.keyFilePath.SetText(updated) + if err := u.changeMasterKeyAction(); err != nil { + t.Fatalf("changeMasterKeyAction() error = %v", err) + } + if err := u.saveAction(); err != nil { + t.Fatalf("saveAction() error = %v", err) + } + if err := u.lockAction(); err != nil { + t.Fatalf("lockAction() error = %v", err) + } + + u.masterPassword.SetText("old-password") + u.keyFilePath.SetText("") + u.setMasterKeyMode(vault.MasterKeyModePasswordOnly) + u.runAction("unlock vault", u.unlockAction) + if u.errorMessage == "" { + t.Fatal("errorMessage = empty, want visible invalid master key error") + } + + u.setMasterKeyMode(vault.MasterKeyModePasswordAndKeyFile) + u.masterPassword.SetText("new-password") + u.keyFilePath.SetText(updated) + if err := u.unlockAction(); err != nil { + t.Fatalf("unlockAction() with updated key error = %v", err) + } + + reopened := newUIWithSession("desktop", &session.Manager{}) + reopened.setMasterKeyMode(vault.MasterKeyModePasswordAndKeyFile) + reopened.masterPassword.SetText("new-password") + reopened.keyFilePath.SetText(updated) + reopened.vaultPath.SetText(path) + if err := reopened.openVaultAction(); err != nil { + t.Fatalf("openVaultAction() with updated key error = %v", err) + } + reopened.currentPath = []string{"Root", "Internet"} + reopened.filter() + if got := reopened.filteredTitles(); !slices.Equal(got, []string{"Vault Console"}) { + t.Fatalf("reopened filteredTitles() = %v, want [Vault Console]", got) + } +} + +func TestUIMasterKeyValidationErrorsAreVisible(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + mode vault.MasterKeyMode + password string + keyFile string + wantError string + }{ + { + name: "password mode requires password", + mode: vault.MasterKeyModePasswordOnly, + wantError: "master password is required", + }, + { + name: "key file mode requires path", + mode: vault.MasterKeyModeKeyFileOnly, + wantError: "key file is required", + }, + { + name: "composite mode requires password", + mode: vault.MasterKeyModePasswordAndKeyFile, + keyFile: filepath.Join("/tmp", "ignored.key"), + wantError: "master password is required", + }, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + u := newUIWithSession("desktop", &session.Manager{}) + u.setMasterKeyMode(tt.mode) + u.masterPassword.SetText(tt.password) + u.keyFilePath.SetText(tt.keyFile) + + u.runAction("create vault", u.createVaultAction) + + if got := u.errorMessage; got != tt.wantError { + t.Fatalf("errorMessage = %q, want %q", got, tt.wantError) + } + if got := u.statusMessage; got != "" { + t.Fatalf("statusMessage = %q, want empty on validation error", got) + } + }) + } +} + +func TestUIUnreadableAndInvalidMasterKeyErrorsAreVisible(t *testing.T) { + t.Parallel() + + keyFile := filepath.Join(t.TempDir(), "master.key") + if err := os.WriteFile(keyFile, []byte("key-material"), 0o600); err != nil { + t.Fatalf("WriteFile(master.key) error = %v", err) + } + + create := newUIWithSession("desktop", &session.Manager{}) + create.setMasterKeyMode(vault.MasterKeyModeKeyFileOnly) + create.keyFilePath.SetText(keyFile) + if err := create.createVaultAction(); err != nil { + t.Fatalf("createVaultAction() error = %v", err) + } + + path := filepath.Join(t.TempDir(), "keepassgo.kdbx") + create.saveAsPath.SetText(path) + if err := create.saveAsAction(); err != nil { + t.Fatalf("saveAsAction() error = %v", err) + } + + unreadable := newUIWithSession("desktop", &session.Manager{}) + unreadable.setMasterKeyMode(vault.MasterKeyModeKeyFileOnly) + unreadable.keyFilePath.SetText(filepath.Join(t.TempDir(), "missing.key")) + unreadable.runAction("open vault", unreadable.openVaultAction) + if got := unreadable.errorMessage; got == "" || got[:14] != "read key file:" { + t.Fatalf("errorMessage = %q, want read key file error", got) + } + + wrong := newUIWithSession("desktop", &session.Manager{}) + wrong.setMasterKeyMode(vault.MasterKeyModeKeyFileOnly) + wrong.keyFilePath.SetText(filepath.Join(t.TempDir(), "wrong.key")) + if err := os.WriteFile(wrong.keyFilePath.Text(), []byte("wrong-key"), 0o600); err != nil { + t.Fatalf("WriteFile(wrong.key) error = %v", err) + } + wrong.vaultPath.SetText(path) + wrong.runAction("open vault", wrong.openVaultAction) + if got := wrong.errorMessage; got == "" || !bytes.Contains([]byte(got), []byte(vault.ErrInvalidMasterKey.Error())) { + t.Fatalf("errorMessage = %q, want invalid master key error", got) + } +} + func TestUIOpenRemoteAndSaveThroughConfiguredWebDAVTarget(t *testing.T) { t.Parallel() @@ -230,6 +482,7 @@ func TestUIMasterKeyInputSupportsKeyFileAndCompositeKeys(t *testing.T) { } u := newUIWithSession("desktop", &session.Manager{}) + u.setMasterKeyMode(vault.MasterKeyModePasswordAndKeyFile) u.masterPassword.SetText("correct horse battery staple") u.keyFilePath.SetText(keyFile) diff --git a/session/session.go b/session/session.go index 1496d89..2abfac9 100644 --- a/session/session.go +++ b/session/session.go @@ -99,17 +99,17 @@ func (m *Manager) SaveRemote() error { return ErrNoPath } - var encoded bytes.Buffer - if err := vault.SaveKDBXWithConfigAndKey(&encoded, m.model, m.key, m.config); err != nil { - return fmt.Errorf("encode vault: %w", err) + encoded, err := m.persistableBytes() + if err != nil { + return err } - version, err := m.remoteClient.Save(m.remotePath, bytes.NewReader(encoded.Bytes()), m.remoteVersion) + version, err := m.remoteClient.Save(m.remotePath, bytes.NewReader(encoded), m.remoteVersion) if err != nil { return fmt.Errorf("save remote %s: %w", m.remotePath, err) } - m.encoded = encoded.Bytes() + m.encoded = encoded m.remoteVersion = version return nil } @@ -165,16 +165,60 @@ func (m *Manager) Unlock(key vault.MasterKey) error { return nil } -func (m *Manager) saveToPath(path string) error { - var encoded bytes.Buffer - if err := vault.SaveKDBXWithConfigAndKey(&encoded, m.model, m.key, m.config); err != nil { - return fmt.Errorf("encode vault: %w", err) +func (m *Manager) ChangeMasterKey(key vault.MasterKey) error { + var ( + model vault.Model + config *vault.KDBXConfig + err error + ) + + if m.locked { + model, config, err = vault.LoadKDBXWithConfig(bytes.NewReader(m.encoded), m.key) + if err != nil { + return fmt.Errorf("decode locked vault: %w", err) + } + } else { + model = m.model + config = m.config } - if err := os.WriteFile(path, encoded.Bytes(), 0o600); err != nil { + var encoded bytes.Buffer + if err := vault.SaveKDBXWithConfigAndKey(&encoded, model, key, config); err != nil { + return fmt.Errorf("encode vault with updated master key: %w", err) + } + + m.key = key + m.config = config + m.encoded = encoded.Bytes() + if !m.locked { + m.model = model + } + + return nil +} + +func (m *Manager) saveToPath(path string) error { + encoded, err := m.persistableBytes() + if err != nil { + return err + } + + if err := os.WriteFile(path, encoded, 0o600); err != nil { return fmt.Errorf("write %s: %w", path, err) } - m.encoded = encoded.Bytes() + m.encoded = encoded return nil } + +func (m *Manager) persistableBytes() ([]byte, error) { + if m.locked { + return append([]byte(nil), m.encoded...), nil + } + + var encoded bytes.Buffer + if err := vault.SaveKDBXWithConfigAndKey(&encoded, m.model, m.key, m.config); err != nil { + return nil, fmt.Errorf("encode vault: %w", err) + } + return encoded.Bytes(), nil +} diff --git a/session/session_test.go b/session/session_test.go index 1241b18..7324da5 100644 --- a/session/session_test.go +++ b/session/session_test.go @@ -391,6 +391,68 @@ func TestSaveUsesRemoteTargetWhenVaultWasOpenedFromWebDAV(t *testing.T) { } } +func TestChangeMasterKeyReencryptsSavedAndLockedVault(t *testing.T) { + t.Parallel() + + originalKey := vault.MasterKey{Password: "old-password"} + updatedKey := vault.MasterKey{ + Password: "new-password", + KeyFileData: []byte("updated-key-file"), + } + model := vault.Model{ + Entries: []vault.Entry{ + { + ID: "entry-1", + Title: "Vault Console", + Username: "dannyocean", + Password: "token-1", + URL: "https://vault.crew.example.invalid", + Path: []string{"Root", "Internet"}, + }, + }, + } + + path := filepath.Join(t.TempDir(), "keepassgo.kdbx") + + var sess Manager + if err := sess.Create(model, originalKey); err != nil { + t.Fatalf("Create() error = %v", err) + } + if err := sess.SaveAs(path); err != nil { + t.Fatalf("SaveAs() error = %v", err) + } + if err := sess.Lock(); err != nil { + t.Fatalf("Lock() error = %v", err) + } + if err := sess.ChangeMasterKey(updatedKey); err != nil { + t.Fatalf("ChangeMasterKey() error = %v", err) + } + if err := sess.Save(); err != nil { + t.Fatalf("Save() error = %v", err) + } + + if err := sess.Unlock(updatedKey); err != nil { + t.Fatalf("Unlock(updatedKey) error = %v", err) + } + + current, err := sess.Current() + if err != nil { + t.Fatalf("Current() error = %v", err) + } + got := current.EntriesInPath([]string{"Root", "Internet"}) + if len(got) != 1 || got[0].Title != "Vault Console" { + t.Fatalf("Current() entries = %#v, want Vault Console entry after ChangeMasterKey", got) + } + + var reopened Manager + if err := reopened.Open(path, updatedKey); err != nil { + t.Fatalf("Open(updatedKey) error = %v", err) + } + if err := reopened.Open(path, originalKey); !errors.Is(err, vault.ErrInvalidMasterKey) { + t.Fatalf("Open(originalKey) error = %v, want ErrInvalidMasterKey", err) + } +} + func TestSavePreservesOpenedKDBXSecuritySettings(t *testing.T) { t.Parallel() diff --git a/ui_forms.go b/ui_forms.go index fd513a6..fd9f417 100644 --- a/ui_forms.go +++ b/ui_forms.go @@ -3,15 +3,37 @@ package main import ( "strings" - "git.julianfamily.org/keepassgo/appstate" "gioui.org/layout" "gioui.org/unit" "gioui.org/widget" "gioui.org/widget/material" + "git.julianfamily.org/keepassgo/appstate" ) func (u *ui) lifecycleControls(gtx layout.Context) layout.Dimensions { return layout.Flex{Axis: layout.Vertical}.Layout(gtx, + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + lbl := material.Label(u.theme, unit.Sp(12), "MASTER KEY MODE") + lbl.Color = mutedColor + return lbl.Layout(gtx) + }), + layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + return layout.Flex{Spacing: layout.SpaceStart}.Layout(gtx, + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + return tonedButton(gtx, u.theme, &u.masterKeyPasswordOnly, "Password Only") + }), + layout.Rigid(layout.Spacer{Width: unit.Dp(6)}.Layout), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + return tonedButton(gtx, u.theme, &u.masterKeyKeyFileOnly, "Key File Only") + }), + layout.Rigid(layout.Spacer{Width: unit.Dp(6)}.Layout), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + return tonedButton(gtx, u.theme, &u.masterKeyComposite, "Password + Key File") + }), + ) + }), + layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), layout.Rigid(labeledEditor(u.theme, "Master Password", &u.masterPassword, true)), layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), layout.Rigid(labeledEditor(u.theme, "Key File", &u.keyFilePath, false)), @@ -36,9 +58,17 @@ func (u *ui) lifecycleControls(gtx layout.Context) layout.Dimensions { layout.Rigid(layout.Spacer{Width: unit.Dp(6)}.Layout), layout.Rigid(func(gtx layout.Context) layout.Dimensions { return tonedButton(gtx, u.theme, &u.saveVault, "Save") }), layout.Rigid(layout.Spacer{Width: unit.Dp(6)}.Layout), - layout.Rigid(func(gtx layout.Context) layout.Dimensions { return tonedButton(gtx, u.theme, &u.saveAsVault, "Save As") }), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + return tonedButton(gtx, u.theme, &u.saveAsVault, "Save As") + }), layout.Rigid(layout.Spacer{Width: unit.Dp(6)}.Layout), - layout.Rigid(func(gtx layout.Context) layout.Dimensions { return tonedButton(gtx, u.theme, &u.openRemote, "Open Remote") }), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + return tonedButton(gtx, u.theme, &u.openRemote, "Open Remote") + }), + layout.Rigid(layout.Spacer{Width: unit.Dp(6)}.Layout), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + return tonedButton(gtx, u.theme, &u.changeMasterKey, "Change Master Key") + }), layout.Rigid(layout.Spacer{Width: unit.Dp(6)}.Layout), layout.Rigid(func(gtx layout.Context) layout.Dimensions { return tonedButton(gtx, u.theme, &u.unlockVault, "Unlock") }), ) @@ -55,11 +85,17 @@ func (u *ui) groupControls(gtx layout.Context) layout.Dimensions { layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), layout.Rigid(func(gtx layout.Context) layout.Dimensions { return layout.Flex{Spacing: layout.SpaceStart}.Layout(gtx, - layout.Rigid(func(gtx layout.Context) layout.Dimensions { return tonedButton(gtx, u.theme, &u.createGroup, "Create Group") }), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + return tonedButton(gtx, u.theme, &u.createGroup, "Create Group") + }), layout.Rigid(layout.Spacer{Width: unit.Dp(6)}.Layout), - layout.Rigid(func(gtx layout.Context) layout.Dimensions { return tonedButton(gtx, u.theme, &u.renameGroup, "Rename Group") }), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + return tonedButton(gtx, u.theme, &u.renameGroup, "Rename Group") + }), layout.Rigid(layout.Spacer{Width: unit.Dp(6)}.Layout), - layout.Rigid(func(gtx layout.Context) layout.Dimensions { return tonedButton(gtx, u.theme, &u.deleteGroup, "Delete Group") }), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + return tonedButton(gtx, u.theme, &u.deleteGroup, "Delete Group") + }), ) }), ) @@ -93,25 +129,39 @@ func (u *ui) entryEditorPanel(gtx layout.Context) layout.Dimensions { switch u.state.Section { case appstate.SectionTemplates: return layout.Flex{Spacing: layout.SpaceStart}.Layout(gtx, - layout.Rigid(func(gtx layout.Context) layout.Dimensions { return tonedButton(gtx, u.theme, &u.saveTemplate, "Save Template") }), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + return tonedButton(gtx, u.theme, &u.saveTemplate, "Save Template") + }), layout.Rigid(layout.Spacer{Width: unit.Dp(6)}.Layout), - layout.Rigid(func(gtx layout.Context) layout.Dimensions { return tonedButton(gtx, u.theme, &u.deleteTemplate, "Delete Template") }), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + return tonedButton(gtx, u.theme, &u.deleteTemplate, "Delete Template") + }), layout.Rigid(layout.Spacer{Width: unit.Dp(6)}.Layout), - layout.Rigid(func(gtx layout.Context) layout.Dimensions { return tonedButton(gtx, u.theme, &u.instantiateTemplate, "Instantiate") }), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + return tonedButton(gtx, u.theme, &u.instantiateTemplate, "Instantiate") + }), ) case appstate.SectionRecycleBin: return tonedButton(gtx, u.theme, &u.restoreEntry, "Restore Entry") default: return layout.Flex{Spacing: layout.SpaceStart}.Layout(gtx, - layout.Rigid(func(gtx layout.Context) layout.Dimensions { return tonedButton(gtx, u.theme, &u.saveEntry, "Save Entry") }), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + return tonedButton(gtx, u.theme, &u.saveEntry, "Save Entry") + }), layout.Rigid(layout.Spacer{Width: unit.Dp(6)}.Layout), - layout.Rigid(func(gtx layout.Context) layout.Dimensions { return tonedButton(gtx, u.theme, &u.duplicateEntry, "Duplicate") }), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + return tonedButton(gtx, u.theme, &u.duplicateEntry, "Duplicate") + }), layout.Rigid(layout.Spacer{Width: unit.Dp(6)}.Layout), layout.Rigid(func(gtx layout.Context) layout.Dimensions { return tonedButton(gtx, u.theme, &u.deleteEntry, "Delete") }), layout.Rigid(layout.Spacer{Width: unit.Dp(6)}.Layout), - layout.Rigid(func(gtx layout.Context) layout.Dimensions { return tonedButton(gtx, u.theme, &u.generatePassword, "Generate Password") }), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + return tonedButton(gtx, u.theme, &u.generatePassword, "Generate Password") + }), layout.Rigid(layout.Spacer{Width: unit.Dp(6)}.Layout), - layout.Rigid(func(gtx layout.Context) layout.Dimensions { return tonedButton(gtx, u.theme, &u.restoreHistory, "Restore History") }), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + return tonedButton(gtx, u.theme, &u.restoreHistory, "Restore History") + }), ) } }), @@ -120,7 +170,9 @@ func (u *ui) entryEditorPanel(gtx layout.Context) layout.Dimensions { return layout.Flex{Spacing: layout.SpaceStart}.Layout(gtx, layout.Rigid(func(gtx layout.Context) layout.Dimensions { return tonedButton(gtx, u.theme, &u.copyUser, "Copy User") }), layout.Rigid(layout.Spacer{Width: unit.Dp(6)}.Layout), - layout.Rigid(func(gtx layout.Context) layout.Dimensions { return tonedButton(gtx, u.theme, &u.copyPass, "Copy Password") }), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + return tonedButton(gtx, u.theme, &u.copyPass, "Copy Password") + }), layout.Rigid(layout.Spacer{Width: unit.Dp(6)}.Layout), layout.Rigid(func(gtx layout.Context) layout.Dimensions { return tonedButton(gtx, u.theme, &u.copyURL, "Copy URL") }), ) @@ -134,11 +186,17 @@ func (u *ui) entryEditorPanel(gtx layout.Context) layout.Dimensions { layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), layout.Rigid(func(gtx layout.Context) layout.Dimensions { return layout.Flex{Spacing: layout.SpaceStart}.Layout(gtx, - layout.Rigid(func(gtx layout.Context) layout.Dimensions { return tonedButton(gtx, u.theme, &u.addAttachment, "Add Attachment") }), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + return tonedButton(gtx, u.theme, &u.addAttachment, "Add Attachment") + }), layout.Rigid(layout.Spacer{Width: unit.Dp(6)}.Layout), - layout.Rigid(func(gtx layout.Context) layout.Dimensions { return tonedButton(gtx, u.theme, &u.removeAttachment, "Remove Attachment") }), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + return tonedButton(gtx, u.theme, &u.removeAttachment, "Remove Attachment") + }), layout.Rigid(layout.Spacer{Width: unit.Dp(6)}.Layout), - layout.Rigid(func(gtx layout.Context) layout.Dimensions { return tonedButton(gtx, u.theme, &u.exportAttachment, "Export Attachment") }), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + return tonedButton(gtx, u.theme, &u.exportAttachment, "Export Attachment") + }), ) }), ) diff --git a/vault/masterkey.go b/vault/masterkey.go new file mode 100644 index 0000000..aed1b8b --- /dev/null +++ b/vault/masterkey.go @@ -0,0 +1,13 @@ +package vault + +// MasterKeyMode identifies which key material the user intends to provide. +type MasterKeyMode string + +const ( + // MasterKeyModePasswordOnly requires a master password and no key file. + MasterKeyModePasswordOnly MasterKeyMode = "password-only" + // MasterKeyModeKeyFileOnly requires a key file and no password. + MasterKeyModeKeyFileOnly MasterKeyMode = "key-file-only" + // MasterKeyModePasswordAndKeyFile requires both password and key file. + MasterKeyModePasswordAndKeyFile MasterKeyMode = "password-and-key-file" +)