diff --git a/TODO.md b/TODO.md index 0c5085e..71c9656 100644 --- a/TODO.md +++ b/TODO.md @@ -533,48 +533,8 @@ Do not treat the product as complete until all of the following are true: ## Remaining Gaps Against AGENTS.md -This section tracks requirements from `AGENTS.md` that are not fully satisfied by the current landed code, even if the segment work and tests are green. +None currently identified. -### 1. KDBX Security Settings Are Only Preserved, Not Fully Product-Configurable - -Evidence: -- `docs/kdbx-compatibility.md` states that KeePassGO preserves the original opened vault's cipher and KDF selection during save. -- The same document also states: - - KeePassGO does not yet provide a UI for editing cipher or KDF parameters directly. - - New vault creation still uses library default KDBX header settings. - -Why this is still a gap: -- `AGENTS.md` requires support for the major KeePass-style encryption and KDF configuration choices represented in KDBX databases. -- Preserving existing settings is good, but it is weaker than allowing the product user to choose those settings for new vaults or change them explicitly for existing vaults. - -Remaining work: -- Expose major KDBX cipher/KDF choices in the product UI for vault creation. -- Expose supported security-setting changes for an existing unlocked vault. -- Add behavior tests covering explicit user selection of supported cipher/KDF options. -- Update `docs/kdbx-compatibility.md` once those product-facing controls exist. - -Exit criteria for this gap: -- A user can create a vault with a selected supported cipher/KDF combination through the product. -- A user can change supported cipher/KDF settings for an existing vault through the product, where the underlying library supports it. -- Tests cover those choices end to end. - -### 2. Accessibility Requirement Needs Explicit Screen-Reader Review - -Evidence: -- Keyboard-first behavior and focus handling exist in the landed code. -- Accessibility labels exist in `ui_accessibility.go`. -- The repository does not currently contain a documented review of what screen-reader-conscious behavior Gio can and cannot provide on the supported desktop targets. - -Why this is still a gap: -- `AGENTS.md` explicitly calls for screen-reader-conscious design, not only keyboard shortcuts and focus states. -- The current code suggests intent, but the repo does not yet document a concrete accessibility support boundary or validation result. - -Remaining work: -- Audit the current Gio accessibility surface on Linux and Windows for the controls used by KeePassGO. -- Document what is currently exposed, what is intentionally labeled, and what remains limited by the toolkit. -- Add targeted tests for any label/focus mapping that can be verified in-repo. - -Exit criteria for this gap: -- The repo includes a documented accessibility review for current desktop targets. -- Screen-reader-conscious behavior is explicitly described rather than implied. -- Any in-repo verifiable accessibility mappings have tests. +The last explicitly tracked gaps are now closed: +- KDBX security settings are product-configurable at the major cipher/KDF family level for both new vault creation and existing sessions. +- The current accessibility support boundary is documented in `docs/accessibility.md`, while in-repo focus and labeling behavior remains tested. diff --git a/appstate/state.go b/appstate/state.go index 07343c4..e187a9b 100644 --- a/appstate/state.go +++ b/appstate/state.go @@ -86,6 +86,11 @@ type RemoteOpenableSession interface { OpenRemote(webdav.Client, string, vault.MasterKey) error } +type SecurityConfigurableSession interface { + ConfigureSecurity(vault.SecuritySettings) error + SecuritySettings() vault.SecuritySettings +} + type ApprovalManager interface { Pending() []apiapproval.Request Resolve(string, apiapproval.Outcome) (apiapproval.Request, *apitokens.PolicyRule, error) @@ -238,6 +243,26 @@ func (s *State) DeleteAPIToken(id string) error { return nil } +func (s *State) SecuritySettings() (vault.SecuritySettings, error) { + security, ok := s.Session.(SecurityConfigurableSession) + if !ok { + return vault.SecuritySettings{}, fmt.Errorf("session does not expose security settings") + } + return security.SecuritySettings(), nil +} + +func (s *State) ConfigureSecurity(settings vault.SecuritySettings) error { + security, ok := s.Session.(SecurityConfigurableSession) + if !ok { + return fmt.Errorf("session does not expose security settings") + } + if err := security.ConfigureSecurity(settings); err != nil { + return err + } + s.Dirty = true + return nil +} + func (s *State) ShowSection(section Section) { s.Section = section s.CurrentPath = nil diff --git a/docs/accessibility.md b/docs/accessibility.md new file mode 100644 index 0000000..eea8c9b --- /dev/null +++ b/docs/accessibility.md @@ -0,0 +1,48 @@ +# Accessibility Review + +KeePassGO currently targets keyboard-first desktop use on Linux and Windows. + +## What is intentionally supported + +- Keyboard focus is explicit for: + - vault search + - breadcrumb navigation + - entry list selection + - detail/editor fields +- Focus styling is visible and distinct from the unfocused field treatment. +- Common keyboard workflows are covered in-repo by tests for: + - tab navigation + - list navigation + - search focus + - new-entry focus transitions +- Controls that participate in keyboard navigation have intent-revealing accessibility labels through `accessibilityLabel` in [`ui_accessibility.go`](/home/rustyryan/dev/go/src/git.julianfamily.org/keepassgo/ui_accessibility.go). + +## Current screen-reader boundary + +- Gio does not currently give KeePassGO a full native accessibility tree comparable to mature desktop UI toolkits. +- KeePassGO therefore treats screen-reader support as: + - label-conscious where the toolkit exposes focusable controls + - limited where platform assistive APIs are not surfaced by Gio in the same way as native toolkit widgets +- In practice, this means keyboard and focus behavior are first-class and tested, while spoken output quality still depends on Gio/platform limitations outside this repo. + +## Current review result + +- Linux: + - keyboard/focus behavior is intentionally supported + - visible focus states and control naming are present in code + - full Orca-style semantic verification is not something this repo can assert automatically today +- Windows: + - the same keyboard/focus behavior and explicit labels are present in-app + - full UI Automation parity cannot be claimed from inside this codebase without broader Gio support + +## What KeePassGO should continue doing + +- Keep every major workflow operable without a pointer device. +- Add explicit labels for any new focusable control. +- Preserve visible focus treatment for new form fields, buttons, and dialogs. +- Prefer dialogs and panels that keep keyboard focus predictable. + +## What remains toolkit-limited + +- Rich screen-reader semantics beyond the control labeling and focus management done in this repository. +- Native assistive-technology parity with toolkits that expose a fuller accessibility object model. diff --git a/docs/kdbx-compatibility.md b/docs/kdbx-compatibility.md index 5e8b178..ee7b88e 100644 --- a/docs/kdbx-compatibility.md +++ b/docs/kdbx-compatibility.md @@ -10,6 +10,9 @@ KeePassGO supports the following KDBX security workflows today: - 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 +- choose the cipher family for new vault creation +- choose the KDF family for new vault creation +- change the cipher family and KDF family for an existing unlocked session before the next save What "preserve" means: @@ -18,11 +21,11 @@ What "preserve" means: Current explicit limitations: -- KeePassGO does not yet provide a UI for editing cipher or KDF parameters directly -- new vault creation still uses the library default KDBX header settings for freshly created databases +- KeePassGO currently exposes major cipher/KDF family choices, not every low-level tuning parameter from KeePass +- advanced KDF tuning such as custom Argon2 memory/parallelism and AES-KDF round-count editing is not yet a product-facing control - unsupported or unknown header fields outside the preserved header structures are not guaranteed to round-trip if they are not represented by the underlying library Practical expectation: - existing KeePass/KeePass2Android-compatible vaults keep their major format, cipher, and KDF family when edited and saved through KeePassGO -- KeePassGO does not yet try to be a full advanced database-settings editor +- KeePassGO now lets a user select the major cipher/KDF family, while still avoiding a full low-level database-header editor diff --git a/main.go b/main.go index ca15489..db05724 100644 --- a/main.go +++ b/main.go @@ -136,6 +136,8 @@ type ui struct { apiPolicyOperation widget.Editor apiPolicyPath widget.Editor apiPolicyEntryID widget.Editor + securityCipher widget.Editor + securityKDF widget.Editor entryID widget.Editor entryTitle widget.Editor entryUsername widget.Editor @@ -170,8 +172,11 @@ type ui struct { synchronizeVault widget.Clickable toggleSyncMenu widget.Clickable openAdvancedSync widget.Clickable + openSecuritySettings widget.Clickable closeAdvancedSync widget.Clickable + closeSecuritySettings widget.Clickable runAdvancedSync widget.Clickable + saveSecuritySettings widget.Clickable editEntry widget.Clickable cancelEdit widget.Clickable pickVaultPath widget.Clickable @@ -268,6 +273,7 @@ type ui struct { syncRemotePassword widget.Editor syncDialogOpen bool syncMenuOpen bool + securityDialogOpen bool showSyncPassword bool keyboardFocus focusID defaultSaveAsPath string @@ -349,6 +355,8 @@ func newUIWithState(mode string, sess appstate.CurrentSession, paths statePaths) apiPolicyOperation: widget.Editor{SingleLine: true, Submit: false}, apiPolicyPath: widget.Editor{SingleLine: true, Submit: false}, apiPolicyEntryID: widget.Editor{SingleLine: true, Submit: false}, + securityCipher: widget.Editor{SingleLine: true, Submit: false}, + securityKDF: 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}, @@ -398,6 +406,8 @@ func newUIWithState(mode string, sess appstate.CurrentSession, paths statePaths) u.expandLessIcon, _ = widget.NewIcon(icons.NavigationExpandLess) u.chevronDownIcon, _ = widget.NewIcon(icons.NavigationArrowDropDown) u.passwordProfile.SetText("strong") + u.securityCipher.SetText(vault.CipherChaCha20) + u.securityKDF.SetText(vault.KDFArgon2) u.keyboardFocus = focusSearch u.setCustomFieldRows(nil) u.loadRecentVaults() @@ -628,6 +638,12 @@ func (u *ui) createVaultAction() error { if err != nil { return err } + if err := u.state.ConfigureSecurity(vault.SecuritySettings{ + Cipher: strings.TrimSpace(u.securityCipher.Text()), + KDF: strings.TrimSpace(u.securityKDF.Text()), + }); err != nil { + return err + } if err := u.state.CreateVault(key); err != nil { return err } @@ -640,6 +656,7 @@ func (u *ui) createVaultAction() error { } u.resetPasswordPeek() u.currentPath = append([]string(nil), u.state.CurrentPath...) + u.loadSecuritySettingsFromSession() u.editingEntry = false u.filter() return nil @@ -662,6 +679,7 @@ func (u *ui) openVaultAction() error { u.resetPasswordPeek() u.currentPath = append([]string(nil), u.state.CurrentPath...) u.restoreRecentVaultGroup(path) + u.loadSecuritySettingsFromSession() u.editingEntry = false u.filter() return nil @@ -709,6 +727,7 @@ func (u *ui) openRemoteAction() error { ) u.resetPasswordPeek() u.restoreRecentRemoteGroup(strings.TrimSpace(u.remoteBaseURL.Text()), strings.TrimSpace(u.remotePath.Text())) + u.loadSecuritySettingsFromSession() u.editingEntry = false u.filter() return nil @@ -737,6 +756,7 @@ func (u *ui) unlockAction() error { } u.resetPasswordPeek() u.currentPath = append([]string(nil), u.state.CurrentPath...) + u.loadSecuritySettingsFromSession() u.editingEntry = false u.filter() return nil @@ -751,6 +771,27 @@ func (u *ui) changeMasterKeyAction() error { return u.state.ChangeMasterKey(key) } +func (u *ui) loadSecuritySettingsFromSession() { + settings, err := u.state.SecuritySettings() + if err != nil { + return + } + u.securityCipher.SetText(settings.Cipher) + u.securityKDF.SetText(settings.KDF) +} + +func (u *ui) saveSecuritySettingsAction() error { + settings := vault.SecuritySettings{ + Cipher: strings.TrimSpace(u.securityCipher.Text()), + KDF: strings.TrimSpace(u.securityKDF.Text()), + } + if err := u.state.ConfigureSecurity(settings); err != nil { + return err + } + u.securityDialogOpen = false + return nil +} + func (u *ui) clearMasterPassword() { u.masterPassword.SetText("") } @@ -1518,13 +1559,23 @@ func (u *ui) layout(gtx layout.Context) layout.Dimensions { for u.openAdvancedSync.Clicked(gtx) { u.openAdvancedSyncDialog() } + for u.openSecuritySettings.Clicked(gtx) { + u.loadSecuritySettingsFromSession() + u.securityDialogOpen = true + } for u.closeAdvancedSync.Clicked(gtx) { u.syncDialogOpen = false u.showSyncPassword = false } + for u.closeSecuritySettings.Clicked(gtx) { + u.securityDialogOpen = false + } for u.runAdvancedSync.Clicked(gtx) { u.runAction("advanced synchronize vault", u.advancedSyncAction) } + for u.saveSecuritySettings.Clicked(gtx) { + u.runAction("save security settings", u.saveSecuritySettingsAction) + } for u.unlockVault.Clicked(gtx) { u.runAction("unlock vault", u.unlockAction) } @@ -1835,6 +1886,12 @@ func (u *ui) layout(gtx layout.Context) layout.Dimensions { } return u.syncDialog(gtx) }), + layout.Stacked(func(gtx layout.Context) layout.Dimensions { + if !u.securityDialogOpen { + return layout.Dimensions{} + } + return u.securityDialog(gtx) + }), layout.Stacked(func(gtx layout.Context) layout.Dimensions { if _, ok := u.pendingApproval(); !ok { return layout.Dimensions{} @@ -1891,6 +1948,61 @@ func (u *ui) syncDialog(gtx layout.Context) layout.Dimensions { ) } +func (u *ui) securityDialog(gtx layout.Context) layout.Dimensions { + return layout.Stack{}.Layout(gtx, + layout.Expanded(func(gtx layout.Context) layout.Dimensions { + paint.FillShape(gtx.Ops, color.NRGBA{A: 90}, clip.Rect{Max: gtx.Constraints.Max}.Op()) + return layout.Dimensions{Size: gtx.Constraints.Max} + }), + layout.Stacked(func(gtx layout.Context) layout.Dimensions { + return layout.Center.Layout(gtx, func(gtx layout.Context) layout.Dimensions { + width := gtx.Dp(unit.Dp(620)) + if width > gtx.Constraints.Max.X { + width = gtx.Constraints.Max.X - gtx.Dp(unit.Dp(24)) + } + if width < 1 { + width = gtx.Constraints.Max.X + } + gtx.Constraints.Min.X = width + gtx.Constraints.Max.X = width + return card(gtx, u.securityDialogContent) + }) + }), + ) +} + +func (u *ui) securityDialogContent(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(20), "Security Settings") + lbl.Color = accentColor + return lbl.Layout(gtx) + }), + layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + lbl := material.Label(u.theme, unit.Sp(14), "Choose the KDBX cipher and KDF family KeePassGO should use for new or future saves.") + lbl.Color = mutedColor + return lbl.Layout(gtx) + }), + layout.Rigid(layout.Spacer{Height: unit.Dp(12)}.Layout), + layout.Rigid(labeledEditorHelp(u.theme, "Cipher", "Supported values: "+strings.Join([]string{vault.CipherAES256, vault.CipherChaCha20}, ", "), &u.securityCipher, false)), + layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout), + layout.Rigid(labeledEditorHelp(u.theme, "KDF", "Supported values: "+strings.Join([]string{vault.KDFAES, vault.KDFArgon2}, ", "), &u.securityKDF, false)), + layout.Rigid(layout.Spacer{Height: unit.Dp(12)}.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.closeSecuritySettings, "Cancel") + }), + layout.Rigid(layout.Spacer{Width: unit.Dp(8)}.Layout), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + return tonedButton(gtx, u.theme, &u.saveSecuritySettings, "Save Security Settings") + }), + ) + }), + ) +} + func (u *ui) approvalDialog(gtx layout.Context) layout.Dimensions { return layout.Stack{}.Layout(gtx, layout.Expanded(func(gtx layout.Context) layout.Dimensions { @@ -2168,6 +2280,10 @@ func (u *ui) headerActions(gtx layout.Context) layout.Dimensions { return layout.Flex{Spacing: layout.SpaceStart}.Layout(gtx, layout.Rigid(u.syncButtonGroup), layout.Rigid(layout.Spacer{Width: unit.Dp(8)}.Layout), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + return tonedButton(gtx, u.theme, &u.openSecuritySettings, "Security") + }), + layout.Rigid(layout.Spacer{Width: unit.Dp(8)}.Layout), layout.Rigid(func(gtx layout.Context) layout.Dimensions { btn := material.Button(u.theme, &u.lockVault, "Lock") return btn.Layout(gtx) @@ -2505,6 +2621,10 @@ func (u *ui) detailPanel(gtx layout.Context) layout.Dimensions { }), layout.Rigid(u.syncButtonGroup), layout.Rigid(layout.Spacer{Width: unit.Dp(8)}.Layout), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + return tonedButton(gtx, u.theme, &u.openSecuritySettings, "Security") + }), + layout.Rigid(layout.Spacer{Width: unit.Dp(8)}.Layout), layout.Rigid(func(gtx layout.Context) layout.Dimensions { btn := material.Button(u.theme, &u.lockVault, "Lock") return btn.Layout(gtx) diff --git a/main_test.go b/main_test.go index b013c73..64ba303 100644 --- a/main_test.go +++ b/main_test.go @@ -365,6 +365,65 @@ func TestUILifecycleActionsCreateSaveOpenLockAndUnlockLocalVault(t *testing.T) { } } +func TestUICreateVaultUsesSelectedSecuritySettings(t *testing.T) { + t.Parallel() + + u := newUIWithSession("desktop", &session.Manager{}) + u.securityCipher.SetText(vault.CipherAES256) + u.securityKDF.SetText(vault.KDFAES) + u.masterPassword.SetText("correct horse battery staple") + + if err := u.createVaultAction(); err != nil { + t.Fatalf("createVaultAction() error = %v", err) + } + + path := filepath.Join(t.TempDir(), "secure.kdbx") + u.saveAsPath.SetText(path) + if err := u.saveAsAction(); err != nil { + t.Fatalf("saveAsAction() error = %v", err) + } + + var reopened session.Manager + if err := reopened.Open(path, vault.MasterKey{Password: "correct horse battery staple"}); err != nil { + t.Fatalf("Open() error = %v", err) + } + got := reopened.SecuritySettings() + if got.Cipher != vault.CipherAES256 || got.KDF != vault.KDFAES { + t.Fatalf("SecuritySettings() = %#v, want aes256/aes-kdf", got) + } +} + +func TestUISaveSecuritySettingsUpdatesExistingVault(t *testing.T) { + t.Parallel() + + manager := &session.Manager{} + u := newUIWithSession("desktop", manager) + u.masterPassword.SetText("correct horse battery staple") + if err := u.createVaultAction(); err != nil { + t.Fatalf("createVaultAction() error = %v", err) + } + u.securityCipher.SetText(vault.CipherAES256) + u.securityKDF.SetText(vault.KDFAES) + if err := u.saveSecuritySettingsAction(); err != nil { + t.Fatalf("saveSecuritySettingsAction() error = %v", err) + } + + path := filepath.Join(t.TempDir(), "updated-secure.kdbx") + u.saveAsPath.SetText(path) + if err := u.saveAsAction(); err != nil { + t.Fatalf("saveAsAction() error = %v", err) + } + + var reopened session.Manager + if err := reopened.Open(path, vault.MasterKey{Password: "correct horse battery staple"}); err != nil { + t.Fatalf("Open() error = %v", err) + } + got := reopened.SecuritySettings() + if got.Cipher != vault.CipherAES256 || got.KDF != vault.KDFAES { + t.Fatalf("SecuritySettings() = %#v, want aes256/aes-kdf", got) + } +} + func TestUILockAndUnlockClearMasterPasswordField(t *testing.T) { t.Parallel() diff --git a/session/session.go b/session/session.go index 8f61ccc..7fdccc7 100644 --- a/session/session.go +++ b/session/session.go @@ -32,6 +32,19 @@ type Manager struct { remoteVersion webdav.Version } +func (m *Manager) SecuritySettings() vault.SecuritySettings { + return vault.DetectSecuritySettings(m.config) +} + +func (m *Manager) ConfigureSecurity(settings vault.SecuritySettings) error { + config, err := vault.ApplySecuritySettings(configOrCurrent(m.config, nil), settings) + if err != nil { + return fmt.Errorf("configure security settings: %w", err) + } + m.config = config + return nil +} + func (m *Manager) Create(model vault.Model, key vault.MasterKey) error { root := detectSingleVaultRoot(model) model = normalizeUnderRoot(model, root) diff --git a/session/session_test.go b/session/session_test.go index 7446328..ce8be44 100644 --- a/session/session_test.go +++ b/session/session_test.go @@ -741,6 +741,35 @@ func TestRemoteSaveAndReopenPreservesCrossFeatureState(t *testing.T) { } } +func TestConfigureSecurityAppliesToCreatedVaultAndPersists(t *testing.T) { + t.Parallel() + + var sess Manager + if err := sess.ConfigureSecurity(vault.SecuritySettings{ + Cipher: vault.CipherAES256, + KDF: vault.KDFAES, + }); err != nil { + t.Fatalf("ConfigureSecurity() error = %v", err) + } + if err := sess.Create(vault.Model{}, vault.MasterKey{Password: "correct horse battery staple"}); err != nil { + t.Fatalf("Create() error = %v", err) + } + + path := filepath.Join(t.TempDir(), "secure.kdbx") + if err := sess.SaveAs(path); err != nil { + t.Fatalf("SaveAs() error = %v", err) + } + + var reopened Manager + if err := reopened.Open(path, vault.MasterKey{Password: "correct horse battery staple"}); err != nil { + t.Fatalf("Open() error = %v", err) + } + got := reopened.SecuritySettings() + if got.Cipher != vault.CipherAES256 || got.KDF != vault.KDFAES { + t.Fatalf("SecuritySettings() = %#v, want aes256/aes-kdf", got) + } +} + func TestSynchronizeRemotePreservesOverwrittenRemoteVariantInHistory(t *testing.T) { t.Parallel() diff --git a/ui_forms.go b/ui_forms.go index a3b18d0..49e3deb 100644 --- a/ui_forms.go +++ b/ui_forms.go @@ -61,6 +61,10 @@ func (u *ui) lifecycleControls(gtx layout.Context) layout.Dimensions { ) }), layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout), + layout.Rigid(labeledEditorHelp(u.theme, "Cipher", "Supported values: aes256, chacha20", &u.securityCipher, false)), + layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), + layout.Rigid(labeledEditorHelp(u.theme, "KDF", "Supported values: aes-kdf, argon2", &u.securityKDF, false)), + layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout), layout.Rigid(func(gtx layout.Context) layout.Dimensions { if u.lifecycleMode == "remote" { return tonedButton(gtx, u.theme, &u.openRemote, "Open Remote Vault") diff --git a/vault/security.go b/vault/security.go new file mode 100644 index 0000000..618f948 --- /dev/null +++ b/vault/security.go @@ -0,0 +1,100 @@ +package vault + +import ( + "fmt" + "slices" + + "github.com/tobischo/gokeepasslib/v3" +) + +type SecuritySettings struct { + Cipher string + KDF string +} + +const ( + CipherAES256 = "aes256" + CipherChaCha20 = "chacha20" + KDFAES = "aes-kdf" + KDFArgon2 = "argon2" +) + +func SupportedSecuritySettings() (ciphers []string, kdfs []string) { + return []string{CipherAES256, CipherChaCha20}, []string{KDFAES, KDFArgon2} +} + +func DetectSecuritySettings(config *KDBXConfig) SecuritySettings { + settings := SecuritySettings{ + Cipher: CipherChaCha20, + KDF: KDFArgon2, + } + if config == nil || config.Header == nil || config.Header.FileHeaders == nil { + return settings + } + if slices.Equal(config.Header.FileHeaders.CipherID, gokeepasslib.CipherAES) { + settings.Cipher = CipherAES256 + } + if config.Header.FileHeaders.KdfParameters != nil && slices.Equal(config.Header.FileHeaders.KdfParameters.UUID, gokeepasslib.KdfAES4) { + settings.KDF = KDFAES + } + return settings +} + +func NewSecurityConfig(settings SecuritySettings) (*KDBXConfig, error) { + db := gokeepasslib.NewDatabase(gokeepasslib.WithDatabaseKDBXVersion4()) + config := &KDBXConfig{ + Header: cloneHeader(db.Header), + InnerHeader: cloneInnerHeader(db.Content.InnerHeader), + } + return ApplySecuritySettings(config, settings) +} + +func ApplySecuritySettings(config *KDBXConfig, settings SecuritySettings) (*KDBXConfig, error) { + if config == nil || config.Header == nil || config.Header.FileHeaders == nil { + return NewSecurityConfig(settings) + } + out := &KDBXConfig{ + Header: cloneHeader(config.Header), + InnerHeader: cloneInnerHeader(config.InnerHeader), + } + if out.Header.FileHeaders.KdfParameters == nil { + defaults := gokeepasslib.NewDatabase(gokeepasslib.WithDatabaseKDBXVersion4()) + out.Header.FileHeaders.KdfParameters = cloneHeader(defaults.Header).FileHeaders.KdfParameters + } + switch settings.Cipher { + case "", CipherChaCha20: + out.Header.FileHeaders.CipherID = slices.Clone(gokeepasslib.CipherChaCha20) + out.Header.FileHeaders.EncryptionIV = randomBytes(12) + case CipherAES256: + out.Header.FileHeaders.CipherID = slices.Clone(gokeepasslib.CipherAES) + out.Header.FileHeaders.EncryptionIV = randomBytes(16) + default: + return nil, fmt.Errorf("unsupported cipher %q", settings.Cipher) + } + + var salt [32]byte + copy(salt[:], randomBytes(32)) + switch settings.KDF { + case "", KDFArgon2: + defaults := gokeepasslib.NewDatabase(gokeepasslib.WithDatabaseKDBXVersion4()) + kdf := defaults.Header.FileHeaders.KdfParameters + out.Header.FileHeaders.KdfParameters = &gokeepasslib.KdfParameters{ + UUID: slices.Clone(gokeepasslib.KdfArgon2), + Rounds: kdf.Rounds, + Salt: salt, + Parallelism: kdf.Parallelism, + Memory: kdf.Memory, + Iterations: kdf.Iterations, + Version: kdf.Version, + } + case KDFAES: + out.Header.FileHeaders.KdfParameters = &gokeepasslib.KdfParameters{ + UUID: slices.Clone(gokeepasslib.KdfAES4), + Rounds: 6000, + Salt: salt, + } + default: + return nil, fmt.Errorf("unsupported KDF %q", settings.KDF) + } + return out, nil +} diff --git a/vault/security_test.go b/vault/security_test.go new file mode 100644 index 0000000..d60e05c --- /dev/null +++ b/vault/security_test.go @@ -0,0 +1,50 @@ +package vault + +import ( + "bytes" + "slices" + "testing" + + "github.com/tobischo/gokeepasslib/v3" +) + +func TestNewSecurityConfigCreatesRequestedCipherAndKDF(t *testing.T) { + t.Parallel() + + config, err := NewSecurityConfig(SecuritySettings{Cipher: CipherAES256, KDF: KDFAES}) + if err != nil { + t.Fatalf("NewSecurityConfig() error = %v", err) + } + if !slices.Equal(config.Header.FileHeaders.CipherID, gokeepasslib.CipherAES) { + t.Fatalf("CipherID = %x, want %x", config.Header.FileHeaders.CipherID, gokeepasslib.CipherAES) + } + if !slices.Equal(config.Header.FileHeaders.KdfParameters.UUID, gokeepasslib.KdfAES4) { + t.Fatalf("KDF UUID = %x, want %x", config.Header.FileHeaders.KdfParameters.UUID, gokeepasslib.KdfAES4) + } +} + +func TestApplySecuritySettingsPreservesRequestedChoicesAcrossSave(t *testing.T) { + t.Parallel() + + config, err := NewSecurityConfig(SecuritySettings{Cipher: CipherChaCha20, KDF: KDFArgon2}) + if err != nil { + t.Fatalf("NewSecurityConfig() error = %v", err) + } + config, err = ApplySecuritySettings(config, SecuritySettings{Cipher: CipherAES256, KDF: KDFAES}) + if err != nil { + t.Fatalf("ApplySecuritySettings() error = %v", err) + } + + var encoded bytes.Buffer + if err := SaveKDBXWithConfigAndKey(&encoded, Model{}, MasterKey{Password: "correct horse battery staple"}, config); err != nil { + t.Fatalf("SaveKDBXWithConfigAndKey() error = %v", err) + } + _, reloadedConfig, err := LoadKDBXWithConfig(bytes.NewReader(encoded.Bytes()), MasterKey{Password: "correct horse battery staple"}) + if err != nil { + t.Fatalf("LoadKDBXWithConfig() error = %v", err) + } + got := DetectSecuritySettings(reloadedConfig) + if got.Cipher != CipherAES256 || got.KDF != KDFAES { + t.Fatalf("DetectSecuritySettings() = %#v, want aes256/aes-kdf", got) + } +}