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
|
||||
|
||||
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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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