Add configurable vault security settings

This commit is contained in:
Joe Julian
2026-03-30 07:58:27 -07:00
parent 84f39b99de
commit b2c26622e8
11 changed files with 458 additions and 47 deletions
+4 -44
View File
@@ -533,48 +533,8 @@ Do not treat the product as complete until all of the following are true:
## Remaining Gaps Against AGENTS.md ## 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 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.
Evidence: - The current accessibility support boundary is documented in `docs/accessibility.md`, while in-repo focus and labeling behavior remains tested.
- `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.
+25
View File
@@ -86,6 +86,11 @@ type RemoteOpenableSession interface {
OpenRemote(webdav.Client, string, vault.MasterKey) error OpenRemote(webdav.Client, string, vault.MasterKey) error
} }
type SecurityConfigurableSession interface {
ConfigureSecurity(vault.SecuritySettings) error
SecuritySettings() vault.SecuritySettings
}
type ApprovalManager interface { type ApprovalManager interface {
Pending() []apiapproval.Request Pending() []apiapproval.Request
Resolve(string, apiapproval.Outcome) (apiapproval.Request, *apitokens.PolicyRule, error) Resolve(string, apiapproval.Outcome) (apiapproval.Request, *apitokens.PolicyRule, error)
@@ -238,6 +243,26 @@ func (s *State) DeleteAPIToken(id string) error {
return nil 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) { func (s *State) ShowSection(section Section) {
s.Section = section s.Section = section
s.CurrentPath = nil s.CurrentPath = nil
+48
View File
@@ -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.
+6 -3
View File
@@ -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 KDBX format version during save
- preserve the original opened vault's cipher selection during save - preserve the original opened vault's cipher selection during save
- preserve the original opened vault's KDF 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: What "preserve" means:
@@ -18,11 +21,11 @@ What "preserve" means:
Current explicit limitations: Current explicit limitations:
- KeePassGO does not yet provide a UI for editing cipher or KDF parameters directly - KeePassGO currently exposes major cipher/KDF family choices, not every low-level tuning parameter from KeePass
- new vault creation still uses the library default KDBX header settings for freshly created databases - 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 - 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: Practical expectation:
- existing KeePass/KeePass2Android-compatible vaults keep their major format, cipher, and KDF family when edited and saved through KeePassGO - 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
+120
View File
@@ -136,6 +136,8 @@ type ui struct {
apiPolicyOperation widget.Editor apiPolicyOperation widget.Editor
apiPolicyPath widget.Editor apiPolicyPath widget.Editor
apiPolicyEntryID widget.Editor apiPolicyEntryID widget.Editor
securityCipher widget.Editor
securityKDF widget.Editor
entryID widget.Editor entryID widget.Editor
entryTitle widget.Editor entryTitle widget.Editor
entryUsername widget.Editor entryUsername widget.Editor
@@ -170,8 +172,11 @@ type ui struct {
synchronizeVault widget.Clickable synchronizeVault widget.Clickable
toggleSyncMenu widget.Clickable toggleSyncMenu widget.Clickable
openAdvancedSync widget.Clickable openAdvancedSync widget.Clickable
openSecuritySettings widget.Clickable
closeAdvancedSync widget.Clickable closeAdvancedSync widget.Clickable
closeSecuritySettings widget.Clickable
runAdvancedSync widget.Clickable runAdvancedSync widget.Clickable
saveSecuritySettings widget.Clickable
editEntry widget.Clickable editEntry widget.Clickable
cancelEdit widget.Clickable cancelEdit widget.Clickable
pickVaultPath widget.Clickable pickVaultPath widget.Clickable
@@ -268,6 +273,7 @@ type ui struct {
syncRemotePassword widget.Editor syncRemotePassword widget.Editor
syncDialogOpen bool syncDialogOpen bool
syncMenuOpen bool syncMenuOpen bool
securityDialogOpen bool
showSyncPassword bool showSyncPassword bool
keyboardFocus focusID keyboardFocus focusID
defaultSaveAsPath string defaultSaveAsPath string
@@ -349,6 +355,8 @@ func newUIWithState(mode string, sess appstate.CurrentSession, paths statePaths)
apiPolicyOperation: widget.Editor{SingleLine: true, Submit: false}, apiPolicyOperation: widget.Editor{SingleLine: true, Submit: false},
apiPolicyPath: widget.Editor{SingleLine: true, Submit: false}, apiPolicyPath: widget.Editor{SingleLine: true, Submit: false},
apiPolicyEntryID: 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}, entryID: widget.Editor{SingleLine: true, Submit: false},
entryTitle: widget.Editor{SingleLine: true, Submit: false}, entryTitle: widget.Editor{SingleLine: true, Submit: false},
entryUsername: 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.expandLessIcon, _ = widget.NewIcon(icons.NavigationExpandLess)
u.chevronDownIcon, _ = widget.NewIcon(icons.NavigationArrowDropDown) u.chevronDownIcon, _ = widget.NewIcon(icons.NavigationArrowDropDown)
u.passwordProfile.SetText("strong") u.passwordProfile.SetText("strong")
u.securityCipher.SetText(vault.CipherChaCha20)
u.securityKDF.SetText(vault.KDFArgon2)
u.keyboardFocus = focusSearch u.keyboardFocus = focusSearch
u.setCustomFieldRows(nil) u.setCustomFieldRows(nil)
u.loadRecentVaults() u.loadRecentVaults()
@@ -628,6 +638,12 @@ func (u *ui) createVaultAction() error {
if err != nil { if err != nil {
return err 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 { if err := u.state.CreateVault(key); err != nil {
return err return err
} }
@@ -640,6 +656,7 @@ func (u *ui) createVaultAction() error {
} }
u.resetPasswordPeek() u.resetPasswordPeek()
u.currentPath = append([]string(nil), u.state.CurrentPath...) u.currentPath = append([]string(nil), u.state.CurrentPath...)
u.loadSecuritySettingsFromSession()
u.editingEntry = false u.editingEntry = false
u.filter() u.filter()
return nil return nil
@@ -662,6 +679,7 @@ func (u *ui) openVaultAction() error {
u.resetPasswordPeek() u.resetPasswordPeek()
u.currentPath = append([]string(nil), u.state.CurrentPath...) u.currentPath = append([]string(nil), u.state.CurrentPath...)
u.restoreRecentVaultGroup(path) u.restoreRecentVaultGroup(path)
u.loadSecuritySettingsFromSession()
u.editingEntry = false u.editingEntry = false
u.filter() u.filter()
return nil return nil
@@ -709,6 +727,7 @@ func (u *ui) openRemoteAction() error {
) )
u.resetPasswordPeek() u.resetPasswordPeek()
u.restoreRecentRemoteGroup(strings.TrimSpace(u.remoteBaseURL.Text()), strings.TrimSpace(u.remotePath.Text())) u.restoreRecentRemoteGroup(strings.TrimSpace(u.remoteBaseURL.Text()), strings.TrimSpace(u.remotePath.Text()))
u.loadSecuritySettingsFromSession()
u.editingEntry = false u.editingEntry = false
u.filter() u.filter()
return nil return nil
@@ -737,6 +756,7 @@ func (u *ui) unlockAction() error {
} }
u.resetPasswordPeek() u.resetPasswordPeek()
u.currentPath = append([]string(nil), u.state.CurrentPath...) u.currentPath = append([]string(nil), u.state.CurrentPath...)
u.loadSecuritySettingsFromSession()
u.editingEntry = false u.editingEntry = false
u.filter() u.filter()
return nil return nil
@@ -751,6 +771,27 @@ func (u *ui) changeMasterKeyAction() error {
return u.state.ChangeMasterKey(key) 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() { func (u *ui) clearMasterPassword() {
u.masterPassword.SetText("") u.masterPassword.SetText("")
} }
@@ -1518,13 +1559,23 @@ func (u *ui) layout(gtx layout.Context) layout.Dimensions {
for u.openAdvancedSync.Clicked(gtx) { for u.openAdvancedSync.Clicked(gtx) {
u.openAdvancedSyncDialog() u.openAdvancedSyncDialog()
} }
for u.openSecuritySettings.Clicked(gtx) {
u.loadSecuritySettingsFromSession()
u.securityDialogOpen = true
}
for u.closeAdvancedSync.Clicked(gtx) { for u.closeAdvancedSync.Clicked(gtx) {
u.syncDialogOpen = false u.syncDialogOpen = false
u.showSyncPassword = false u.showSyncPassword = false
} }
for u.closeSecuritySettings.Clicked(gtx) {
u.securityDialogOpen = false
}
for u.runAdvancedSync.Clicked(gtx) { for u.runAdvancedSync.Clicked(gtx) {
u.runAction("advanced synchronize vault", u.advancedSyncAction) u.runAction("advanced synchronize vault", u.advancedSyncAction)
} }
for u.saveSecuritySettings.Clicked(gtx) {
u.runAction("save security settings", u.saveSecuritySettingsAction)
}
for u.unlockVault.Clicked(gtx) { for u.unlockVault.Clicked(gtx) {
u.runAction("unlock vault", u.unlockAction) u.runAction("unlock vault", u.unlockAction)
} }
@@ -1835,6 +1886,12 @@ func (u *ui) layout(gtx layout.Context) layout.Dimensions {
} }
return u.syncDialog(gtx) 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 { layout.Stacked(func(gtx layout.Context) layout.Dimensions {
if _, ok := u.pendingApproval(); !ok { if _, ok := u.pendingApproval(); !ok {
return layout.Dimensions{} 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 { func (u *ui) approvalDialog(gtx layout.Context) layout.Dimensions {
return layout.Stack{}.Layout(gtx, return layout.Stack{}.Layout(gtx,
layout.Expanded(func(gtx layout.Context) layout.Dimensions { 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, return layout.Flex{Spacing: layout.SpaceStart}.Layout(gtx,
layout.Rigid(u.syncButtonGroup), layout.Rigid(u.syncButtonGroup),
layout.Rigid(layout.Spacer{Width: unit.Dp(8)}.Layout), 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 { layout.Rigid(func(gtx layout.Context) layout.Dimensions {
btn := material.Button(u.theme, &u.lockVault, "Lock") btn := material.Button(u.theme, &u.lockVault, "Lock")
return btn.Layout(gtx) return btn.Layout(gtx)
@@ -2505,6 +2621,10 @@ func (u *ui) detailPanel(gtx layout.Context) layout.Dimensions {
}), }),
layout.Rigid(u.syncButtonGroup), layout.Rigid(u.syncButtonGroup),
layout.Rigid(layout.Spacer{Width: unit.Dp(8)}.Layout), 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 { layout.Rigid(func(gtx layout.Context) layout.Dimensions {
btn := material.Button(u.theme, &u.lockVault, "Lock") btn := material.Button(u.theme, &u.lockVault, "Lock")
return btn.Layout(gtx) return btn.Layout(gtx)
+59
View File
@@ -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) { func TestUILockAndUnlockClearMasterPasswordField(t *testing.T) {
t.Parallel() t.Parallel()
+13
View File
@@ -32,6 +32,19 @@ type Manager struct {
remoteVersion webdav.Version 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 { func (m *Manager) Create(model vault.Model, key vault.MasterKey) error {
root := detectSingleVaultRoot(model) root := detectSingleVaultRoot(model)
model = normalizeUnderRoot(model, root) model = normalizeUnderRoot(model, root)
+29
View File
@@ -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) { func TestSynchronizeRemotePreservesOverwrittenRemoteVariantInHistory(t *testing.T) {
t.Parallel() t.Parallel()
+4
View File
@@ -61,6 +61,10 @@ func (u *ui) lifecycleControls(gtx layout.Context) layout.Dimensions {
) )
}), }),
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout), 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 { layout.Rigid(func(gtx layout.Context) layout.Dimensions {
if u.lifecycleMode == "remote" { if u.lifecycleMode == "remote" {
return tonedButton(gtx, u.theme, &u.openRemote, "Open Remote Vault") return tonedButton(gtx, u.theme, &u.openRemote, "Open Remote Vault")
+100
View File
@@ -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
}
+50
View File
@@ -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)
}
}