Add UI master key setup and change flows

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