Add configurable vault security settings
This commit is contained in:
@@ -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.
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user