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
- 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
- password generation profiles
- gRPC integration surface for trusted automation
+19
View File
@@ -32,6 +32,11 @@ type LockableSession interface {
Unlock(vault.MasterKey) error
}
type MasterKeyChangeableSession interface {
CurrentSession
ChangeMasterKey(vault.MasterKey) error
}
type SaveableSession interface {
CurrentSession
Save() error
@@ -419,6 +424,20 @@ func (s *State) Unlock(key vault.MasterKey) error {
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) {
s.CurrentPath = append(append([]string(nil), s.CurrentPath...), name)
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) {
t.Parallel()
@@ -929,6 +948,7 @@ type lifecycleStubSession struct {
openPath string
saveAsPath string
remotePath string
changedKey vault.MasterKey
}
func (s *lifecycleStubSession) Current() (vault.Model, error) {
@@ -954,3 +974,8 @@ func (s *lifecycleStubSession) OpenRemote(_ webdav.Client, path string, _ vault.
s.remotePath = path
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 key-file-only 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 cipher selection during save
- preserve the original opened vault's KDF selection during save
+169 -118
View File
@@ -1,111 +1,116 @@
package main
import (
"fmt"
"flag"
"fmt"
"image"
"image/color"
"os"
"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/clipboard"
"git.julianfamily.org/keepassgo/session"
"git.julianfamily.org/keepassgo/vault"
"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"
)
type entry = vault.Entry
type ui struct {
mode string
theme *material.Theme
search widget.Editor
vaultPath widget.Editor
saveAsPath widget.Editor
remoteBaseURL widget.Editor
remotePath widget.Editor
remoteUsername widget.Editor
remotePassword widget.Editor
masterPassword widget.Editor
keyFilePath widget.Editor
entryID widget.Editor
entryTitle widget.Editor
entryUsername widget.Editor
entryPassword widget.Editor
entryURL widget.Editor
entryNotes widget.Editor
entryTags widget.Editor
entryPath widget.Editor
entryFields widget.Editor
historyIndex widget.Editor
groupName widget.Editor
passwordProfile widget.Editor
attachmentName widget.Editor
attachmentPath widget.Editor
exportAttachmentPath widget.Editor
list widget.List
detailList widget.List
copyUser widget.Clickable
copyPass widget.Clickable
copyURL widget.Clickable
openURL widget.Clickable
lockVault widget.Clickable
unlockVault widget.Clickable
createVault widget.Clickable
openVault widget.Clickable
saveVault widget.Clickable
saveAsVault widget.Clickable
openRemote widget.Clickable
addEntry widget.Clickable
saveEntry widget.Clickable
duplicateEntry widget.Clickable
deleteEntry widget.Clickable
restoreEntry widget.Clickable
saveTemplate widget.Clickable
deleteTemplate widget.Clickable
instantiateTemplate widget.Clickable
addAttachment widget.Clickable
removeAttachment widget.Clickable
exportAttachment widget.Clickable
restoreHistory widget.Clickable
generatePassword widget.Clickable
createGroup widget.Clickable
renameGroup widget.Clickable
deleteGroup widget.Clickable
togglePasswordInline widget.Clickable
showEntries widget.Clickable
showTemplates widget.Clickable
showRecycle widget.Clickable
entryClicks []widget.Clickable
breadcrumbs []widget.Clickable
groupClicks []widget.Clickable
state appstate.State
visible []entry
currentPath []string
showPassword bool
togglePassword widget.Clickable
phoneSplit widget.Float
splitDrag gesture.Drag
splitBase float32
splitStartY float32
phoneSpan int
eyeIcon *widget.Icon
eyeOffIcon *widget.Icon
copyIcon *widget.Icon
statusMessage string
errorMessage string
mode string
theme *material.Theme
search widget.Editor
vaultPath widget.Editor
saveAsPath widget.Editor
remoteBaseURL widget.Editor
remotePath widget.Editor
remoteUsername widget.Editor
remotePassword widget.Editor
masterPassword widget.Editor
keyFilePath widget.Editor
entryID widget.Editor
entryTitle widget.Editor
entryUsername widget.Editor
entryPassword widget.Editor
entryURL widget.Editor
entryNotes widget.Editor
entryTags widget.Editor
entryPath widget.Editor
entryFields widget.Editor
historyIndex widget.Editor
groupName widget.Editor
passwordProfile widget.Editor
attachmentName widget.Editor
attachmentPath widget.Editor
exportAttachmentPath widget.Editor
list widget.List
detailList widget.List
copyUser widget.Clickable
copyPass widget.Clickable
copyURL widget.Clickable
openURL widget.Clickable
lockVault widget.Clickable
unlockVault widget.Clickable
createVault widget.Clickable
openVault widget.Clickable
saveVault widget.Clickable
saveAsVault widget.Clickable
openRemote widget.Clickable
changeMasterKey widget.Clickable
addEntry widget.Clickable
saveEntry widget.Clickable
duplicateEntry widget.Clickable
deleteEntry widget.Clickable
restoreEntry widget.Clickable
saveTemplate widget.Clickable
deleteTemplate widget.Clickable
instantiateTemplate widget.Clickable
addAttachment widget.Clickable
removeAttachment widget.Clickable
exportAttachment widget.Clickable
restoreHistory widget.Clickable
generatePassword widget.Clickable
createGroup widget.Clickable
renameGroup widget.Clickable
deleteGroup widget.Clickable
togglePasswordInline widget.Clickable
showEntries widget.Clickable
showTemplates widget.Clickable
showRecycle widget.Clickable
masterKeyPasswordOnly widget.Clickable
masterKeyKeyFileOnly widget.Clickable
masterKeyComposite widget.Clickable
entryClicks []widget.Clickable
breadcrumbs []widget.Clickable
groupClicks []widget.Clickable
state appstate.State
masterKeyMode vault.MasterKeyMode
visible []entry
currentPath []string
showPassword bool
togglePassword widget.Clickable
phoneSplit widget.Float
splitDrag gesture.Drag
splitBase float32
splitStartY float32
phoneSpan int
eyeIcon *widget.Icon
eyeOffIcon *widget.Icon
copyIcon *widget.Icon
statusMessage string
errorMessage string
}
var (
@@ -143,28 +148,28 @@ func newUIWithState(mode string, sess appstate.CurrentSession) *ui {
SingleLine: true,
Submit: false,
},
vaultPath: widget.Editor{SingleLine: true, Submit: false},
saveAsPath: widget.Editor{SingleLine: true, Submit: false},
remoteBaseURL: widget.Editor{SingleLine: true, Submit: false},
remotePath: widget.Editor{SingleLine: true, Submit: false},
remoteUsername: widget.Editor{SingleLine: true, Submit: false},
remotePassword: widget.Editor{SingleLine: true, Submit: false},
masterPassword: widget.Editor{SingleLine: true, Submit: false},
keyFilePath: 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},
entryPassword: widget.Editor{SingleLine: true, Submit: false},
entryURL: widget.Editor{SingleLine: true, Submit: false},
entryNotes: widget.Editor{SingleLine: false, Submit: false},
entryTags: widget.Editor{SingleLine: true, Submit: false},
entryPath: widget.Editor{SingleLine: true, Submit: false},
entryFields: widget.Editor{SingleLine: false, Submit: false},
historyIndex: widget.Editor{SingleLine: true, Submit: false},
groupName: widget.Editor{SingleLine: true, Submit: false},
passwordProfile: widget.Editor{SingleLine: true, Submit: false},
attachmentName: widget.Editor{SingleLine: true, Submit: false},
attachmentPath: widget.Editor{SingleLine: true, Submit: false},
vaultPath: widget.Editor{SingleLine: true, Submit: false},
saveAsPath: widget.Editor{SingleLine: true, Submit: false},
remoteBaseURL: widget.Editor{SingleLine: true, Submit: false},
remotePath: widget.Editor{SingleLine: true, Submit: false},
remoteUsername: widget.Editor{SingleLine: true, Submit: false},
remotePassword: widget.Editor{SingleLine: true, Submit: false},
masterPassword: widget.Editor{SingleLine: true, Submit: false},
keyFilePath: 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},
entryPassword: widget.Editor{SingleLine: true, Submit: false},
entryURL: widget.Editor{SingleLine: true, Submit: false},
entryNotes: widget.Editor{SingleLine: false, Submit: false},
entryTags: widget.Editor{SingleLine: true, Submit: false},
entryPath: widget.Editor{SingleLine: true, Submit: false},
entryFields: widget.Editor{SingleLine: false, Submit: false},
historyIndex: widget.Editor{SingleLine: true, Submit: false},
groupName: widget.Editor{SingleLine: true, Submit: false},
passwordProfile: widget.Editor{SingleLine: true, Submit: false},
attachmentName: widget.Editor{SingleLine: true, Submit: false},
attachmentPath: widget.Editor{SingleLine: true, Submit: false},
exportAttachmentPath: widget.Editor{SingleLine: true, Submit: false},
list: widget.List{
List: layout.List{Axis: layout.Vertical},
@@ -172,7 +177,8 @@ func newUIWithState(mode string, sess appstate.CurrentSession) *ui {
detailList: widget.List{
List: layout.List{Axis: layout.Vertical},
},
state: appstate.State{},
state: appstate.State{},
masterKeyMode: vault.MasterKeyModePasswordOnly,
}
u.state.Session = sess
u.phoneSplit.Value = 0.46
@@ -263,19 +269,44 @@ func (u *ui) selectedEntry() (entry, bool) {
}
func (u *ui) currentMasterKey() (vault.MasterKey, error) {
key := vault.MasterKey{Password: u.masterPassword.Text()}
password := u.masterPassword.Text()
path := strings.TrimSpace(u.keyFilePath.Text())
if path == "" {
return key, nil
switch u.masterKeyMode {
case vault.MasterKeyModeKeyFileOnly:
if path == "" {
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)
if err != nil {
return vault.MasterKey{}, fmt.Errorf("read key file: %w", err)
}
key.KeyFileData = content
return key, nil
if len(content) == 0 {
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 {
@@ -359,6 +390,14 @@ func (u *ui) unlockAction() error {
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) {
if err := action(); err != nil {
u.errorMessage = err.Error()
@@ -392,9 +431,21 @@ func (u *ui) layout(gtx layout.Context) layout.Dimensions {
for u.openRemote.Clicked(gtx) {
u.runAction("open remote vault", u.openRemoteAction)
}
for u.changeMasterKey.Clicked(gtx) {
u.runAction("change master key", u.changeMasterKeyAction)
}
for u.unlockVault.Clicked(gtx) {
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) {
u.showEntriesSection()
}
@@ -757,7 +808,7 @@ func (u *ui) phoneSlider(gtx layout.Context) layout.Dimensions {
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, 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,
}.Op(gtx.Ops))
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) {
t.Parallel()
@@ -230,6 +482,7 @@ func TestUIMasterKeyInputSupportsKeyFileAndCompositeKeys(t *testing.T) {
}
u := newUIWithSession("desktop", &session.Manager{})
u.setMasterKeyMode(vault.MasterKeyModePasswordAndKeyFile)
u.masterPassword.SetText("correct horse battery staple")
u.keyFilePath.SetText(keyFile)
+55 -11
View File
@@ -99,17 +99,17 @@ func (m *Manager) SaveRemote() error {
return ErrNoPath
}
var encoded bytes.Buffer
if err := vault.SaveKDBXWithConfigAndKey(&encoded, m.model, m.key, m.config); err != nil {
return fmt.Errorf("encode vault: %w", err)
encoded, err := m.persistableBytes()
if err != nil {
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 {
return fmt.Errorf("save remote %s: %w", m.remotePath, err)
}
m.encoded = encoded.Bytes()
m.encoded = encoded
m.remoteVersion = version
return nil
}
@@ -165,16 +165,60 @@ func (m *Manager) Unlock(key vault.MasterKey) error {
return nil
}
func (m *Manager) saveToPath(path string) error {
var encoded bytes.Buffer
if err := vault.SaveKDBXWithConfigAndKey(&encoded, m.model, m.key, m.config); err != nil {
return fmt.Errorf("encode vault: %w", err)
func (m *Manager) ChangeMasterKey(key vault.MasterKey) error {
var (
model vault.Model
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)
}
m.encoded = encoded.Bytes()
m.encoded = encoded
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) {
t.Parallel()
+75 -17
View File
@@ -3,15 +3,37 @@ package main
import (
"strings"
"git.julianfamily.org/keepassgo/appstate"
"gioui.org/layout"
"gioui.org/unit"
"gioui.org/widget"
"gioui.org/widget/material"
"git.julianfamily.org/keepassgo/appstate"
)
func (u *ui) lifecycleControls(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(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(layout.Spacer{Height: unit.Dp(6)}.Layout),
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(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(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(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(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(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.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(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(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 {
case appstate.SectionTemplates:
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(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(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:
return tonedButton(gtx, u.theme, &u.restoreEntry, "Restore Entry")
default:
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(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(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(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(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,
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(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(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(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.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(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(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"
)