Compare commits

...

72 Commits

Author SHA1 Message Date
Joe Julian ed2118223f Add API token authorization TODO segments 2026-03-29 22:38:09 -07:00
Joe Julian 9e30f654b2 Preserve single-root KDBX group trees 2026-03-29 22:17:40 -07:00
Joe Julian 1ecad1fae9 Set up Android SDK prerequisites for APK builds 2026-03-29 21:59:52 -07:00
Joe Julian fc34508689 Add advanced synchronize dialog and APK tooling 2026-03-29 21:38:46 -07:00
Joe Julian 9dea7a4a34 Preserve remote conflicts in entry history 2026-03-29 21:19:42 -07:00
Joe Julian e5904dd224 Tighten banner and navigation behavior 2026-03-29 21:16:38 -07:00
Joe Julian da769a9728 Keep recent remote passwords masked 2026-03-29 21:07:57 -07:00
Joe Julian f47682f3a1 Scroll lifecycle screen and restore remote groups 2026-03-29 17:46:48 -07:00
Joe Julian 9dcc5f1d6f Collapse empty phone detail pane 2026-03-29 17:41:51 -07:00
Joe Julian ee9b00bd85 Rebalance desktop open-vault layout 2026-03-29 17:36:41 -07:00
Joe Julian 8ce924e08f Reset password peek across view changes 2026-03-29 17:27:19 -07:00
Joe Julian bda28eef4b Remember recent remote vault connections 2026-03-29 17:05:42 -07:00
Joe Julian bf56e38bc5 Tighten desktop vault navigation header 2026-03-29 16:58:21 -07:00
Joe Julian d137018d3a Honor env state dir in default UI paths 2026-03-29 16:54:20 -07:00
Joe Julian fcc0291dd9 Simplify locked vault screen 2026-03-29 16:47:29 -07:00
Joe Julian cc127a013e Add collapsible group tools section 2026-03-29 16:39:27 -07:00
Joe Julian b2f1d9a66d Use structured custom fields in entry editor 2026-03-29 16:37:20 -07:00
Joe Julian b391cea295 Restore per-vault groups and scroll edit forms 2026-03-29 16:32:54 -07:00
Joe Julian fba135ff09 Require isolated state dir for test runs 2026-03-29 16:26:21 -07:00
Joe Julian 71b383114d Improve group deletion and status banner UX 2026-03-29 16:17:22 -07:00
Joe Julian 75dc6453f7 Support KeePassGO state dir via flag and env 2026-03-29 15:41:44 -07:00
Joe Julian 72b413a11a Clear KeePassGO master password after use 2026-03-29 15:38:30 -07:00
Joe Julian 75002a4c47 Fix KeePassGO open screen field and banner UX 2026-03-29 15:35:32 -07:00
Joe Julian e98256709d Default Enter to opening the selected vault 2026-03-29 15:33:41 -07:00
Joe Julian 5e15ad3265 Persist recent KeePassGO vault list 2026-03-29 15:28:20 -07:00
Joe Julian d492743eb1 Improve KeePassGO vault navigation UX 2026-03-29 15:18:14 -07:00
Joe Julian 4a629c16bd Simplify KeePassGO desktop vault workflow 2026-03-29 14:50:33 -07:00
Joe Julian 62fc343ecf Refine lifecycle setup screen 2026-03-29 14:00:20 -07:00
Joe Julian 1e51eff76e Document remaining AGENTS gaps 2026-03-29 13:53:27 -07:00
Joe Julian 4509634a15 Update lifecycle tests for controller state 2026-03-29 13:46:30 -07:00
Joe Julian 47bbbdb05e Restore search path context helper 2026-03-29 13:46:30 -07:00
Joe Julian 4fbaee3970 Align keyboard and tests with controller state 2026-03-29 13:46:30 -07:00
Joe Julian d2b49ca0ad Remove duplicate app state group deletion 2026-03-29 13:46:30 -07:00
Joe Julian fedeab2fc1 Merge branch 'seg01-appstate' into merge-main-01-seg01-appstate 2026-03-29 13:46:30 -07:00
Joe Julian b56401b5c6 Centralize app state ownership in controller 2026-03-29 13:46:30 -07:00
Joe Julian 01559a3a2b Fix rebased main path tests 2026-03-29 13:44:05 -07:00
Joe Julian 16dc1de3c8 Add password generation UI profile workflow 2026-03-29 13:44:05 -07:00
Joe Julian bd9674a1f5 Add local vault lifecycle UI coverage 2026-03-29 13:43:13 -07:00
Joe Julian e2e38a97a0 Drop obsolete UI path fallback after rebase 2026-03-29 13:41:44 -07:00
Joe Julian 34a6e0150e Sync UI path fallbacks during main landing 2026-03-29 13:40:57 -07:00
Joe Julian ec063ac81f Align moved-entry test with controller navigation 2026-03-29 13:40:36 -07:00
Joe Julian ac5b6894cf Add attachment replace workflow UI 2026-03-29 13:40:36 -07:00
Joe Julian d6bc213474 test: align rebased path coverage with controller state 2026-03-29 13:37:57 -07:00
Joe Julian 706a72e135 test: align keyboard navigation with controller paths 2026-03-29 13:37:00 -07:00
Joe Julian f8111f626f test: align template UI coverage with controller navigation 2026-03-29 13:37:00 -07:00
Joe Julian 0bfa30de91 test: cover template CRUD UI flows 2026-03-29 13:37:00 -07:00
Joe Julian 24dcf9c8ad Make group navigation controller-driven 2026-03-29 13:37:00 -07:00
Joe Julian ea5c8b3c31 Merge commit '7651b3b' into merge-main-13-seg13-copy-reveal 2026-03-29 13:36:26 -07:00
Joe Julian c355eebed5 Merge commit 'cffe05a' into merge-main-13-seg13-copy-reveal
# Conflicts:
#	main.go
2026-03-29 13:35:59 -07:00
Joe Julian 7651b3b237 Resolve remote lifecycle landing conflicts 2026-03-29 13:35:18 -07:00
Joe Julian da942b41d7 Add WebDAV lifecycle UI feedback 2026-03-29 13:35:18 -07:00
Joe Julian 1c0cd20bcc Merge commit '4fe912b' into merge-main-13-seg13-copy-reveal 2026-03-29 13:35:12 -07:00
Joe Julian cffe05af82 Add entry history browsing to UI 2026-03-29 13:35:12 -07:00
Joe Julian 8deccced9e Add keyboard-first accessibility behavior 2026-03-29 13:35:12 -07:00
Joe Julian 4fe912b41f Merge branch 'main' into merge-main-20-seg20-regression 2026-03-29 13:34:26 -07:00
Joe Julian 55ca6352b4 Add secret-safe copy and reveal UX coverage 2026-03-29 13:34:25 -07:00
Joe Julian 0cfccb58d6 Remove dead deleted-object helper 2026-03-29 13:34:12 -07:00
Joe Julian b043ecdc83 Add regression coverage for KDBX reopen cycles 2026-03-29 13:34:12 -07:00
Joe Julian d3be07f252 Add Segment 6 entry editor coverage 2026-03-29 13:34:12 -07:00
Joe Julian 0f30c5b1c5 Add gRPC vault lifecycle backend flow 2026-03-29 13:34:12 -07:00
Joe Julian 985150c36f Add gRPC group deletion and custom entry fields 2026-03-29 13:33:52 -07:00
Joe Julian f39bdcd3be Complete search section behavior 2026-03-29 13:33:52 -07:00
Joe Julian 48ffa78fa2 Add keyboard-first accessibility behavior 2026-03-29 13:33:52 -07:00
Joe Julian 1349fe6e38 Remove dead deleted-object helper 2026-03-29 13:33:30 -07:00
Joe Julian 19ccab1d8d Add regression coverage for KDBX reopen cycles 2026-03-29 13:33:30 -07:00
Joe Julian ae1921506d Add Segment 6 entry editor coverage 2026-03-29 13:33:30 -07:00
Joe Julian 1f51904567 Add gRPC vault lifecycle backend flow 2026-03-29 13:33:30 -07:00
Joe Julian ef867adfa1 Merge branch 'seg09-search' into merge-main-09-seg09-search 2026-03-29 13:33:07 -07:00
Joe Julian 5eb2068d3e Add keyboard-first accessibility behavior 2026-03-29 13:33:07 -07:00
Joe Julian 77407e0510 Complete search section behavior 2026-03-29 13:33:07 -07:00
Joe Julian dd0b0b6f6e Complete UI state and error surfaces 2026-03-29 13:32:28 -07:00
Joe Julian 566256ab79 Add UI master key setup and change flows 2026-03-29 11:23:54 -07:00
33 changed files with 9123 additions and 760 deletions
+2 -1
View File
@@ -1 +1,2 @@
gio-keepass-mock
build/
*.apk
+5
View File
@@ -125,4 +125,9 @@ These features are product requirements, not “nice to have” ideas.
- Keep `golangci-lint` passing.
- Keep `go test ./...` passing.
- Track `gogio` as a Go tool and keep a reproducible `make apk` path for Android packaging.
- When running tests or other automated validation that may touch persisted UI state, set `KEEPASSGO_STATE_DIR` to an isolated temporary directory so recent-vault history and other local state do not pollute the users real config.
- Prefer commands shaped like `KEEPASSGO_STATE_DIR=\"$(mktemp -d)\" go test ./...` for ad hoc local validation unless a test already manages its own isolated state directory.
- Do not assume the agent can decrypt SOPS-encrypted secrets in this repository.
- If work requires plaintext from a SOPS-encrypted secret, stop and ask the user to decrypt it or otherwise provide the needed plaintext.
- Do not commit generated binaries.
+32
View File
@@ -0,0 +1,32 @@
# Android Build
Build the APK with:
```sh
make apk
```
Environment:
- `ANDROID_SDK_ROOT` defaults to `/opt/android-sdk`.
- `ANDROID_NDK_ROOT` defaults to `/opt/android-ndk`.
- `JAVA_HOME` defaults to `/usr/lib/jvm/java-25-openjdk`.
- `APP_ID` overrides the Android application id.
- `APK_OUT` overrides the output path.
- `APK_VERSION` overrides the packaged app version.
- `ANDROID_MIN_SDK` overrides the minimum supported Android SDK.
- `ANDROID_TARGET_SDK` overrides the target Android SDK.
Installed machine prerequisites expected by this repo:
- `android-sdk-cmdline-tools-latest`
- `android-sdk-build-tools`
- `android-platform-35`
- `android-sdk-platform-tools`
- JDK 17 or newer
The repo tracks `gogio` as a Go tool, so the build runs through:
```sh
go tool gogio -target android ...
```
+31
View File
@@ -0,0 +1,31 @@
ANDROID_SDK_ROOT ?= /opt/android-sdk
ANDROID_NDK_ROOT ?= /opt/android-ndk
JAVA_HOME ?= /usr/lib/jvm/java-25-openjdk
PATH := $(JAVA_HOME)/bin:$(ANDROID_SDK_ROOT)/cmdline-tools/latest/bin:$(ANDROID_SDK_ROOT)/platform-tools:$(PATH)
APP_ID ?= org.julianfamily.keepassgo
APK_OUT ?= build/keepassgo.apk
APK_VERSION ?= 0.1.0.1
ANDROID_MIN_SDK ?= 28
ANDROID_TARGET_SDK ?= 35
.PHONY: apk
apk:
@test -x "$(JAVA_HOME)/bin/java" || { echo "JAVA_HOME must point to a JDK 17+ install"; exit 1; }
@test -d "$(ANDROID_SDK_ROOT)" || { echo "ANDROID_SDK_ROOT must point to an Android SDK install"; exit 1; }
@test -d "$(ANDROID_NDK_ROOT)" || { echo "ANDROID_NDK_ROOT must point to an Android NDK install"; exit 1; }
@test -x "$(ANDROID_SDK_ROOT)/cmdline-tools/latest/bin/sdkmanager" || { echo "Android SDK cmdline-tools are missing"; exit 1; }
@test -d "$(ANDROID_SDK_ROOT)/platforms/android-$(ANDROID_TARGET_SDK)" || { echo "Android platform android-$(ANDROID_TARGET_SDK) is missing"; exit 1; }
@test -d "$(ANDROID_SDK_ROOT)/build-tools" || { echo "Android build-tools are missing"; exit 1; }
@mkdir -p "$(dir $(APK_OUT))"
ANDROID_HOME="$(ANDROID_SDK_ROOT)" \
ANDROID_SDK_ROOT="$(ANDROID_SDK_ROOT)" \
ANDROID_NDK_ROOT="$(ANDROID_NDK_ROOT)" \
JAVA_HOME="$(JAVA_HOME)" \
go tool gogio -target android \
-buildmode exe \
-appid $(APP_ID) \
-o $(APK_OUT) \
-version $(APK_VERSION) \
-minsdk $(ANDROID_MIN_SDK) \
-targetsdk $(ANDROID_TARGET_SDK) \
.
+3 -2
View File
@@ -1,11 +1,12 @@
# KeePassGO
KeePassGO is a Go-based KeePass-compatible password manager prototype targeting desktop first, with future Android support.
KeePassGO is a Go-based KeePass-compatible password manager targeting desktop first, with future Android support.
## 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
+237
View File
@@ -6,6 +6,195 @@ These segments are intended to be independently executable wherever possible.
Each segment has its own local exit criteria.
The product is not complete until the global exit criteria at the end of this file are also met.
## API Token And gRPC Authorization Parallel Segments
These segments define the work for programmatic access control over gRPC.
They are designed to be independently landable wherever file overlap permits.
The feature is not complete until all segment exit criteria and the global exit criteria are satisfied.
### API Segment A: Token Domain Model
Scope:
- Represent API tokens as first-class vault-backed records.
- Mark token entries explicitly as API credentials rather than generic passwords.
- Store token metadata:
token id,
hashed secret or verifier,
display name,
client name,
created at,
expires at,
disabled state.
- Keep the persisted representation compatible with KDBX entry fields.
Exit criteria:
- A domain type exists for API tokens and round-trips through the persisted vault model.
- Generic entry listing can distinguish API token entries from ordinary secrets.
- Tests cover create, load, save, and parse behavior for API token entries.
- `go test ./...` passes.
### API Segment B: Token Issuance And Rotation
Scope:
- Generate new API tokens for external tools.
- Return the cleartext token only at creation or explicit rotation time.
- Rotate an existing token while preserving its identity and policy linkage.
- Revoke or disable a token without deleting policy history.
Exit criteria:
- Token issuance, rotation, disable, and revoke operations exist in the domain/service layer.
- Cleartext token material is only exposed on creation or rotation paths.
- Tests cover generation, rotation, and disable/revoke semantics.
- `go test ./...` passes.
### API Segment C: Token Expiration
Scope:
- Allow tokens to have optional expiration timestamps.
- Treat expired tokens as unauthenticated.
- Surface expiration in UI and gRPC management views.
- Support non-expiring tokens explicitly.
Exit criteria:
- Expired tokens are rejected by the gRPC authentication path.
- Token expiration can be created, edited, and removed through the service layer.
- Tests cover valid, expired, and non-expiring token behavior.
- `go test ./...` passes.
### API Segment D: Authorization Policy Model
Scope:
- Define an authorization model for token-scoped access.
- Support allow and deny rules over:
folders/groups,
specific entries,
entry fields where needed,
and operation types.
- Keep specific deny rules higher priority than broad allow rules.
- Model “not yet decided” separately from “denied”.
Exit criteria:
- A policy evaluator exists for token, resource, and operation tuples.
- Explicit deny overrides allow.
- Unspecified access is distinguishable from denied access.
- Tests cover allow, deny, inherited group scope, and exact-entry scope behavior.
- `go test ./...` passes.
### API Segment E: gRPC Authentication And Authorization Enforcement
Scope:
- Replace the current single static bearer-token interceptor with token-backed auth.
- Authenticate callers using issued KeePassGO API tokens.
- Authorize every gRPC method against token policy.
- Apply scope checks to lifecycle, list, read, mutation, copy, and password-generation RPCs.
Exit criteria:
- gRPC requests authenticate through stored API tokens rather than one static shared secret.
- Every RPC enforces token-specific authorization before mutating or revealing vault data.
- Unauthorized requests return the correct authz/authn gRPC status.
- Integration tests cover permitted, denied, expired, and revoked token behavior.
- `go test ./...` passes.
### API Segment F: Approval Queue And Pending Access Requests
Scope:
- When a token requests access to a resource that is neither explicitly allowed nor denied:
create a pending approval request.
- Include:
token identity,
client name,
requested operation,
requested group/entry scope,
requested time,
and permanence choice.
- Allow the request to be accepted, denied, or canceled by the user.
Exit criteria:
- Unspecified access creates a pending approval instead of silently denying or allowing.
- Pending approvals are queryable from the application layer.
- Canceling the prompt results in the API request failing without granting access.
- Tests cover pending creation, approval, denial, and cancellation.
- `go test ./...` passes.
### API Segment G: Approval UI
Scope:
- Show a user-facing approval screen/dialog when a pending API request needs a decision.
- Provide actions:
allow once,
deny once,
allow permanently,
deny permanently,
cancel.
- Make the requested scope and operation clear to the user.
- Ensure the dialog appears only for requests not already decided.
Exit criteria:
- A pending request triggers a visible approval surface in the app.
- The user can allow, deny, or cancel from the UI.
- Permanent decisions become persisted policy rules.
- UI tests cover each approval outcome.
- `go test ./...` passes.
### API Segment H: gRPC Request Blocking And Resume Behavior
Scope:
- Define how an in-flight gRPC call waits for or fails on user approval.
- Hold the request while approval is pending within a bounded timeout.
- Return unauthenticated or permission-denied when denied/canceled/expired.
- Resume the original call automatically when approval is granted.
Exit criteria:
- Pending requests block safely without leaking goroutines.
- Allowed requests resume and complete without the client reissuing the call where practical.
- Denied and canceled requests return a consistent gRPC status code and message.
- Tests cover timeout, allow, deny, and cancel paths.
- `go test ./...` passes.
### API Segment I: Token Management UI
Scope:
- Add UI for listing API tokens.
- Create token flow with one-time secret display.
- Edit token display metadata and expiration.
- Disable, revoke, and rotate tokens.
- Show effective policy summary per token.
Exit criteria:
- Users can manage API tokens from the app UI end to end.
- One-time token display is explicit and not re-shown later.
- Expiration and disable state are visible.
- UI tests cover create, rotate, disable, revoke, and edit flows.
- `go test ./...` passes.
### API Segment J: Policy Management UI
Scope:
- Let users define folder, entry, and operation scopes for each token.
- Show explicit allow and deny rules.
- Show inherited implications of a folder-level rule.
- Let users review prior permanent decisions created from approval prompts.
Exit criteria:
- Users can inspect and edit token policy from the UI.
- Folder-level and entry-level rules are distinguishable and editable.
- Permanent prompt decisions are visible as policy.
- UI tests cover rule creation, update, and deletion.
- `go test ./...` passes.
### API Segment K: Audit And Event History
Scope:
- Record token issuance, rotation, revoke, approval, deny, and prompt outcomes.
- Record authorization failures and expirations without logging secret material.
- Provide a bounded event history visible in the UI and/or gRPC admin surface.
Exit criteria:
- Security-relevant API token events are captured without secret leakage.
- Approval outcomes and policy changes are auditable.
- Tests cover audit generation for the main token lifecycle and approval actions.
- `go test ./...` passes.
### Segment 1: Application State Ownership
Scope:
@@ -341,3 +530,51 @@ Do not treat the product as complete until all of the following are true:
- Build and run instructions exist for desktop, and packaging guidance exists for Android.
- `go test ./...` passes.
- `go tool golangci-lint run ./...` passes.
## 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.
### 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.
+82 -17
View File
@@ -4,15 +4,17 @@ import (
"context"
"errors"
"maps"
"os"
"slices"
"sync"
"strings"
"sync"
"git.julianfamily.org/keepassgo/clipboard"
"git.julianfamily.org/keepassgo/passwords"
keepassgov1 "git.julianfamily.org/keepassgo/proto/keepassgo/v1"
"git.julianfamily.org/keepassgo/webdav"
"git.julianfamily.org/keepassgo/session"
"git.julianfamily.org/keepassgo/vault"
"git.julianfamily.org/keepassgo/webdav"
"google.golang.org/grpc"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/metadata"
@@ -36,6 +38,8 @@ type lifecycleBackend interface {
Open(string, vault.MasterKey) error
OpenRemote(webdav.Client, string, vault.MasterKey) error
Save() error
Lock() error
Unlock(vault.MasterKey) error
}
func NewServer(model vault.Model, profiles map[string]passwords.Profile, clipboardWriter clipboard.Writer) *Server {
@@ -57,8 +61,8 @@ func (s *Server) GetSessionStatus(_ context.Context, _ *keepassgov1.GetSessionSt
defer s.mu.RUnlock()
return &keepassgov1.GetSessionStatusResponse{
Locked: s.locked,
Dirty: s.dirty,
Locked: s.locked,
Dirty: s.dirty,
EntryCount: uint32(len(s.model.Entries)),
}, nil
}
@@ -70,12 +74,12 @@ func (s *Server) OpenVault(_ context.Context, req *keepassgov1.OpenVaultRequest)
key := vault.MasterKey{Password: req.GetPassword(), KeyFileData: append([]byte(nil), req.GetKeyFileData()...)}
if err := s.lifecycle.Open(req.GetPath(), key); err != nil {
return nil, status.Errorf(codes.Internal, "open vault: %v", err)
return nil, mapLifecycleError("open vault", err)
}
model, err := s.lifecycle.Current()
if err != nil {
return nil, status.Errorf(codes.Internal, "load opened vault: %v", err)
return nil, mapLifecycleError("load opened vault", err)
}
s.mu.Lock()
@@ -99,12 +103,12 @@ func (s *Server) OpenRemoteVault(_ context.Context, req *keepassgov1.OpenRemoteV
}
key := vault.MasterKey{Password: req.GetMasterPassword(), KeyFileData: append([]byte(nil), req.GetKeyFileData()...)}
if err := s.lifecycle.OpenRemote(client, req.GetPath(), key); err != nil {
return nil, status.Errorf(codes.Internal, "open remote vault: %v", err)
return nil, mapLifecycleError("open remote vault", err)
}
model, err := s.lifecycle.Current()
if err != nil {
return nil, status.Errorf(codes.Internal, "load opened remote vault: %v", err)
return nil, mapLifecycleError("load opened remote vault", err)
}
s.mu.Lock()
@@ -122,7 +126,7 @@ func (s *Server) SaveVault(_ context.Context, _ *keepassgov1.SaveVaultRequest) (
}
if err := s.lifecycle.Save(); err != nil {
return nil, status.Errorf(codes.Internal, "save vault: %v", err)
return nil, mapLifecycleError("save vault", err)
}
s.mu.Lock()
@@ -133,23 +137,59 @@ func (s *Server) SaveVault(_ context.Context, _ *keepassgov1.SaveVaultRequest) (
}
func (s *Server) LockVault(_ context.Context, _ *keepassgov1.LockVaultRequest) (*keepassgov1.LockVaultResponse, error) {
s.mu.Lock()
defer s.mu.Unlock()
if s.lifecycle == nil {
return nil, status.Error(codes.FailedPrecondition, "vault lifecycle backend is not configured")
}
if err := s.lifecycle.Lock(); err != nil {
return nil, mapLifecycleError("lock vault", err)
}
s.mu.Lock()
s.locked = true
s.mu.Unlock()
return &keepassgov1.LockVaultResponse{}, nil
}
func (s *Server) UnlockVault(_ context.Context, _ *keepassgov1.UnlockVaultRequest) (*keepassgov1.UnlockVaultResponse, error) {
s.mu.Lock()
defer s.mu.Unlock()
func (s *Server) UnlockVault(_ context.Context, req *keepassgov1.UnlockVaultRequest) (*keepassgov1.UnlockVaultResponse, error) {
if s.lifecycle == nil {
return nil, status.Error(codes.FailedPrecondition, "vault lifecycle backend is not configured")
}
key := vault.MasterKey{Password: req.GetPassword(), KeyFileData: append([]byte(nil), req.GetKeyFileData()...)}
if err := s.lifecycle.Unlock(key); err != nil {
return nil, mapLifecycleError("unlock vault", err)
}
model, err := s.lifecycle.Current()
if err != nil {
return nil, mapLifecycleError("load unlocked vault", err)
}
s.mu.Lock()
s.model = model
s.locked = false
s.mu.Unlock()
return &keepassgov1.UnlockVaultResponse{}, nil
}
func mapLifecycleError(operation string, err error) error {
switch {
case errors.Is(err, os.ErrNotExist):
return status.Errorf(codes.NotFound, "%s: %v", operation, err)
case errors.Is(err, vault.ErrInvalidMasterKey):
return status.Errorf(codes.InvalidArgument, "%s: %v", operation, err)
case errors.Is(err, session.ErrLocked), errors.Is(err, session.ErrNoPath):
return status.Errorf(codes.FailedPrecondition, "%s: %v", operation, err)
case errors.Is(err, webdav.ErrConflict):
return status.Errorf(codes.Aborted, "%s: %v", operation, err)
default:
return status.Errorf(codes.Internal, "%s: %v", operation, err)
}
}
func (s *Server) ListEntries(_ context.Context, req *keepassgov1.ListEntriesRequest) (*keepassgov1.ListEntriesResponse, error) {
s.mu.RLock()
defer s.mu.RUnlock()
@@ -224,6 +264,29 @@ func (s *Server) RenameGroup(_ context.Context, req *keepassgov1.RenameGroupRequ
return &keepassgov1.RenameGroupResponse{}, nil
}
func (s *Server) DeleteGroup(_ context.Context, req *keepassgov1.DeleteGroupRequest) (*keepassgov1.DeleteGroupResponse, error) {
s.mu.Lock()
defer s.mu.Unlock()
if s.locked {
return nil, status.Error(codes.FailedPrecondition, "vault is locked")
}
if err := s.model.DeleteGroup(req.GetPath()); err != nil {
switch {
case errors.Is(err, vault.ErrEntryNotFound):
return nil, status.Error(codes.NotFound, err.Error())
case errors.Is(err, vault.ErrGroupNotEmpty):
return nil, status.Error(codes.FailedPrecondition, err.Error())
default:
return nil, status.Errorf(codes.Internal, "delete group: %v", err)
}
}
s.dirty = true
return &keepassgov1.DeleteGroupResponse{}, nil
}
func (s *Server) UpsertEntry(_ context.Context, req *keepassgov1.UpsertEntryRequest) (*keepassgov1.UpsertEntryResponse, error) {
if req.GetEntry() == nil {
return nil, status.Error(codes.InvalidArgument, "missing entry")
@@ -542,9 +605,9 @@ func (s *Server) GeneratePassword(_ context.Context, req *keepassgov1.GeneratePa
return nil, status.Error(codes.FailedPrecondition, "vault is locked")
}
profile, ok := s.profiles[req.GetProfile()]
if !ok {
return nil, status.Errorf(codes.InvalidArgument, "unknown password profile %q", req.GetProfile())
profile, err := passwords.LookupProfile(req.GetProfile(), s.profiles)
if err != nil {
return nil, status.Error(codes.InvalidArgument, err.Error())
}
password, err := passwords.Generate(profile)
@@ -565,6 +628,7 @@ func entryToProto(entry vault.Entry) *keepassgov1.Entry {
Notes: entry.Notes,
Tags: append([]string(nil), entry.Tags...),
Path: append([]string(nil), entry.Path...),
Fields: maps.Clone(entry.Fields),
}
}
@@ -578,6 +642,7 @@ func entryFromProto(entry *keepassgov1.Entry) vault.Entry {
Notes: entry.GetNotes(),
Tags: append([]string(nil), entry.GetTags()...),
Path: append([]string(nil), entry.GetPath()...),
Fields: maps.Clone(entry.GetFields()),
}
}
+371 -15
View File
@@ -4,6 +4,7 @@ import (
"bytes"
"context"
"net"
"os"
"testing"
"git.julianfamily.org/keepassgo/passwords"
@@ -34,7 +35,21 @@ func TestVaultServiceRejectsRequestsWithoutBearerToken(t *testing.T) {
func TestVaultServiceReportsSessionStatusAndSupportsLockUnlock(t *testing.T) {
t.Parallel()
client, _, cleanup := newTestClient(t)
lifecycle := &stubLifecycle{
model: vault.Model{
Entries: []vault.Entry{
{
ID: "git-server",
Title: "Git Server",
Username: "joejulian",
Password: "token-1",
URL: "https://git.julianfamily.org",
Path: []string{"Root", "Internet"},
},
},
},
}
client, _, cleanup := newTestClientWithLifecycle(t, lifecycle)
defer cleanup()
ctx := metadata.AppendToOutgoingContext(context.Background(), "authorization", "Bearer test-token")
@@ -78,6 +93,82 @@ func TestVaultServiceReportsSessionStatusAndSupportsLockUnlock(t *testing.T) {
}
}
func TestVaultServiceLockAndUnlockUseLifecycleBackend(t *testing.T) {
t.Parallel()
lifecycle := &stubLifecycle{
model: vault.Model{
Entries: []vault.Entry{
{ID: "entry-1", Title: "Remote Git", Path: []string{"Root", "Internet"}},
},
},
unlockPassword: "correct horse battery staple",
unlockKeyFile: []byte("key-material"),
}
client, _, cleanup := newTestClientWithLifecycle(t, lifecycle)
defer cleanup()
ctx := metadata.AppendToOutgoingContext(context.Background(), "authorization", "Bearer test-token")
if _, err := client.OpenVault(ctx, &keepassgov1.OpenVaultRequest{
Path: "/tmp/test.kdbx",
Password: lifecycle.unlockPassword,
KeyFileData: lifecycle.unlockKeyFile,
}); err != nil {
t.Fatalf("OpenVault() error = %v", err)
}
if _, err := client.LockVault(ctx, &keepassgov1.LockVaultRequest{}); err != nil {
t.Fatalf("LockVault() error = %v", err)
}
if !lifecycle.locked {
t.Fatal("LockVault() did not lock lifecycle backend")
}
statusResp, err := client.GetSessionStatus(ctx, &keepassgov1.GetSessionStatusRequest{})
if err != nil {
t.Fatalf("GetSessionStatus() after lock error = %v", err)
}
if !statusResp.Locked {
t.Fatal("GetSessionStatus().Locked = false, want true after lock")
}
if _, err := client.UnlockVault(ctx, &keepassgov1.UnlockVaultRequest{
Password: "wrong password",
KeyFileData: lifecycle.unlockKeyFile,
}); status.Code(err) != codes.InvalidArgument {
t.Fatalf("UnlockVault(wrong password) code = %v, want %v", status.Code(err), codes.InvalidArgument)
}
statusResp, err = client.GetSessionStatus(ctx, &keepassgov1.GetSessionStatusRequest{})
if err != nil {
t.Fatalf("GetSessionStatus() after failed unlock error = %v", err)
}
if !statusResp.Locked {
t.Fatal("GetSessionStatus().Locked = false, want true after failed unlock")
}
if _, err := client.UnlockVault(ctx, &keepassgov1.UnlockVaultRequest{
Password: lifecycle.unlockPassword,
KeyFileData: lifecycle.unlockKeyFile,
}); err != nil {
t.Fatalf("UnlockVault() error = %v", err)
}
if lifecycle.lastUnlockKey.Password != lifecycle.unlockPassword {
t.Fatalf("UnlockVault() password = %q, want %q", lifecycle.lastUnlockKey.Password, lifecycle.unlockPassword)
}
if !bytes.Equal(lifecycle.lastUnlockKey.KeyFileData, lifecycle.unlockKeyFile) {
t.Fatalf("UnlockVault() key data = %q, want %q", lifecycle.lastUnlockKey.KeyFileData, lifecycle.unlockKeyFile)
}
listed, err := client.ListEntries(ctx, &keepassgov1.ListEntriesRequest{Path: []string{"Root", "Internet"}})
if err != nil {
t.Fatalf("ListEntries() after unlock error = %v", err)
}
if len(listed.Entries) != 1 || listed.Entries[0].Title != "Remote Git" {
t.Fatalf("ListEntries().Entries = %#v, want Remote Git after unlock", listed.Entries)
}
}
func TestVaultServiceOpensAndSavesVaultThroughLifecycleBackend(t *testing.T) {
t.Parallel()
@@ -131,6 +222,143 @@ func TestVaultServiceOpensAndSavesVaultThroughLifecycleBackend(t *testing.T) {
}
}
func TestVaultServiceLifecycleMethodsRequireLifecycleBackend(t *testing.T) {
t.Parallel()
client, _, cleanup := newTestClient(t)
defer cleanup()
ctx := metadata.AppendToOutgoingContext(context.Background(), "authorization", "Bearer test-token")
testCases := []struct {
name string
call func() error
}{
{
name: "open",
call: func() error {
_, err := client.OpenVault(ctx, &keepassgov1.OpenVaultRequest{Path: "/tmp/test.kdbx"})
return err
},
},
{
name: "open_remote",
call: func() error {
_, err := client.OpenRemoteVault(ctx, &keepassgov1.OpenRemoteVaultRequest{
BaseUrl: "https://dav.example.com",
Path: "vaults/main.kdbx",
})
return err
},
},
{
name: "save",
call: func() error {
_, err := client.SaveVault(ctx, &keepassgov1.SaveVaultRequest{})
return err
},
},
{
name: "lock",
call: func() error {
_, err := client.LockVault(ctx, &keepassgov1.LockVaultRequest{})
return err
},
},
{
name: "unlock",
call: func() error {
_, err := client.UnlockVault(ctx, &keepassgov1.UnlockVaultRequest{})
return err
},
},
}
for _, tt := range testCases {
tt := tt
t.Run(tt.name, func(t *testing.T) {
err := tt.call()
if status.Code(err) != codes.FailedPrecondition {
t.Fatalf("%s code = %v, want %v", tt.name, status.Code(err), codes.FailedPrecondition)
}
})
}
}
func TestVaultServiceLifecycleMethodsMapBackendErrors(t *testing.T) {
t.Parallel()
testCases := []struct {
name string
call func(keepassgov1.VaultServiceClient, context.Context) error
err error
want codes.Code
}{
{
name: "open not found",
call: func(client keepassgov1.VaultServiceClient, ctx context.Context) error {
_, err := client.OpenVault(ctx, &keepassgov1.OpenVaultRequest{Path: "/tmp/missing.kdbx"})
return err
},
err: os.ErrNotExist,
want: codes.NotFound,
},
{
name: "open invalid master key",
call: func(client keepassgov1.VaultServiceClient, ctx context.Context) error {
_, err := client.OpenVault(ctx, &keepassgov1.OpenVaultRequest{Path: "/tmp/test.kdbx"})
return err
},
err: vault.ErrInvalidMasterKey,
want: codes.InvalidArgument,
},
{
name: "save no path",
call: func(client keepassgov1.VaultServiceClient, ctx context.Context) error {
_, err := client.SaveVault(ctx, &keepassgov1.SaveVaultRequest{})
return err
},
err: session.ErrNoPath,
want: codes.FailedPrecondition,
},
{
name: "lock already locked",
call: func(client keepassgov1.VaultServiceClient, ctx context.Context) error {
_, err := client.LockVault(ctx, &keepassgov1.LockVaultRequest{})
return err
},
err: session.ErrLocked,
want: codes.FailedPrecondition,
},
{
name: "unlock invalid master key",
call: func(client keepassgov1.VaultServiceClient, ctx context.Context) error {
_, err := client.UnlockVault(ctx, &keepassgov1.UnlockVaultRequest{Password: "wrong"})
return err
},
err: vault.ErrInvalidMasterKey,
want: codes.InvalidArgument,
},
}
for _, tt := range testCases {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
lifecycle := &stubLifecycle{err: tt.err}
client, _, cleanup := newTestClientWithLifecycle(t, lifecycle)
defer cleanup()
ctx := metadata.AppendToOutgoingContext(context.Background(), "authorization", "Bearer test-token")
err := tt.call(client, ctx)
if status.Code(err) != tt.want {
t.Fatalf("%s code = %v, want %v", tt.name, status.Code(err), tt.want)
}
})
}
}
func TestVaultServiceListsEntriesForAuthorizedClients(t *testing.T) {
t.Parallel()
@@ -150,6 +378,9 @@ func TestVaultServiceListsEntriesForAuthorizedClients(t *testing.T) {
if resp.Entries[0].Title != "Git Server" {
t.Fatalf("ListEntries().Entries[0].Title = %q, want %q", resp.Entries[0].Title, "Git Server")
}
if got := resp.Entries[0].Fields["X-Role"]; got != "automation" {
t.Fatalf("ListEntries().Entries[0].Fields[X-Role] = %q, want %q", got, "automation")
}
}
func TestVaultServiceListsCreatesAndRenamesGroupsForAuthorizedClients(t *testing.T) {
@@ -199,6 +430,42 @@ func TestVaultServiceListsCreatesAndRenamesGroupsForAuthorizedClients(t *testing
}
}
func TestVaultServiceDeletesEmptyGroupsAndRejectsNonEmptyGroups(t *testing.T) {
t.Parallel()
client, _, cleanup := newTestClient(t)
defer cleanup()
ctx := metadata.AppendToOutgoingContext(context.Background(), "authorization", "Bearer test-token")
if _, err := client.CreateGroup(ctx, &keepassgov1.CreateGroupRequest{
ParentPath: []string{"Root"},
Name: "Finance",
}); err != nil {
t.Fatalf("CreateGroup() error = %v", err)
}
if _, err := client.DeleteGroup(ctx, &keepassgov1.DeleteGroupRequest{
Path: []string{"Root", "Finance"},
}); err != nil {
t.Fatalf("DeleteGroup() error = %v, want success for empty group", err)
}
listed, err := client.ListGroups(ctx, &keepassgov1.ListGroupsRequest{Path: []string{"Root"}})
if err != nil {
t.Fatalf("ListGroups() error = %v", err)
}
if len(listed.Names) != 2 || listed.Names[0] != "Home Assistant" || listed.Names[1] != "Internet" {
t.Fatalf("ListGroups().Names = %#v, want empty Finance group removed", listed.Names)
}
_, err = client.DeleteGroup(ctx, &keepassgov1.DeleteGroupRequest{
Path: []string{"Root", "Internet"},
})
if status.Code(err) != codes.FailedPrecondition {
t.Fatalf("DeleteGroup() code = %v, want %v for non-empty group", status.Code(err), codes.FailedPrecondition)
}
}
func TestVaultServiceGeneratesPasswordsForAuthorizedClients(t *testing.T) {
t.Parallel()
@@ -216,6 +483,19 @@ func TestVaultServiceGeneratesPasswordsForAuthorizedClients(t *testing.T) {
}
}
func TestVaultServiceGeneratePasswordRejectsUnknownProfiles(t *testing.T) {
t.Parallel()
client, _, cleanup := newTestClient(t)
defer cleanup()
ctx := metadata.AppendToOutgoingContext(context.Background(), "authorization", "Bearer test-token")
_, err := client.GeneratePassword(ctx, &keepassgov1.GeneratePasswordRequest{Profile: "invalid"})
if status.Code(err) != codes.InvalidArgument {
t.Fatalf("GeneratePassword() code = %v, want %v", status.Code(err), codes.InvalidArgument)
}
}
func TestVaultServiceCopiesEntryFieldsForAuthorizedClients(t *testing.T) {
t.Parallel()
@@ -249,7 +529,10 @@ func TestVaultServiceUpsertsEntriesForAuthorizedClients(t *testing.T) {
Username: "codex",
Password: "token-2",
Url: "https://lights.julianfamily.org",
Path: []string{"Root", "Home Assistant"},
Fields: map[string]string{
"X-Role": "lights-admin",
},
Path: []string{"Root", "Home Assistant"},
},
})
if err != nil {
@@ -259,6 +542,9 @@ func TestVaultServiceUpsertsEntriesForAuthorizedClients(t *testing.T) {
if upserted.Entry.Title != "Home Assistant (Codex)" {
t.Fatalf("UpsertEntry().Entry.Title = %q, want %q", upserted.Entry.Title, "Home Assistant (Codex)")
}
if got := upserted.Entry.Fields["X-Role"]; got != "lights-admin" {
t.Fatalf("UpsertEntry().Entry.Fields[X-Role] = %q, want %q", got, "lights-admin")
}
listed, err := client.ListEntries(ctx, &keepassgov1.ListEntriesRequest{Path: []string{"Root", "Home Assistant"}})
if err != nil {
@@ -268,6 +554,9 @@ func TestVaultServiceUpsertsEntriesForAuthorizedClients(t *testing.T) {
if len(listed.Entries) != 1 || listed.Entries[0].Password != "token-2" {
t.Fatalf("ListEntries().Entries = %#v, want persisted Home Assistant entry", listed.Entries)
}
if got := listed.Entries[0].Fields["X-Role"]; got != "lights-admin" {
t.Fatalf("ListEntries().Entries[0].Fields[X-Role] = %q, want %q", got, "lights-admin")
}
}
func TestVaultServiceDeletesAndRestoresEntriesForAuthorizedClients(t *testing.T) {
@@ -324,6 +613,9 @@ func TestVaultServiceListsAndInstantiatesTemplatesForAuthorizedClients(t *testin
if len(templates.Templates) != 1 || templates.Templates[0].Title != "Website Login" {
t.Fatalf("ListTemplates().Templates = %#v, want Website Login template", templates.Templates)
}
if got := templates.Templates[0].Fields["Environment"]; got != "prod" {
t.Fatalf("ListTemplates().Templates[0].Fields[Environment] = %q, want %q", got, "prod")
}
instantiated, err := client.InstantiateTemplate(ctx, &keepassgov1.InstantiateTemplateRequest{
TemplateId: "website-login",
@@ -333,8 +625,11 @@ func TestVaultServiceListsAndInstantiatesTemplatesForAuthorizedClients(t *testin
Username: "jjulian",
Password: "hunter2",
Url: "https://www.dynadot.com",
Path: []string{"Root", "Internet"},
Tags: []string{"dns"},
Fields: map[string]string{
"Environment": "staging",
},
Path: []string{"Root", "Internet"},
Tags: []string{"dns"},
},
})
if err != nil {
@@ -344,6 +639,9 @@ func TestVaultServiceListsAndInstantiatesTemplatesForAuthorizedClients(t *testin
if instantiated.Entry.Title != "Dynadot" || instantiated.Entry.Notes != "Reusable template for website accounts." {
t.Fatalf("InstantiateTemplate().Entry = %#v, want Dynadot entry with template notes", instantiated.Entry)
}
if got := instantiated.Entry.Fields["Environment"]; got != "staging" {
t.Fatalf("InstantiateTemplate().Entry.Fields[Environment] = %q, want %q", got, "staging")
}
listed, err := client.ListEntries(ctx, &keepassgov1.ListEntriesRequest{Path: []string{"Root", "Internet"}})
if err != nil {
@@ -368,7 +666,10 @@ func TestVaultServiceUpsertsAndDeletesTemplatesForAuthorizedClients(t *testing.T
Title: "Website Login Updated",
Username: "template-user",
Password: "template-password",
Path: []string{"Templates", "Web"},
Fields: map[string]string{
"Environment": "dev",
},
Path: []string{"Templates", "Web"},
},
})
if err != nil {
@@ -377,6 +678,9 @@ func TestVaultServiceUpsertsAndDeletesTemplatesForAuthorizedClients(t *testing.T
if upserted.Template.Title != "Website Login Updated" {
t.Fatalf("UpsertTemplate().Template.Title = %q, want updated title", upserted.Template.Title)
}
if got := upserted.Template.Fields["Environment"]; got != "dev" {
t.Fatalf("UpsertTemplate().Template.Fields[Environment] = %q, want %q", got, "dev")
}
listed, err := client.ListTemplates(ctx, &keepassgov1.ListTemplatesRequest{})
if err != nil {
@@ -385,6 +689,9 @@ func TestVaultServiceUpsertsAndDeletesTemplatesForAuthorizedClients(t *testing.T
if len(listed.Templates) != 1 || listed.Templates[0].Title != "Website Login Updated" {
t.Fatalf("ListTemplates().Templates = %#v, want updated template", listed.Templates)
}
if got := listed.Templates[0].Fields["Environment"]; got != "dev" {
t.Fatalf("ListTemplates().Templates[0].Fields[Environment] = %q, want %q", got, "dev")
}
if _, err := client.DeleteTemplate(ctx, &keepassgov1.DeleteTemplateRequest{Id: "website-login"}); err != nil {
t.Fatalf("DeleteTemplate() error = %v", err)
@@ -493,6 +800,9 @@ func newTestClient(t *testing.T) (keepassgov1.VaultServiceClient, *memoryClipboa
Username: "joejulian",
Password: "token-1",
URL: "https://git.julianfamily.org",
Fields: map[string]string{
"X-Role": "automation",
},
History: []vault.Entry{
{
ID: "git-server-h1",
@@ -503,7 +813,7 @@ func newTestClient(t *testing.T) (keepassgov1.VaultServiceClient, *memoryClipboa
Path: []string{"Root", "Internet"},
},
},
Path: []string{"Root", "Internet"},
Path: []string{"Root", "Internet"},
},
{
ID: "ha-codex",
@@ -522,8 +832,11 @@ func newTestClient(t *testing.T) (keepassgov1.VaultServiceClient, *memoryClipboa
Password: "template-password",
URL: "https://example.com",
Notes: "Reusable template for website accounts.",
Tags: []string{"template", "web"},
Path: []string{"Templates"},
Fields: map[string]string{
"Environment": "prod",
},
Tags: []string{"template", "web"},
Path: []string{"Templates"},
},
},
},
@@ -560,7 +873,7 @@ func newTestClientWithLifecycle(t *testing.T, lifecycle *stubLifecycle) (keepass
server := grpc.NewServer(grpc.UnaryInterceptor(BearerTokenInterceptor("test-token")))
clipboardWriter := &memoryClipboardWriter{}
keepassgov1.RegisterVaultServiceServer(server, NewServerWithLifecycle(
vault.Model{},
lifecycle.model,
passwords.DefaultProfiles(),
clipboardWriter,
lifecycle,
@@ -598,12 +911,16 @@ func (w *memoryClipboardWriter) WriteText(text string) error {
}
type stubLifecycle struct {
model vault.Model
openPath string
remoteBaseURL string
remotePath string
saved bool
locked bool
model vault.Model
openPath string
remoteBaseURL string
remotePath string
saved bool
locked bool
err error
unlockPassword string
unlockKeyFile []byte
lastUnlockKey vault.MasterKey
}
func (s *stubLifecycle) Current() (vault.Model, error) {
@@ -614,17 +931,56 @@ func (s *stubLifecycle) Current() (vault.Model, error) {
}
func (s *stubLifecycle) Open(path string, _ vault.MasterKey) error {
if s.err != nil {
return s.err
}
s.openPath = path
s.locked = false
return nil
}
func (s *stubLifecycle) OpenRemote(client webdav.Client, path string, _ vault.MasterKey) error {
if s.err != nil {
return s.err
}
s.remoteBaseURL = client.BaseURL
s.remotePath = path
s.locked = false
return nil
}
func (s *stubLifecycle) Save() error {
if s.err != nil {
return s.err
}
s.saved = true
return nil
}
func (s *stubLifecycle) Lock() error {
if s.err != nil {
return s.err
}
s.locked = true
return nil
}
func (s *stubLifecycle) Unlock(key vault.MasterKey) error {
if s.err != nil {
return s.err
}
if s.unlockPassword != "" && key.Password != s.unlockPassword {
return vault.ErrInvalidMasterKey
}
if s.unlockKeyFile != nil && !bytes.Equal(key.KeyFileData, s.unlockKeyFile) {
return vault.ErrInvalidMasterKey
}
s.lastUnlockKey = vault.MasterKey{
Password: key.Password,
KeyFileData: append([]byte(nil), key.KeyFileData...),
}
s.locked = false
return nil
}
+199
View File
@@ -1,6 +1,7 @@
package appstate
import (
"errors"
"fmt"
"slices"
"strings"
@@ -11,6 +12,11 @@ import (
type Section string
var (
ErrAttachmentAlreadyExists = errors.New("attachment already exists")
ErrAttachmentNotFound = errors.New("attachment not found")
)
const (
SectionEntries Section = ""
SectionTemplates Section = "templates"
@@ -32,11 +38,29 @@ type LockableSession interface {
Unlock(vault.MasterKey) error
}
type MasterKeyChangeableSession interface {
CurrentSession
ChangeMasterKey(vault.MasterKey) error
}
type SaveableSession interface {
CurrentSession
Save() error
}
type SynchronizableSession interface {
CurrentSession
Synchronize() error
}
type AdvancedSynchronizableSession interface {
CurrentSession
SynchronizeFromLocal(string) error
SynchronizeToLocal(string) error
SynchronizeFromRemote(webdav.Client, string) error
SynchronizeToRemote(webdav.Client, string) error
}
type CreateableSession interface {
CurrentSession
Create(vault.Model, vault.MasterKey) error
@@ -64,6 +88,35 @@ type State struct {
SearchQuery string
SelectedEntryID string
Dirty bool
StatusMessage string
ErrorMessage string
}
func (s *State) ShowSection(section Section) {
s.Section = section
s.CurrentPath = nil
s.SelectedEntryID = ""
}
func (s *State) SetSearchQuery(query string) {
s.SearchQuery = query
}
func (s *State) BeginNewEntry() {
s.SelectedEntryID = ""
s.StatusMessage = ""
s.ErrorMessage = ""
}
func (s *State) SetActionResult(label string, err error) {
if err != nil {
s.ErrorMessage = err.Error()
s.StatusMessage = ""
return
}
s.ErrorMessage = ""
s.StatusMessage = label + " complete"
}
func (s *State) VisibleEntries() ([]vault.Entry, error) {
@@ -156,6 +209,19 @@ func (s *State) entriesForSection(model vault.Model) []vault.Entry {
}
}
func (s State) SearchPathContext(entry vault.Entry) string {
path := slices.Clone(entry.Path)
switch s.Section {
case SectionTemplates:
if len(path) == 0 || path[0] != "Templates" {
path = append([]string{"Templates"}, path...)
}
case SectionRecycleBin:
path = append([]string{"Recycle Bin"}, path...)
}
return strings.Join(path, " / ")
}
func entriesInPath(entries []vault.Entry, path []string) []vault.Entry {
var out []vault.Entry
for _, entry := range entries {
@@ -419,6 +485,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 = ""
@@ -443,6 +523,68 @@ func (s *State) Save() error {
return nil
}
func (s *State) Synchronize() error {
session, ok := s.Session.(SynchronizableSession)
if !ok {
return fmt.Errorf("session is not synchronizable")
}
if err := session.Synchronize(); err != nil {
return err
}
s.Dirty = false
return nil
}
func (s *State) SynchronizeFromLocal(path string) error {
session, ok := s.Session.(AdvancedSynchronizableSession)
if !ok {
return fmt.Errorf("session is not advanced-synchronizable")
}
if err := session.SynchronizeFromLocal(path); err != nil {
return err
}
s.Dirty = false
return nil
}
func (s *State) SynchronizeToLocal(path string) error {
session, ok := s.Session.(AdvancedSynchronizableSession)
if !ok {
return fmt.Errorf("session is not advanced-synchronizable")
}
if err := session.SynchronizeToLocal(path); err != nil {
return err
}
s.Dirty = true
return nil
}
func (s *State) SynchronizeFromRemote(client webdav.Client, path string) error {
session, ok := s.Session.(AdvancedSynchronizableSession)
if !ok {
return fmt.Errorf("session is not advanced-synchronizable")
}
if err := session.SynchronizeFromRemote(client, path); err != nil {
return err
}
s.Dirty = false
return nil
}
func (s *State) SynchronizeToRemote(client webdav.Client, path string) error {
session, ok := s.Session.(AdvancedSynchronizableSession)
if !ok {
return fmt.Errorf("session is not advanced-synchronizable")
}
if err := session.SynchronizeToRemote(client, path); err != nil {
return err
}
s.Dirty = true
return nil
}
func (s *State) CreateVault(key vault.MasterKey) error {
session, ok := s.Session.(CreateableSession)
if !ok {
@@ -565,6 +707,30 @@ func (s *State) MoveSelectedEntry(path []string) error {
return nil
}
func (s *State) DeleteCurrentGroup() error {
session, ok := s.Session.(MutableSession)
if !ok {
return fmt.Errorf("session is not mutable")
}
model, err := session.Current()
if err != nil {
return err
}
if err := model.DeleteGroup(s.CurrentPath); err != nil {
return err
}
session.Replace(model)
if len(s.CurrentPath) > 0 {
s.CurrentPath = append([]string(nil), s.CurrentPath[:len(s.CurrentPath)-1]...)
}
s.SelectedEntryID = ""
s.Dirty = true
return nil
}
func (s *State) AddAttachmentToSelectedEntry(name string, content []byte) error {
session, ok := s.Session.(MutableSession)
if !ok {
@@ -583,6 +749,36 @@ func (s *State) AddAttachmentToSelectedEntry(name string, content []byte) error
if model.Entries[i].Attachments == nil {
model.Entries[i].Attachments = map[string][]byte{}
}
if _, exists := model.Entries[i].Attachments[name]; exists {
return ErrAttachmentAlreadyExists
}
model.Entries[i].Attachments[name] = append([]byte(nil), content...)
session.Replace(model)
s.Dirty = true
return nil
}
return vault.ErrEntryNotFound
}
func (s *State) ReplaceAttachmentOnSelectedEntry(name string, content []byte) error {
session, ok := s.Session.(MutableSession)
if !ok {
return fmt.Errorf("session is not mutable")
}
model, err := session.Current()
if err != nil {
return err
}
for i := range model.Entries {
if model.Entries[i].ID != s.SelectedEntryID {
continue
}
if _, exists := model.Entries[i].Attachments[name]; !exists {
return ErrAttachmentNotFound
}
model.Entries[i].Attachments[name] = append([]byte(nil), content...)
session.Replace(model)
s.Dirty = true
@@ -607,6 +803,9 @@ func (s *State) DeleteAttachmentFromSelectedEntry(name string) error {
if model.Entries[i].ID != s.SelectedEntryID {
continue
}
if _, exists := model.Entries[i].Attachments[name]; !exists {
return ErrAttachmentNotFound
}
delete(model.Entries[i].Attachments, name)
if len(model.Entries[i].Attachments) == 0 {
model.Entries[i].Attachments = nil
+424
View File
@@ -117,6 +117,142 @@ func TestVisibleEntriesUsesRecycleBinSection(t *testing.T) {
}
}
func TestVisibleEntriesUsesGlobalSearchWithinTemplateSection(t *testing.T) {
t.Parallel()
state := State{
Session: stubSession{
model: vault.Model{
Templates: []vault.Entry{
{ID: "tpl-1", Title: "Website Login", URL: "https://accounts.example.com", Path: []string{"Templates", "Web"}},
{ID: "tpl-2", Title: "SSH Login", URL: "ssh://infra.internal", Path: []string{"Templates", "Infra"}},
},
},
},
Section: SectionTemplates,
CurrentPath: []string{"Templates", "Web"},
SearchQuery: "infra",
}
got, err := state.VisibleEntries()
if err != nil {
t.Fatalf("VisibleEntries() error = %v", err)
}
if len(got) != 1 || got[0].ID != "tpl-2" {
t.Fatalf("VisibleEntries() = %#v, want global template search result tpl-2", got)
}
}
func TestVisibleEntriesResetToCurrentTemplatePathAfterClearingSearch(t *testing.T) {
t.Parallel()
state := State{
Session: stubSession{
model: vault.Model{
Templates: []vault.Entry{
{ID: "tpl-1", Title: "Website Login", Path: []string{"Templates", "Web"}},
{ID: "tpl-2", Title: "Email Login", Path: []string{"Templates", "Web"}},
{ID: "tpl-3", Title: "SSH Login", Path: []string{"Templates", "Infra"}},
},
},
},
Section: SectionTemplates,
CurrentPath: []string{"Templates", "Web"},
SearchQuery: "ssh",
}
got, err := state.VisibleEntries()
if err != nil {
t.Fatalf("VisibleEntries() with search error = %v", err)
}
if len(got) != 1 || got[0].ID != "tpl-3" {
t.Fatalf("VisibleEntries() with search = %#v, want tpl-3", got)
}
state.SearchQuery = ""
got, err = state.VisibleEntries()
if err != nil {
t.Fatalf("VisibleEntries() after clearing search error = %v", err)
}
if len(got) != 2 {
t.Fatalf("len(VisibleEntries()) after clearing search = %d, want 2", len(got))
}
if titles := []string{got[0].Title, got[1].Title}; !slices.Equal(titles, []string{"Email Login", "Website Login"}) {
t.Fatalf("VisibleEntries() after clearing search titles = %v, want [Email Login Website Login]", titles)
}
}
func TestVisibleEntriesUsesGlobalSearchWithinRecycleBin(t *testing.T) {
t.Parallel()
state := State{
Session: stubSession{
model: vault.Model{
RecycleBin: []vault.Entry{
{ID: "deleted-1", Title: "Deleted Dynadot", Path: []string{"Root", "Internet"}},
{ID: "deleted-2", Title: "Deleted HVAC", URL: "https://climate.example.com", Path: []string{"Root", "Home"}},
},
},
},
Section: SectionRecycleBin,
CurrentPath: []string{"Root", "Internet"},
SearchQuery: "climate",
}
got, err := state.VisibleEntries()
if err != nil {
t.Fatalf("VisibleEntries() error = %v", err)
}
if len(got) != 1 || got[0].ID != "deleted-2" {
t.Fatalf("VisibleEntries() = %#v, want global recycle-bin search result deleted-2", got)
}
}
func TestSearchPathContextIncludesSectionRoots(t *testing.T) {
t.Parallel()
tests := []struct {
name string
section Section
entry vault.Entry
want string
}{
{
name: "entries use direct path",
section: SectionEntries,
entry: vault.Entry{Path: []string{"Root", "Internet"}},
want: "Root / Internet",
},
{
name: "templates retain templates root",
section: SectionTemplates,
entry: vault.Entry{Path: []string{"Templates", "Web"}},
want: "Templates / Web",
},
{
name: "recycle bin prefixes root label",
section: SectionRecycleBin,
entry: vault.Entry{Path: []string{"Root", "Internet"}},
want: "Recycle Bin / Root / Internet",
},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
state := State{Section: tt.section}
if got := state.SearchPathContext(tt.entry); got != tt.want {
t.Fatalf("SearchPathContext(%v) = %q, want %q", tt.entry.Path, got, tt.want)
}
})
}
}
func TestChildGroupsUsesCurrentModelAndCurrentPath(t *testing.T) {
t.Parallel()
@@ -473,6 +609,39 @@ func TestDuplicateSelectedEntryCreatesCopyAndSelectsIt(t *testing.T) {
}
}
func TestMoveSelectedEntryMovesEntryToNewPathAndMarksDirty(t *testing.T) {
t.Parallel()
sess := &mutableStubSession{model: vault.Model{
Entries: []vault.Entry{
{ID: "git-server", Title: "Git Server", Path: []string{"Root", "Internet"}},
},
}}
state := State{
Session: sess,
CurrentPath: []string{"Root", "Internet"},
SelectedEntryID: "git-server",
}
if err := state.MoveSelectedEntry([]string{"Root", "Infrastructure"}); err != nil {
t.Fatalf("MoveSelectedEntry() error = %v", err)
}
oldPath := sess.model.EntriesInPath([]string{"Root", "Internet"})
if len(oldPath) != 0 {
t.Fatalf("EntriesInPath(Root/Internet) = %#v, want empty after move", oldPath)
}
newPath := sess.model.EntriesInPath([]string{"Root", "Infrastructure"})
if len(newPath) != 1 || newPath[0].ID != "git-server" {
t.Fatalf("EntriesInPath(Root/Infrastructure) = %#v, want moved git-server entry", newPath)
}
if !state.Dirty {
t.Fatal("Dirty = false, want true after move")
}
}
func TestRestoreSelectedEntryVersionReplacesCurrentVersion(t *testing.T) {
t.Parallel()
@@ -696,6 +865,106 @@ 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 TestShowSectionResetsPathAndSelection(t *testing.T) {
t.Parallel()
state := State{
Section: SectionEntries,
CurrentPath: []string{"Root", "Internet"},
SelectedEntryID: "git-server",
SearchQuery: "git",
}
state.ShowSection(SectionTemplates)
if state.Section != SectionTemplates {
t.Fatalf("Section = %q, want %q", state.Section, SectionTemplates)
}
if len(state.CurrentPath) != 0 {
t.Fatalf("CurrentPath = %v, want empty", state.CurrentPath)
}
if state.SelectedEntryID != "" {
t.Fatalf("SelectedEntryID = %q, want empty", state.SelectedEntryID)
}
if state.SearchQuery != "git" {
t.Fatalf("SearchQuery = %q, want search preserved", state.SearchQuery)
}
}
func TestSetSearchQueryUpdatesControllerSearchState(t *testing.T) {
t.Parallel()
state := State{}
state.SetSearchQuery("lights")
if state.SearchQuery != "lights" {
t.Fatalf("SearchQuery = %q, want %q", state.SearchQuery, "lights")
}
}
func TestBeginNewEntryClearsSelectionAndStatus(t *testing.T) {
t.Parallel()
state := State{
SelectedEntryID: "git-server",
ErrorMessage: "previous error",
}
state.BeginNewEntry()
if state.SelectedEntryID != "" {
t.Fatalf("SelectedEntryID = %q, want empty", state.SelectedEntryID)
}
if state.StatusMessage != "" {
t.Fatalf("StatusMessage = %q, want empty", state.StatusMessage)
}
if state.ErrorMessage != "" {
t.Fatalf("ErrorMessage = %q, want empty", state.ErrorMessage)
}
}
func TestSetActionResultTracksSuccessAndFailureMessages(t *testing.T) {
t.Parallel()
state := State{}
state.SetActionResult("save vault", nil)
if state.StatusMessage != "save vault complete" {
t.Fatalf("StatusMessage = %q, want save vault complete", state.StatusMessage)
}
if state.ErrorMessage != "" {
t.Fatalf("ErrorMessage = %q, want empty on success", state.ErrorMessage)
}
state.SetActionResult("save vault", errors.New("disk full"))
if state.StatusMessage != "" {
t.Fatalf("StatusMessage = %q, want empty on failure", state.StatusMessage)
}
if state.ErrorMessage != "disk full" {
t.Fatalf("ErrorMessage = %q, want disk full", state.ErrorMessage)
}
}
func TestEnterGroupAppendsPathAndClearsSelection(t *testing.T) {
t.Parallel()
@@ -732,6 +1001,37 @@ func TestNavigateToPathReplacesPathAndClearsSelection(t *testing.T) {
}
}
func TestDeleteCurrentGroupMovesToParentAndMarksDirty(t *testing.T) {
t.Parallel()
model := testVaultModel()
model.CreateGroup([]string{"Root"}, "Finance")
sess := &mutableStubSession{model: model}
state := State{
Session: sess,
CurrentPath: []string{"Root", "Finance"},
}
if err := state.DeleteCurrentGroup(); err != nil {
t.Fatalf("DeleteCurrentGroup() error = %v", err)
}
if !slices.Equal(state.CurrentPath, []string{"Root"}) {
t.Fatalf("CurrentPath = %v, want [Root]", state.CurrentPath)
}
if !state.Dirty {
t.Fatal("Dirty = false, want true after DeleteCurrentGroup")
}
got, err := state.ChildGroups()
if err != nil {
t.Fatalf("ChildGroups() error = %v", err)
}
if !slices.Equal(got, []string{"Home Assistant", "Internet"}) {
t.Fatalf("ChildGroups() = %v, want [Home Assistant Internet]", got)
}
}
func TestCreateGroupPersistsGroupAndMarksDirty(t *testing.T) {
t.Parallel()
@@ -779,6 +1079,38 @@ func TestRenameCurrentGroupUpdatesPathAndMarksDirty(t *testing.T) {
}
}
func TestDeleteCurrentGroupRemovesItNavigatesToParentAndMarksDirty(t *testing.T) {
t.Parallel()
model := testVaultModel()
model.CreateGroup([]string{"Root"}, "Finance")
sess := &mutableStubSession{model: model}
state := State{
Session: sess,
CurrentPath: []string{"Root", "Finance"},
}
if err := state.DeleteCurrentGroup(); err != nil {
t.Fatalf("DeleteCurrentGroup() error = %v", err)
}
if !slices.Equal(state.CurrentPath, []string{"Root"}) {
t.Fatalf("CurrentPath = %v, want [Root]", state.CurrentPath)
}
got, err := state.ChildGroups()
if err != nil {
t.Fatalf("ChildGroups() error = %v", err)
}
if !slices.Equal(got, []string{"Home Assistant", "Internet"}) {
t.Fatalf("ChildGroups() = %v, want [Home Assistant Internet]", got)
}
if !state.Dirty {
t.Fatal("Dirty = false, want true after DeleteCurrentGroup")
}
}
func TestMoveSelectedEntryPersistsPathChangeAndMarksDirty(t *testing.T) {
t.Parallel()
@@ -836,6 +1168,76 @@ func TestAddAttachmentToSelectedEntryPersistsAndMarksDirty(t *testing.T) {
}
}
func TestAddAttachmentToSelectedEntryRejectsDuplicateNames(t *testing.T) {
t.Parallel()
model := testVaultModel()
model.Entries[0].Attachments = map[string][]byte{"token.txt": []byte("secret")}
sess := &mutableStubSession{model: model}
state := State{
Session: sess,
CurrentPath: []string{"Root", "Internet"},
SelectedEntryID: "dynadot",
}
err := state.AddAttachmentToSelectedEntry("token.txt", []byte("replacement"))
if !errors.Is(err, ErrAttachmentAlreadyExists) {
t.Fatalf("AddAttachmentToSelectedEntry() error = %v, want ErrAttachmentAlreadyExists", err)
}
got, currentErr := sess.Current()
if currentErr != nil {
t.Fatalf("Current() error = %v", currentErr)
}
if string(got.Entries[0].Attachments["token.txt"]) != "secret" {
t.Fatalf("attachment content = %q, want %q", got.Entries[0].Attachments["token.txt"], "secret")
}
}
func TestReplaceAttachmentOnSelectedEntryPersistsAndMarksDirty(t *testing.T) {
t.Parallel()
model := testVaultModel()
model.Entries[0].Attachments = map[string][]byte{"token.txt": []byte("secret")}
sess := &mutableStubSession{model: model}
state := State{
Session: sess,
CurrentPath: []string{"Root", "Internet"},
SelectedEntryID: "dynadot",
}
if err := state.ReplaceAttachmentOnSelectedEntry("token.txt", []byte("replacement")); err != nil {
t.Fatalf("ReplaceAttachmentOnSelectedEntry() error = %v", err)
}
got, err := state.VisibleEntries()
if err != nil {
t.Fatalf("VisibleEntries() error = %v", err)
}
if string(got[0].Attachments["token.txt"]) != "replacement" {
t.Fatalf("attachment content = %q, want %q", got[0].Attachments["token.txt"], "replacement")
}
if !state.Dirty {
t.Fatal("Dirty = false, want true after ReplaceAttachmentOnSelectedEntry")
}
}
func TestReplaceAttachmentOnSelectedEntryRequiresExistingAttachment(t *testing.T) {
t.Parallel()
sess := &mutableStubSession{model: testVaultModel()}
state := State{
Session: sess,
CurrentPath: []string{"Root", "Internet"},
SelectedEntryID: "dynadot",
}
err := state.ReplaceAttachmentOnSelectedEntry("token.txt", []byte("replacement"))
if !errors.Is(err, ErrAttachmentNotFound) {
t.Fatalf("ReplaceAttachmentOnSelectedEntry() error = %v, want ErrAttachmentNotFound", err)
}
}
func TestDeleteAttachmentFromSelectedEntryPersistsAndMarksDirty(t *testing.T) {
t.Parallel()
@@ -864,6 +1266,22 @@ func TestDeleteAttachmentFromSelectedEntryPersistsAndMarksDirty(t *testing.T) {
}
}
func TestDeleteAttachmentFromSelectedEntryRequiresExistingAttachment(t *testing.T) {
t.Parallel()
sess := &mutableStubSession{model: testVaultModel()}
state := State{
Session: sess,
CurrentPath: []string{"Root", "Internet"},
SelectedEntryID: "dynadot",
}
err := state.DeleteAttachmentFromSelectedEntry("token.txt")
if !errors.Is(err, ErrAttachmentNotFound) {
t.Fatalf("DeleteAttachmentFromSelectedEntry() error = %v, want ErrAttachmentNotFound", err)
}
}
type mutableStubSession struct {
model vault.Model
err error
@@ -929,6 +1347,7 @@ type lifecycleStubSession struct {
openPath string
saveAsPath string
remotePath string
changedKey vault.MasterKey
}
func (s *lifecycleStubSession) Current() (vault.Model, error) {
@@ -954,3 +1373,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
}
+18 -2
View File
@@ -2,7 +2,6 @@ package clipboard
import (
"errors"
"fmt"
systemclipboard "github.com/atotto/clipboard"
@@ -10,6 +9,7 @@ import (
)
var ErrUnsupportedTarget = errors.New("unsupported clipboard target")
var ErrWriteFailed = errors.New("clipboard write failed")
type Target string
@@ -39,7 +39,7 @@ func (s Service) Copy(model vault.Model, entryID string, target Target) error {
}
if err := s.writer().WriteText(content); err != nil {
return fmt.Errorf("write clipboard text: %w", err)
return writeError{err: err}
}
return nil
@@ -79,3 +79,19 @@ type systemWriter struct{}
func (systemWriter) WriteText(text string) error {
return systemclipboard.WriteAll(text)
}
type writeError struct {
err error
}
func (e writeError) Error() string {
return ErrWriteFailed.Error()
}
func (e writeError) Unwrap() error {
return e.err
}
func (e writeError) Is(target error) bool {
return target == ErrWriteFailed
}
+27
View File
@@ -68,6 +68,25 @@ func TestServiceRejectsUnknownEntryAndUnsupportedTarget(t *testing.T) {
}
}
func TestServiceSanitizesClipboardWriteErrors(t *testing.T) {
t.Parallel()
service := Service{Writer: failingWriter{err: errors.New("backend refused token-1")}}
model := vault.Model{
Entries: []vault.Entry{
{ID: "git-server", Password: "token-1"},
},
}
err := service.Copy(model, "git-server", TargetPassword)
if !errors.Is(err, ErrWriteFailed) {
t.Fatalf("Copy() write error = %v, want ErrWriteFailed", err)
}
if err.Error() != ErrWriteFailed.Error() {
t.Fatalf("Copy() write error string = %q, want %q", err.Error(), ErrWriteFailed.Error())
}
}
type memoryWriter struct {
content string
}
@@ -76,3 +95,11 @@ func (w *memoryWriter) WriteText(text string) error {
w.content = text
return nil
}
type failingWriter struct {
err error
}
func (w failingWriter) WriteText(string) error {
return w.err
}
+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
+6 -1
View File
@@ -14,6 +14,7 @@ require (
require (
4d63.com/gocheckcompilerdirectives v1.3.0 // indirect
4d63.com/gochecknoglobals v0.2.2 // indirect
gioui.org/cmd v0.9.0 // indirect
gioui.org/shader v1.0.8 // indirect
github.com/4meepo/tagalign v1.4.2 // indirect
github.com/Abirdcfly/dupword v0.1.3 // indirect
@@ -26,6 +27,7 @@ require (
github.com/GaijinEntertainment/go-exhaustruct/v3 v3.3.1 // indirect
github.com/Masterminds/semver/v3 v3.3.0 // indirect
github.com/OpenPeeDeeP/depguard/v2 v2.2.1 // indirect
github.com/akavel/rsrc v0.10.1 // indirect
github.com/alecthomas/go-check-sumtype v0.3.1 // indirect
github.com/alexkohler/nakedret/v2 v2.0.5 // indirect
github.com/alexkohler/prealloc v1.0.0 // indirect
@@ -207,4 +209,7 @@ require (
mvdan.cc/unparam v0.0.0-20240528143540-8a5130ca722f // indirect
)
tool github.com/golangci/golangci-lint/cmd/golangci-lint
tool (
gioui.org/cmd/gogio
github.com/golangci/golangci-lint/cmd/golangci-lint
)
+4
View File
@@ -39,6 +39,8 @@ eliasnaur.com/font v0.0.0-20230308162249-dd43949cb42d h1:ARo7NCVvN2NdhLlJE9xAbKw
eliasnaur.com/font v0.0.0-20230308162249-dd43949cb42d/go.mod h1:OYVuxibdk9OSLX8vAqydtRPP87PyTFcT9uH3MlEGBQA=
gioui.org v0.9.0 h1:4u7XZwnb5kzQW91Nz/vR0wKD6LdW9CaVF96r3rfy4kc=
gioui.org v0.9.0/go.mod h1:CjNig0wAhLt9WZxOPAusgFD8x8IRvqt26LdDBa3Jvao=
gioui.org/cmd v0.9.0 h1:H1F2u3vBAd8TDRvaJd4IbrbbiOPBWc7Z3ZykOYoq/20=
gioui.org/cmd v0.9.0/go.mod h1:RBQfFU8JCgMjQ2wKU9DG3zMC38TnY97E5MKoBGhGl3s=
gioui.org/cpu v0.0.0-20210808092351-bfe733dd3334/go.mod h1:A8M0Cn5o+vY5LTMlnRoK3O5kG+rH0kWfJjeKd9QpBmQ=
gioui.org/shader v1.0.8 h1:6ks0o/A+b0ne7RzEqRZK5f4Gboz2CfG+mVliciy6+qA=
gioui.org/shader v1.0.8/go.mod h1:mWdiME581d/kV7/iEhLmUgUK5iZ09XR5XpduXzbePVM=
@@ -66,6 +68,8 @@ github.com/Masterminds/semver/v3 v3.3.0 h1:B8LGeaivUe71a5qox1ICM/JLl0NqZSW5CHyL+
github.com/Masterminds/semver/v3 v3.3.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM=
github.com/OpenPeeDeeP/depguard/v2 v2.2.1 h1:vckeWVESWp6Qog7UZSARNqfu/cZqvki8zsuj3piCMx4=
github.com/OpenPeeDeeP/depguard/v2 v2.2.1/go.mod h1:q4DKzC4UcVaAvcfd41CZh0PWpGgzrVxUYBlgKNGquUo=
github.com/akavel/rsrc v0.10.1 h1:hCCPImjmFKVNGpeLZyTDRHEFC283DzyTXTo0cO0Rq9o=
github.com/akavel/rsrc v0.10.1/go.mod h1:uLoCtb9J+EyAqh+26kdrTgmzRBFPGOolLWKpdxkKq+c=
github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0=
github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=
github.com/alecthomas/go-check-sumtype v0.3.1 h1:u9aUvbGINJxLVXiFvHUlPEaD7VDULsrxJb4Aq31NLkU=
+2003 -289
View File
File diff suppressed because it is too large Load Diff
+2481 -18
View File
File diff suppressed because it is too large Load Diff
+27
View File
@@ -5,6 +5,7 @@ import (
"errors"
"fmt"
"math/big"
"slices"
"strings"
)
@@ -16,6 +17,7 @@ const (
)
var ErrImpossibleProfile = errors.New("impossible password profile")
var ErrUnknownProfile = errors.New("unknown password profile")
type Profile struct {
Name string
@@ -61,6 +63,31 @@ func DefaultProfiles() map[string]Profile {
}
}
func DefaultProfileNames() []string {
return ProfileNames(DefaultProfiles())
}
func LookupProfile(name string, profiles map[string]Profile) (Profile, error) {
profile, ok := profiles[strings.TrimSpace(name)]
if !ok {
return Profile{}, fmt.Errorf("%w %q", ErrUnknownProfile, strings.TrimSpace(name))
}
return profile, nil
}
func LookupDefaultProfile(name string) (Profile, error) {
return LookupProfile(name, DefaultProfiles())
}
func ProfileNames(profiles map[string]Profile) []string {
names := make([]string, 0, len(profiles))
for name := range profiles {
names = append(names, name)
}
slices.Sort(names)
return names
}
func Generate(profile Profile) (string, error) {
if err := validateProfile(profile); err != nil {
return "", err
+40 -11
View File
@@ -1,6 +1,8 @@
package passwords
import (
"errors"
"slices"
"strings"
"testing"
)
@@ -9,17 +11,17 @@ func TestGenerateRespectsProfileRequirements(t *testing.T) {
t.Parallel()
profile := Profile{
Name: "strong",
Length: 24,
Lowercase: true,
Uppercase: true,
Digits: true,
Symbols: true,
MinLowercase: 2,
MinUppercase: 2,
MinDigits: 2,
MinSymbols: 2,
ExcludeSimilar: true,
Name: "strong",
Length: 24,
Lowercase: true,
Uppercase: true,
Digits: true,
Symbols: true,
MinLowercase: 2,
MinUppercase: 2,
MinDigits: 2,
MinSymbols: 2,
ExcludeSimilar: true,
}
password, err := Generate(profile)
@@ -86,6 +88,33 @@ func TestProfileSetReturnsNamedProfiles(t *testing.T) {
}
}
func TestDefaultProfileNamesReturnsSortedNames(t *testing.T) {
t.Parallel()
got := DefaultProfileNames()
want := []string{"memorable", "strong"}
if !slices.Equal(got, want) {
t.Fatalf("DefaultProfileNames() = %v, want %v", got, want)
}
}
func TestLookupDefaultProfileResolvesKnownProfilesAndRejectsUnknownNames(t *testing.T) {
t.Parallel()
profile, err := LookupDefaultProfile(" strong ")
if err != nil {
t.Fatalf("LookupDefaultProfile(\" strong \") error = %v", err)
}
if profile.Name != "strong" {
t.Fatalf("LookupDefaultProfile(\" strong \").Name = %q, want %q", profile.Name, "strong")
}
_, err = LookupDefaultProfile("invalid")
if !errors.Is(err, ErrUnknownProfile) {
t.Fatalf("LookupDefaultProfile(\"invalid\") error = %v, want ErrUnknownProfile", err)
}
}
func countFromSet(password, chars string) int {
count := 0
for _, r := range password {
File diff suppressed because it is too large Load Diff
+12 -1
View File
@@ -15,6 +15,7 @@ service VaultService {
rpc ListGroups(ListGroupsRequest) returns (ListGroupsResponse);
rpc CreateGroup(CreateGroupRequest) returns (CreateGroupResponse);
rpc RenameGroup(RenameGroupRequest) returns (RenameGroupResponse);
rpc DeleteGroup(DeleteGroupRequest) returns (DeleteGroupResponse);
rpc UpsertEntry(UpsertEntryRequest) returns (UpsertEntryResponse);
rpc DeleteEntry(DeleteEntryRequest) returns (DeleteEntryResponse);
rpc RestoreEntry(RestoreEntryRequest) returns (RestoreEntryResponse);
@@ -67,7 +68,10 @@ message LockVaultRequest {}
message LockVaultResponse {}
message UnlockVaultRequest {}
message UnlockVaultRequest {
string password = 1;
bytes key_file_data = 2;
}
message UnlockVaultResponse {}
@@ -85,6 +89,7 @@ message Entry {
string notes = 6;
repeated string tags = 7;
repeated string path = 8;
map<string, string> fields = 9;
}
message ListEntriesResponse {
@@ -113,6 +118,12 @@ message RenameGroupRequest {
message RenameGroupResponse {}
message DeleteGroupRequest {
repeated string path = 1;
}
message DeleteGroupResponse {}
message UpsertEntryRequest {
Entry entry = 1;
}
+38
View File
@@ -29,6 +29,7 @@ const (
VaultService_ListGroups_FullMethodName = "/keepassgo.v1.VaultService/ListGroups"
VaultService_CreateGroup_FullMethodName = "/keepassgo.v1.VaultService/CreateGroup"
VaultService_RenameGroup_FullMethodName = "/keepassgo.v1.VaultService/RenameGroup"
VaultService_DeleteGroup_FullMethodName = "/keepassgo.v1.VaultService/DeleteGroup"
VaultService_UpsertEntry_FullMethodName = "/keepassgo.v1.VaultService/UpsertEntry"
VaultService_DeleteEntry_FullMethodName = "/keepassgo.v1.VaultService/DeleteEntry"
VaultService_RestoreEntry_FullMethodName = "/keepassgo.v1.VaultService/RestoreEntry"
@@ -60,6 +61,7 @@ type VaultServiceClient interface {
ListGroups(ctx context.Context, in *ListGroupsRequest, opts ...grpc.CallOption) (*ListGroupsResponse, error)
CreateGroup(ctx context.Context, in *CreateGroupRequest, opts ...grpc.CallOption) (*CreateGroupResponse, error)
RenameGroup(ctx context.Context, in *RenameGroupRequest, opts ...grpc.CallOption) (*RenameGroupResponse, error)
DeleteGroup(ctx context.Context, in *DeleteGroupRequest, opts ...grpc.CallOption) (*DeleteGroupResponse, error)
UpsertEntry(ctx context.Context, in *UpsertEntryRequest, opts ...grpc.CallOption) (*UpsertEntryResponse, error)
DeleteEntry(ctx context.Context, in *DeleteEntryRequest, opts ...grpc.CallOption) (*DeleteEntryResponse, error)
RestoreEntry(ctx context.Context, in *RestoreEntryRequest, opts ...grpc.CallOption) (*RestoreEntryResponse, error)
@@ -185,6 +187,16 @@ func (c *vaultServiceClient) RenameGroup(ctx context.Context, in *RenameGroupReq
return out, nil
}
func (c *vaultServiceClient) DeleteGroup(ctx context.Context, in *DeleteGroupRequest, opts ...grpc.CallOption) (*DeleteGroupResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(DeleteGroupResponse)
err := c.cc.Invoke(ctx, VaultService_DeleteGroup_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *vaultServiceClient) UpsertEntry(ctx context.Context, in *UpsertEntryRequest, opts ...grpc.CallOption) (*UpsertEntryResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(UpsertEntryResponse)
@@ -349,6 +361,7 @@ type VaultServiceServer interface {
ListGroups(context.Context, *ListGroupsRequest) (*ListGroupsResponse, error)
CreateGroup(context.Context, *CreateGroupRequest) (*CreateGroupResponse, error)
RenameGroup(context.Context, *RenameGroupRequest) (*RenameGroupResponse, error)
DeleteGroup(context.Context, *DeleteGroupRequest) (*DeleteGroupResponse, error)
UpsertEntry(context.Context, *UpsertEntryRequest) (*UpsertEntryResponse, error)
DeleteEntry(context.Context, *DeleteEntryRequest) (*DeleteEntryResponse, error)
RestoreEntry(context.Context, *RestoreEntryRequest) (*RestoreEntryResponse, error)
@@ -404,6 +417,9 @@ func (UnimplementedVaultServiceServer) CreateGroup(context.Context, *CreateGroup
func (UnimplementedVaultServiceServer) RenameGroup(context.Context, *RenameGroupRequest) (*RenameGroupResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method RenameGroup not implemented")
}
func (UnimplementedVaultServiceServer) DeleteGroup(context.Context, *DeleteGroupRequest) (*DeleteGroupResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method DeleteGroup not implemented")
}
func (UnimplementedVaultServiceServer) UpsertEntry(context.Context, *UpsertEntryRequest) (*UpsertEntryResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method UpsertEntry not implemented")
}
@@ -650,6 +666,24 @@ func _VaultService_RenameGroup_Handler(srv interface{}, ctx context.Context, dec
return interceptor(ctx, in, info, handler)
}
func _VaultService_DeleteGroup_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(DeleteGroupRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(VaultServiceServer).DeleteGroup(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: VaultService_DeleteGroup_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(VaultServiceServer).DeleteGroup(ctx, req.(*DeleteGroupRequest))
}
return interceptor(ctx, in, info, handler)
}
func _VaultService_UpsertEntry_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(UpsertEntryRequest)
if err := dec(in); err != nil {
@@ -967,6 +1001,10 @@ var VaultService_ServiceDesc = grpc.ServiceDesc{
MethodName: "RenameGroup",
Handler: _VaultService_RenameGroup_Handler,
},
{
MethodName: "DeleteGroup",
Handler: _VaultService_DeleteGroup_Handler,
},
{
MethodName: "UpsertEntry",
Handler: _VaultService_UpsertEntry_Handler,
+720 -13
View File
@@ -5,6 +5,10 @@ import (
"errors"
"fmt"
"os"
"path/filepath"
"reflect"
"slices"
"strings"
"git.julianfamily.org/keepassgo/vault"
"git.julianfamily.org/keepassgo/webdav"
@@ -20,6 +24,7 @@ type Manager struct {
config *vault.KDBXConfig
path string
key vault.MasterKey
vaultRoot string
locked bool
encoded []byte
remoteClient *webdav.Client
@@ -28,6 +33,8 @@ type Manager struct {
}
func (m *Manager) Create(model vault.Model, key vault.MasterKey) error {
root := detectSingleVaultRoot(model)
model = normalizeUnderRoot(model, root)
var encoded bytes.Buffer
if err := vault.SaveKDBXWithConfigAndKey(&encoded, model, key, m.config); err != nil {
return fmt.Errorf("encode new vault: %w", err)
@@ -35,11 +42,24 @@ func (m *Manager) Create(model vault.Model, key vault.MasterKey) error {
m.model = model
m.key = key
m.vaultRoot = root
m.encoded = encoded.Bytes()
m.locked = false
return nil
}
func (m *Manager) HasVault() bool {
return len(m.encoded) > 0 || m.path != "" || m.remotePath != ""
}
func (m *Manager) IsLocked() bool {
return m.locked
}
func (m *Manager) IsRemote() bool {
return m.remoteClient != nil && m.remotePath != ""
}
func (m *Manager) Open(path string, key vault.MasterKey) error {
content, err := os.ReadFile(path)
if err != nil {
@@ -55,6 +75,7 @@ func (m *Manager) Open(path string, key vault.MasterKey) error {
m.config = config
m.path = path
m.key = key
m.vaultRoot = detectSingleVaultRoot(model)
m.encoded = content
m.locked = false
return nil
@@ -86,6 +107,7 @@ func (m *Manager) OpenRemote(client webdav.Client, path string, key vault.Master
m.model = model
m.config = config
m.key = key
m.vaultRoot = detectSingleVaultRoot(model)
m.encoded = content
m.locked = false
m.remoteClient = &client
@@ -99,21 +121,92 @@ 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
}
func (m *Manager) Synchronize() error {
switch {
case m.remoteClient != nil && m.remotePath != "":
return m.synchronizeRemote()
case m.path != "":
return m.synchronizeLocal()
default:
return ErrNoPath
}
}
func (m *Manager) SynchronizeFromLocal(path string) error {
other, _, err := loadLocalSource(path, m.key)
if err != nil {
return err
}
merged, err := m.mergedWithPeer(other)
if err != nil {
return err
}
return m.persistMergedToCurrentSource(merged)
}
func (m *Manager) SynchronizeToLocal(path string) error {
other, config, err := loadLocalSourceOrEmpty(path, m.key)
if err != nil {
return err
}
merged, err := m.mergedWithPeer(other)
if err != nil {
return err
}
merged = normalizeUnderRoot(merged, m.vaultRoot)
if err := saveModelToLocal(path, merged, m.key, configOrCurrent(config, m.config)); err != nil {
return err
}
m.model = normalizeUnderRoot(merged, m.vaultRoot)
m.locked = false
return nil
}
func (m *Manager) SynchronizeFromRemote(client webdav.Client, path string) error {
other, _, _, err := loadRemoteSource(client, path, m.key)
if err != nil {
return err
}
merged, err := m.mergedWithPeer(other)
if err != nil {
return err
}
return m.persistMergedToCurrentSource(merged)
}
func (m *Manager) SynchronizeToRemote(client webdav.Client, path string) error {
other, config, version, err := loadRemoteSourceOrEmpty(client, path, m.key)
if err != nil {
return err
}
merged, err := m.mergedWithPeer(other)
if err != nil {
return err
}
merged = normalizeUnderRoot(merged, m.vaultRoot)
if err := saveModelToRemote(client, path, merged, m.key, configOrCurrent(config, m.config), version); err != nil {
return err
}
m.model = normalizeUnderRoot(merged, m.vaultRoot)
m.locked = false
return nil
}
func (m *Manager) SaveAs(path string) error {
if err := m.saveToPath(path); err != nil {
return err
@@ -124,7 +217,12 @@ func (m *Manager) SaveAs(path string) error {
}
func (m *Manager) Replace(model vault.Model) {
m.model = model
root := m.vaultRoot
if root == "" {
root = detectSingleVaultRoot(model)
}
m.model = normalizeUnderRoot(model, root)
m.vaultRoot = root
m.locked = false
}
@@ -142,7 +240,8 @@ func (m *Manager) Lock() error {
}
var encoded bytes.Buffer
if err := vault.SaveKDBXWithConfigAndKey(&encoded, m.model, m.key, m.config); err != nil {
model := normalizeUnderRoot(m.model, m.vaultRoot)
if err := vault.SaveKDBXWithConfigAndKey(&encoded, model, m.key, m.config); err != nil {
return fmt.Errorf("encode vault for lock: %w", err)
}
@@ -161,20 +260,628 @@ func (m *Manager) Unlock(key vault.MasterKey) error {
m.model = model
m.config = config
m.key = key
m.vaultRoot = detectSingleVaultRoot(model)
m.locked = false
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.MkdirAll(filepath.Dir(path), 0o700); err != nil {
return fmt.Errorf("create parent dir for %s: %w", path, 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
}
model, err := m.currentModelForPersistence()
if err != nil {
return nil, err
}
var encoded bytes.Buffer
if err := vault.SaveKDBXWithConfigAndKey(&encoded, model, m.key, m.config); err != nil {
return nil, fmt.Errorf("encode vault: %w", err)
}
return encoded.Bytes(), nil
}
func (m *Manager) synchronizeLocal() error {
current, err := m.currentModelForPersistence()
if err != nil {
return err
}
content, err := os.ReadFile(m.path)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return m.saveToPath(m.path)
}
return fmt.Errorf("read %s: %w", m.path, err)
}
latest, config, err := vault.LoadKDBXWithConfig(bytes.NewReader(content), m.key)
if err != nil {
return fmt.Errorf("open %s for synchronize: %w", m.path, err)
}
base, err := m.baseModel()
if err != nil {
return err
}
merged := mergeModels(base, current, latest)
merged = normalizeUnderRoot(merged, m.vaultRoot)
var encoded bytes.Buffer
if err := vault.SaveKDBXWithConfigAndKey(&encoded, merged, m.key, config); err != nil {
return fmt.Errorf("encode synchronized vault: %w", err)
}
if err := os.WriteFile(m.path, encoded.Bytes(), 0o600); err != nil {
return fmt.Errorf("write synchronized %s: %w", m.path, err)
}
m.model = merged
m.config = config
m.encoded = encoded.Bytes()
m.locked = false
return nil
}
func (m *Manager) synchronizeRemote() error {
current, err := m.currentModelForPersistence()
if err != nil {
return err
}
content, version, err := m.remoteClient.Open(m.remotePath)
if err != nil {
return fmt.Errorf("open remote %s for synchronize: %w", m.remotePath, err)
}
latest, config, err := vault.LoadKDBXWithConfig(bytes.NewReader(content), m.key)
if err != nil {
return fmt.Errorf("decode remote %s for synchronize: %w", m.remotePath, err)
}
base, err := m.baseModel()
if err != nil {
return err
}
merged := mergeModels(base, current, latest)
merged = normalizeUnderRoot(merged, m.vaultRoot)
var encoded bytes.Buffer
if err := vault.SaveKDBXWithConfigAndKey(&encoded, merged, m.key, config); err != nil {
return fmt.Errorf("encode synchronized remote vault: %w", err)
}
nextVersion, err := m.remoteClient.Save(m.remotePath, bytes.NewReader(encoded.Bytes()), version)
if err != nil {
return fmt.Errorf("save synchronized remote %s: %w", m.remotePath, err)
}
m.model = merged
m.config = config
m.encoded = encoded.Bytes()
m.remoteVersion = nextVersion
m.locked = false
return nil
}
func (m *Manager) currentModelForPersistence() (vault.Model, error) {
if m.locked {
model, err := vault.LoadKDBXWithKey(bytes.NewReader(m.encoded), m.key)
if err != nil {
return vault.Model{}, err
}
return normalizeUnderRoot(model, m.vaultRoot), nil
}
return normalizeUnderRoot(m.model, m.vaultRoot), nil
}
func (m *Manager) baseModel() (vault.Model, error) {
if len(m.encoded) == 0 {
return vault.Model{}, nil
}
model, err := vault.LoadKDBXWithKey(bytes.NewReader(m.encoded), m.key)
if err != nil {
return vault.Model{}, fmt.Errorf("decode baseline vault: %w", err)
}
return model, nil
}
func (m *Manager) mergedWithPeer(other vault.Model) (vault.Model, error) {
current, err := m.currentModelForPersistence()
if err != nil {
return vault.Model{}, err
}
return mergePeerModels(current, other), nil
}
func (m *Manager) persistMergedToCurrentSource(merged vault.Model) error {
merged = normalizeUnderRoot(merged, m.vaultRoot)
switch {
case m.remoteClient != nil && m.remotePath != "":
if err := saveModelToRemote(*m.remoteClient, m.remotePath, merged, m.key, configOrCurrent(m.config, nil), m.remoteVersion); err != nil {
return err
}
return m.reloadCurrentRemote(merged)
case m.path != "":
if err := saveModelToLocal(m.path, merged, m.key, configOrCurrent(m.config, nil)); err != nil {
return err
}
return m.reloadCurrentLocal(merged)
default:
return ErrNoPath
}
}
func (m *Manager) reloadCurrentLocal(merged vault.Model) error {
merged = normalizeUnderRoot(merged, m.vaultRoot)
encoded, err := encodeModelWithConfig(merged, m.key, configOrCurrent(m.config, nil))
if err != nil {
return err
}
m.model = merged
if root := detectSingleVaultRoot(merged); root != "" {
m.vaultRoot = root
}
m.encoded = encoded
m.locked = false
return nil
}
func (m *Manager) reloadCurrentRemote(merged vault.Model) error {
merged = normalizeUnderRoot(merged, m.vaultRoot)
encoded, err := encodeModelWithConfig(merged, m.key, configOrCurrent(m.config, nil))
if err != nil {
return err
}
content, version, err := m.remoteClient.Open(m.remotePath)
if err != nil {
return fmt.Errorf("reopen remote %s after synchronize: %w", m.remotePath, err)
}
m.model = merged
if root := detectSingleVaultRoot(merged); root != "" {
m.vaultRoot = root
}
m.encoded = encoded
m.remoteVersion = version
m.locked = false
if len(content) > 0 {
m.encoded = content
}
return nil
}
func mergeModels(base, local, latest vault.Model) vault.Model {
merged := latest
merged.Entries = mergeEntrySet(base.Entries, local.Entries, latest.Entries)
merged.Templates = mergeEntrySet(base.Templates, local.Templates, latest.Templates)
merged.RecycleBin = mergeEntrySet(base.RecycleBin, local.RecycleBin, latest.RecycleBin)
merged.Groups = mergeGroups(base.Groups, local.Groups, latest.Groups)
return merged
}
func mergePeerModels(primary, secondary vault.Model) vault.Model {
merged := cloneModel(secondary)
merged.Entries = mergePeerEntrySet(primary.Entries, secondary.Entries)
merged.Templates = mergePeerEntrySet(primary.Templates, secondary.Templates)
merged.RecycleBin = mergePeerEntrySet(primary.RecycleBin, secondary.RecycleBin)
merged.Groups = mergePeerGroups(primary.Groups, secondary.Groups)
return merged
}
func mergeEntrySet(base, local, latest []vault.Entry) []vault.Entry {
baseByID := mapEntries(base)
localByID := mapEntries(local)
latestByID := mapEntries(latest)
for id, current := range localByID {
original, hadBase := baseByID[id]
if !hadBase || !entriesEqual(original, current) {
if latestCurrent, latestChanged := latestByID[id]; hadBase && latestChanged && !entriesEqual(original, latestCurrent) && !entriesEqual(latestCurrent, current) {
current = mergeConflictedEntry(current, latestCurrent)
}
latestByID[id] = current
}
}
for id := range baseByID {
if _, stillLocal := localByID[id]; stillLocal {
continue
}
delete(latestByID, id)
}
out := make([]vault.Entry, 0, len(latestByID))
for _, item := range latestByID {
out = append(out, item)
}
slices.SortFunc(out, func(a, b vault.Entry) int {
switch {
case a.Title < b.Title:
return -1
case a.Title > b.Title:
return 1
default:
return 0
}
})
return out
}
func mergeConflictedEntry(current, latest vault.Entry) vault.Entry {
displaced := cloneEntry(latest)
if sameEntryVersion(current, displaced) {
return current
}
mergedHistory := make([]vault.Entry, 0, len(current.History)+1)
mergedHistory = append(mergedHistory, displaced)
for _, item := range current.History {
if sameEntryVersion(item, displaced) {
continue
}
mergedHistory = append(mergedHistory, cloneEntry(item))
}
current.History = mergedHistory
return current
}
func mergePeerEntrySet(primary, secondary []vault.Entry) []vault.Entry {
outByID := mapEntries(secondary)
for _, item := range primary {
if existing, ok := outByID[item.ID]; ok && !sameEntryVersion(item, existing) {
outByID[item.ID] = mergeConflictedEntry(cloneEntry(item), existing)
continue
}
outByID[item.ID] = cloneEntry(item)
}
out := make([]vault.Entry, 0, len(outByID))
for _, item := range outByID {
out = append(out, item)
}
slices.SortFunc(out, func(a, b vault.Entry) int {
switch {
case a.Title < b.Title:
return -1
case a.Title > b.Title:
return 1
default:
return 0
}
})
return out
}
func mapEntries(entries []vault.Entry) map[string]vault.Entry {
out := make(map[string]vault.Entry, len(entries))
for _, item := range entries {
out[item.ID] = item
}
return out
}
func entriesEqual(a, b vault.Entry) bool {
return a.ID == b.ID &&
a.Title == b.Title &&
a.Username == b.Username &&
a.Password == b.Password &&
a.URL == b.URL &&
a.Notes == b.Notes &&
slices.Equal(a.Tags, b.Tags) &&
slices.Equal(a.Path, b.Path) &&
reflect.DeepEqual(a.History, b.History) &&
reflect.DeepEqual(a.Fields, b.Fields) &&
equalAttachments(a.Attachments, b.Attachments)
}
func equalAttachments(a, b map[string][]byte) bool {
if len(a) != len(b) {
return false
}
for key, value := range a {
if !slices.Equal(value, b[key]) {
return false
}
}
return true
}
func cloneEntry(entry vault.Entry) vault.Entry {
entry.Tags = slices.Clone(entry.Tags)
entry.Path = slices.Clone(entry.Path)
entry.History = cloneHistory(entry.History)
if entry.Fields != nil {
fields := make(map[string]string, len(entry.Fields))
for key, value := range entry.Fields {
fields[key] = value
}
entry.Fields = fields
}
if entry.Attachments != nil {
attachments := make(map[string][]byte, len(entry.Attachments))
for key, value := range entry.Attachments {
attachments[key] = slices.Clone(value)
}
entry.Attachments = attachments
}
return entry
}
func cloneHistory(history []vault.Entry) []vault.Entry {
if len(history) == 0 {
return nil
}
out := make([]vault.Entry, len(history))
for i := range history {
out[i] = cloneEntry(history[i])
}
return out
}
func cloneModel(model vault.Model) vault.Model {
out := model
out.Entries = cloneHistory(model.Entries)
out.Templates = cloneHistory(model.Templates)
out.RecycleBin = cloneHistory(model.RecycleBin)
if len(model.Groups) > 0 {
out.Groups = make([][]string, len(model.Groups))
for i := range model.Groups {
out.Groups[i] = slices.Clone(model.Groups[i])
}
}
return out
}
func sameEntryVersion(a, b vault.Entry) bool {
return entriesEqual(a, b)
}
func mergeGroups(base, local, latest [][]string) [][]string {
set := map[string][]string{}
for _, path := range latest {
set[pathKey(path)] = append([]string(nil), path...)
}
baseSet := map[string]bool{}
for _, path := range base {
baseSet[pathKey(path)] = true
}
localSet := map[string]bool{}
for _, path := range local {
key := pathKey(path)
localSet[key] = true
set[key] = append([]string(nil), path...)
}
for key := range baseSet {
if localSet[key] {
continue
}
delete(set, key)
}
out := make([][]string, 0, len(set))
for _, path := range set {
out = append(out, path)
}
slices.SortFunc(out, func(a, b []string) int {
joinedA := pathKey(a)
joinedB := pathKey(b)
switch {
case joinedA < joinedB:
return -1
case joinedA > joinedB:
return 1
default:
return 0
}
})
return out
}
func mergePeerGroups(primary, secondary [][]string) [][]string {
set := map[string][]string{}
for _, path := range secondary {
set[pathKey(path)] = slices.Clone(path)
}
for _, path := range primary {
set[pathKey(path)] = slices.Clone(path)
}
out := make([][]string, 0, len(set))
for _, path := range set {
out = append(out, path)
}
slices.SortFunc(out, func(a, b []string) int {
joinedA := pathKey(a)
joinedB := pathKey(b)
switch {
case joinedA < joinedB:
return -1
case joinedA > joinedB:
return 1
default:
return 0
}
})
return out
}
func detectSingleVaultRoot(model vault.Model) string {
if len(model.EntriesInPath(nil)) != 0 {
return ""
}
groups := model.ChildGroups(nil)
if len(groups) != 1 {
return ""
}
return groups[0]
}
func normalizeUnderRoot(model vault.Model, root string) vault.Model {
if root == "" {
return model
}
out := cloneModel(model)
normalizePath := func(path []string) []string {
switch {
case len(path) == 0:
return []string{root}
case path[0] == root:
return path
default:
return append([]string{root}, path...)
}
}
for i := range out.Entries {
out.Entries[i].Path = normalizePath(out.Entries[i].Path)
for j := range out.Entries[i].History {
out.Entries[i].History[j].Path = normalizePath(out.Entries[i].History[j].Path)
}
}
for i := range out.RecycleBin {
out.RecycleBin[i].Path = normalizePath(out.RecycleBin[i].Path)
for j := range out.RecycleBin[i].History {
out.RecycleBin[i].History[j].Path = normalizePath(out.RecycleBin[i].History[j].Path)
}
}
for i := range out.Groups {
out.Groups[i] = normalizePath(out.Groups[i])
}
return out
}
func loadLocalSource(path string, key vault.MasterKey) (vault.Model, *vault.KDBXConfig, error) {
content, err := os.ReadFile(path)
if err != nil {
return vault.Model{}, nil, fmt.Errorf("open %s for synchronize: %w", path, err)
}
model, config, err := vault.LoadKDBXWithConfig(bytes.NewReader(content), key)
if err != nil {
return vault.Model{}, nil, fmt.Errorf("decode %s for synchronize: %w", path, err)
}
return model, config, nil
}
func loadLocalSourceOrEmpty(path string, key vault.MasterKey) (vault.Model, *vault.KDBXConfig, error) {
model, config, err := loadLocalSource(path, key)
if err == nil {
return model, config, nil
}
if errors.Is(err, os.ErrNotExist) || strings.Contains(err.Error(), os.ErrNotExist.Error()) {
return vault.Model{}, nil, nil
}
return vault.Model{}, nil, err
}
func loadRemoteSource(client webdav.Client, path string, key vault.MasterKey) (vault.Model, *vault.KDBXConfig, webdav.Version, error) {
content, version, err := client.Open(path)
if err != nil {
return vault.Model{}, nil, webdav.Version{}, fmt.Errorf("open remote %s for synchronize: %w", path, err)
}
model, config, err := vault.LoadKDBXWithConfig(bytes.NewReader(content), key)
if err != nil {
return vault.Model{}, nil, webdav.Version{}, fmt.Errorf("decode remote %s for synchronize: %w", path, err)
}
return model, config, version, nil
}
func loadRemoteSourceOrEmpty(client webdav.Client, path string, key vault.MasterKey) (vault.Model, *vault.KDBXConfig, webdav.Version, error) {
model, config, version, err := loadRemoteSource(client, path, key)
if err == nil {
return model, config, version, nil
}
if strings.Contains(err.Error(), "unexpected status 404") {
return vault.Model{}, nil, webdav.Version{}, nil
}
return vault.Model{}, nil, webdav.Version{}, err
}
func encodeModelWithConfig(model vault.Model, key vault.MasterKey, config *vault.KDBXConfig) ([]byte, error) {
var encoded bytes.Buffer
if err := vault.SaveKDBXWithConfigAndKey(&encoded, model, key, config); err != nil {
return nil, fmt.Errorf("encode synchronized vault: %w", err)
}
return encoded.Bytes(), nil
}
func saveModelToLocal(path string, model vault.Model, key vault.MasterKey, config *vault.KDBXConfig) error {
encoded, err := encodeModelWithConfig(model, key, config)
if err != nil {
return err
}
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
return fmt.Errorf("create parent dir for %s: %w", path, err)
}
if err := os.WriteFile(path, encoded, 0o600); err != nil {
return fmt.Errorf("write synchronized %s: %w", path, err)
}
return nil
}
func saveModelToRemote(client webdav.Client, path string, model vault.Model, key vault.MasterKey, config *vault.KDBXConfig, version webdav.Version) error {
encoded, err := encodeModelWithConfig(model, key, config)
if err != nil {
return err
}
if _, err := client.Save(path, bytes.NewReader(encoded), version); err != nil {
return fmt.Errorf("save synchronized remote %s: %w", path, err)
}
return nil
}
func configOrCurrent(config, fallback *vault.KDBXConfig) *vault.KDBXConfig {
if config != nil {
return config
}
return fallback
}
func pathKey(path []string) string {
return strings.Join(path, "\x00")
}
+604
View File
@@ -175,6 +175,80 @@ func TestSavePersistsEditsBackToCurrentPath(t *testing.T) {
}
}
func TestSaveReparentsMixedPathsUnderSingleVaultRoot(t *testing.T) {
t.Parallel()
key := vault.MasterKey{Password: "correct horse battery staple"}
path := filepath.Join(t.TempDir(), "hidden-root.kdbx")
var initial bytes.Buffer
if err := vault.SaveKDBX(&initial, vault.Model{
Entries: []vault.Entry{
{
ID: "entry-1",
Title: "Git Server",
Username: "joejulian",
Password: "token-1",
URL: "https://git.julianfamily.org",
Path: []string{"keepass", "Joe", "Internet"},
},
{
ID: "entry-2",
Title: "Mail",
Username: "joejulian",
Password: "token-2",
URL: "https://mail.julianfamily.org",
Path: []string{"keepass", "Joe", "eMail"},
},
},
}, key.Password); err != nil {
t.Fatalf("SaveKDBX() error = %v", err)
}
if err := os.WriteFile(path, initial.Bytes(), 0o600); err != nil {
t.Fatalf("WriteFile(hidden-root.kdbx) error = %v", err)
}
var sess Manager
if err := sess.Open(path, key); err != nil {
t.Fatalf("Open() error = %v", err)
}
current, err := sess.Current()
if err != nil {
t.Fatalf("Current() error = %v", err)
}
current.Entries[0].Path = []string{"Joe", "Internet"}
current.Groups = append(current.Groups, []string{"Joe"}, []string{"Joe", "Internet"}, []string{"Joe", "eMail"})
sess.Replace(current)
if err := sess.Save(); err != nil {
t.Fatalf("Save() error = %v", err)
}
reopened, err := os.Open(path)
if err != nil {
t.Fatalf("Open(saved path) error = %v", err)
}
defer reopened.Close()
db := gokeepasslib.NewDatabase()
db.Credentials = gokeepasslib.NewPasswordCredentials(key.Password)
if err := gokeepasslib.NewDecoder(reopened).Decode(db); err != nil {
t.Fatalf("Decode(saved path) error = %v", err)
}
if err := db.UnlockProtectedEntries(); err != nil {
t.Fatalf("UnlockProtectedEntries() error = %v", err)
}
if len(db.Content.Root.Groups) != 1 || db.Content.Root.Groups[0].Name != "keepass" {
t.Fatalf("top-level groups = %#v, want single keepass root", db.Content.Root.Groups)
}
rootGroups := db.Content.Root.Groups[0].Groups
if len(rootGroups) != 1 || rootGroups[0].Name != "Joe" {
t.Fatalf("keepass child groups = %#v, want single Joe group", rootGroups)
}
}
func TestSaveWithoutPathFails(t *testing.T) {
t.Parallel()
@@ -391,6 +465,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: "Git Server",
Username: "joejulian",
Password: "token-1",
URL: "https://git.julianfamily.org",
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 != "Git Server" {
t.Fatalf("Current() entries = %#v, want Git Server 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()
@@ -475,3 +611,471 @@ func TestSavePreservesOpenedKDBXSecuritySettings(t *testing.T) {
t.Fatalf("saved KDF UUID = %x, want %x", reloaded.Header.FileHeaders.KdfParameters.UUID, db.Header.FileHeaders.KdfParameters.UUID)
}
}
func TestRemoteSaveAndReopenPreservesCrossFeatureState(t *testing.T) {
t.Parallel()
key := vault.MasterKey{Password: "correct horse battery staple"}
model := vault.Model{
Entries: []vault.Entry{
{
ID: "entry-1",
Title: "Git Server",
Username: "joejulian",
Password: "token-2",
URL: "https://git.julianfamily.org",
Path: []string{"Root", "Internet"},
Attachments: map[string][]byte{
"token.txt": []byte("secret attachment contents"),
},
History: []vault.Entry{
{
ID: "entry-1-history-1",
Title: "Git Server",
Username: "joejulian",
Password: "token-1",
URL: "https://git.julianfamily.org",
Path: []string{"Root", "Internet"},
},
},
},
},
Templates: []vault.Entry{
{
ID: "tpl-1",
Title: "Website Login",
Username: "template-user",
Password: "template-password",
Path: []string{"Templates", "Web"},
},
},
RecycleBin: []vault.Entry{
{
ID: "deleted-1",
Title: "Retired Entry",
Username: "archived-user",
Password: "retired-token",
Path: []string{"Root", "Archive"},
},
},
Groups: [][]string{
{"Root", "Archive"},
{"Root", "Empty Group"},
{"Templates", "Web"},
},
}
var remoteBytes bytes.Buffer
if err := vault.SaveKDBXWithKey(&remoteBytes, model, key); err != nil {
t.Fatalf("SaveKDBXWithKey(seed remote) error = %v", err)
}
etag := "\"v1\""
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
w.Header().Set("ETag", etag)
_, _ = w.Write(remoteBytes.Bytes())
case http.MethodPut:
if got := r.Header.Get("If-Match"); got != etag {
t.Fatalf("If-Match header = %q, want %q", got, etag)
}
payload, err := io.ReadAll(r.Body)
if err != nil {
t.Fatalf("ReadAll(PUT body) error = %v", err)
}
remoteBytes.Reset()
if _, err := remoteBytes.Write(payload); err != nil {
t.Fatalf("Write(remoteBytes) error = %v", err)
}
etag = "\"v2\""
w.Header().Set("ETag", etag)
w.WriteHeader(http.StatusNoContent)
default:
t.Fatalf("unexpected method %s", r.Method)
}
}))
defer server.Close()
client := webdav.Client{BaseURL: server.URL}
var sess Manager
if err := sess.OpenRemote(client, "vaults/main.kdbx", key); err != nil {
t.Fatalf("OpenRemote() error = %v", err)
}
if err := sess.SaveRemote(); err != nil {
t.Fatalf("SaveRemote() error = %v", err)
}
var reopened Manager
if err := reopened.OpenRemote(client, "vaults/main.kdbx", key); err != nil {
t.Fatalf("reopen OpenRemote() error = %v", err)
}
current, err := reopened.Current()
if err != nil {
t.Fatalf("Current() after reopen error = %v", err)
}
got := current.EntriesInPath([]string{"Root", "Internet"})
if len(got) != 1 {
t.Fatalf("len(EntriesInPath(Root/Internet)) after reopen = %d, want 1", len(got))
}
if got[0].ID != "entry-1" {
t.Fatalf("entry ID after remote reopen = %q, want %q", got[0].ID, "entry-1")
}
if len(got[0].History) != 1 || got[0].History[0].ID != "entry-1-history-1" {
t.Fatalf("History after remote reopen = %#v, want stable history ID entry-1-history-1", got[0].History)
}
if string(got[0].Attachments["token.txt"]) != "secret attachment contents" {
t.Fatalf("attachment after remote reopen = %q, want %q", string(got[0].Attachments["token.txt"]), "secret attachment contents")
}
if len(current.Templates) != 1 || current.Templates[0].Path[1] != "Web" {
t.Fatalf("Templates after remote reopen = %#v, want Website Login in Templates/Web", current.Templates)
}
if len(current.RecycleBin) != 1 || current.RecycleBin[0].Path[1] != "Archive" {
t.Fatalf("RecycleBin after remote reopen = %#v, want retired entry in Root/Archive", current.RecycleBin)
}
}
func TestSynchronizeRemotePreservesOverwrittenRemoteVariantInHistory(t *testing.T) {
t.Parallel()
key := vault.MasterKey{Password: "correct horse battery staple"}
model := vault.Model{
Entries: []vault.Entry{
{
ID: "entry-1",
Title: "Git Server",
Username: "joejulian",
Password: "token-1",
URL: "https://git.julianfamily.org",
Path: []string{"Root", "Internet"},
},
},
}
var remoteBytes bytes.Buffer
if err := vault.SaveKDBXWithKey(&remoteBytes, model, key); err != nil {
t.Fatalf("SaveKDBXWithKey(seed remote) error = %v", err)
}
etag := "\"v1\""
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
w.Header().Set("ETag", etag)
_, _ = w.Write(remoteBytes.Bytes())
case http.MethodPut:
if got := r.Header.Get("If-Match"); got != etag {
t.Fatalf("If-Match header = %q, want %q", got, etag)
}
payload, err := io.ReadAll(r.Body)
if err != nil {
t.Fatalf("ReadAll(PUT body) error = %v", err)
}
remoteBytes.Reset()
if _, err := remoteBytes.Write(payload); err != nil {
t.Fatalf("Write(remoteBytes) error = %v", err)
}
switch etag {
case "\"v1\"":
etag = "\"v2\""
default:
etag = "\"v3\""
}
w.Header().Set("ETag", etag)
w.WriteHeader(http.StatusNoContent)
default:
t.Fatalf("unexpected method %s", r.Method)
}
}))
defer server.Close()
client := webdav.Client{BaseURL: server.URL}
var first Manager
if err := first.OpenRemote(client, "vaults/main.kdbx", key); err != nil {
t.Fatalf("first OpenRemote() error = %v", err)
}
var second Manager
if err := second.OpenRemote(client, "vaults/main.kdbx", key); err != nil {
t.Fatalf("second OpenRemote() error = %v", err)
}
firstCurrent, err := first.Current()
if err != nil {
t.Fatalf("first Current() error = %v", err)
}
firstCurrent.UpsertEntry(vault.Entry{
ID: "entry-1",
Title: "Git Server",
Username: "joejulian",
Password: "remote-token-2",
URL: "https://git.julianfamily.org",
Notes: "updated remotely first",
Path: []string{"Root", "Internet"},
})
first.Replace(firstCurrent)
if err := first.SaveRemote(); err != nil {
t.Fatalf("first SaveRemote() error = %v", err)
}
secondCurrent, err := second.Current()
if err != nil {
t.Fatalf("second Current() error = %v", err)
}
secondCurrent.UpsertEntry(vault.Entry{
ID: "entry-1",
Title: "Git Server",
Username: "joejulian",
Password: "local-token-2",
URL: "https://git.julianfamily.org/settings/tokens",
Path: []string{"Root", "Internet"},
})
second.Replace(secondCurrent)
if err := second.Synchronize(); err != nil {
t.Fatalf("second Synchronize() error = %v", err)
}
var reopened Manager
if err := reopened.OpenRemote(client, "vaults/main.kdbx", key); err != nil {
t.Fatalf("reopened OpenRemote() error = %v", err)
}
current, err := reopened.Current()
if err != nil {
t.Fatalf("reopened Current() error = %v", err)
}
got := current.EntriesInPath([]string{"Root", "Internet"})
if len(got) != 1 {
t.Fatalf("len(EntriesInPath(Root/Internet)) = %d, want 1", len(got))
}
if got[0].Password != "local-token-2" || got[0].URL != "https://git.julianfamily.org/settings/tokens" {
t.Fatalf("entry after synchronize = %#v, want local winning version", got[0])
}
if len(got[0].History) == 0 {
t.Fatal("len(History) = 0, want overwritten remote variant preserved")
}
if got[0].History[0].Password != "remote-token-2" || got[0].History[0].Notes != "updated remotely first" {
t.Fatalf("History[0] = %#v, want displaced remote version first", got[0].History[0])
}
}
func TestSynchronizeFromLocalMergesOtherVaultIntoCurrentSource(t *testing.T) {
t.Parallel()
key := vault.MasterKey{Password: "correct horse battery staple"}
currentPath := filepath.Join(t.TempDir(), "current.kdbx")
otherPath := filepath.Join(t.TempDir(), "other.kdbx")
currentModel := vault.Model{
Entries: []vault.Entry{
{
ID: "entry-current",
Title: "Git Server",
Username: "joejulian",
Password: "token-current",
URL: "https://git.julianfamily.org",
Path: []string{"Root", "Internet"},
},
},
}
otherModel := vault.Model{
Entries: []vault.Entry{
{
ID: "entry-other",
Title: "Dynadot",
Username: "jjulian",
Password: "token-other",
URL: "https://www.dynadot.com",
Path: []string{"Root", "Internet"},
},
},
}
writeKDBXTestFile(t, currentPath, currentModel, key)
writeKDBXTestFile(t, otherPath, otherModel, key)
var sess Manager
if err := sess.Open(currentPath, key); err != nil {
t.Fatalf("Open(current) error = %v", err)
}
if err := sess.SynchronizeFromLocal(otherPath); err != nil {
t.Fatalf("SynchronizeFromLocal() error = %v", err)
}
var reopened Manager
if err := reopened.Open(currentPath, key); err != nil {
t.Fatalf("reopen Open(current) error = %v", err)
}
current, err := reopened.Current()
if err != nil {
t.Fatalf("reopened Current() error = %v", err)
}
got := current.EntriesInPath([]string{"Root", "Internet"})
if len(got) != 2 {
t.Fatalf("len(EntriesInPath(Root/Internet)) = %d, want 2", len(got))
}
}
func TestSynchronizeToLocalWritesMergedVaultToTarget(t *testing.T) {
t.Parallel()
key := vault.MasterKey{Password: "correct horse battery staple"}
currentPath := filepath.Join(t.TempDir(), "current.kdbx")
otherPath := filepath.Join(t.TempDir(), "other.kdbx")
currentModel := vault.Model{
Entries: []vault.Entry{
{
ID: "entry-current",
Title: "Git Server",
Username: "joejulian",
Password: "token-current",
URL: "https://git.julianfamily.org",
Path: []string{"Root", "Internet"},
},
},
}
otherModel := vault.Model{
Entries: []vault.Entry{
{
ID: "entry-other",
Title: "Dynadot",
Username: "jjulian",
Password: "token-other",
URL: "https://www.dynadot.com",
Path: []string{"Root", "Internet"},
},
},
}
writeKDBXTestFile(t, currentPath, currentModel, key)
writeKDBXTestFile(t, otherPath, otherModel, key)
var sess Manager
if err := sess.Open(currentPath, key); err != nil {
t.Fatalf("Open(current) error = %v", err)
}
if err := sess.SynchronizeToLocal(otherPath); err != nil {
t.Fatalf("SynchronizeToLocal() error = %v", err)
}
var reopened Manager
if err := reopened.Open(otherPath, key); err != nil {
t.Fatalf("reopen Open(other) error = %v", err)
}
current, err := reopened.Current()
if err != nil {
t.Fatalf("reopened Current() error = %v", err)
}
got := current.EntriesInPath([]string{"Root", "Internet"})
if len(got) != 2 {
t.Fatalf("len(EntriesInPath(Root/Internet)) = %d, want 2", len(got))
}
}
func TestSynchronizeToRemoteWritesMergedVaultToTarget(t *testing.T) {
t.Parallel()
key := vault.MasterKey{Password: "correct horse battery staple"}
currentPath := filepath.Join(t.TempDir(), "current.kdbx")
currentModel := vault.Model{
Entries: []vault.Entry{
{
ID: "entry-current",
Title: "Git Server",
Username: "joejulian",
Password: "token-current",
URL: "https://git.julianfamily.org",
Path: []string{"Root", "Internet"},
},
},
}
remoteModel := vault.Model{
Entries: []vault.Entry{
{
ID: "entry-remote",
Title: "Dynadot",
Username: "jjulian",
Password: "token-remote",
URL: "https://www.dynadot.com",
Path: []string{"Root", "Internet"},
},
},
}
writeKDBXTestFile(t, currentPath, currentModel, key)
var remoteBytes bytes.Buffer
if err := vault.SaveKDBXWithKey(&remoteBytes, remoteModel, key); err != nil {
t.Fatalf("SaveKDBXWithKey(remote) error = %v", err)
}
etag := "\"v1\""
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
w.Header().Set("ETag", etag)
_, _ = w.Write(remoteBytes.Bytes())
case http.MethodPut:
payload, err := io.ReadAll(r.Body)
if err != nil {
t.Fatalf("ReadAll(PUT body) error = %v", err)
}
remoteBytes.Reset()
if _, err := remoteBytes.Write(payload); err != nil {
t.Fatalf("Write(remoteBytes) error = %v", err)
}
etag = "\"v2\""
w.Header().Set("ETag", etag)
w.WriteHeader(http.StatusNoContent)
default:
t.Fatalf("unexpected method %s", r.Method)
}
}))
defer server.Close()
var sess Manager
if err := sess.Open(currentPath, key); err != nil {
t.Fatalf("Open(current) error = %v", err)
}
if err := sess.SynchronizeToRemote(webdav.Client{BaseURL: server.URL}, "vaults/other.kdbx"); err != nil {
t.Fatalf("SynchronizeToRemote() error = %v", err)
}
var reopened Manager
if err := reopened.OpenRemote(webdav.Client{BaseURL: server.URL}, "vaults/other.kdbx", key); err != nil {
t.Fatalf("OpenRemote(reopened) error = %v", err)
}
current, err := reopened.Current()
if err != nil {
t.Fatalf("reopened Current() error = %v", err)
}
got := current.EntriesInPath([]string{"Root", "Internet"})
if len(got) != 2 {
t.Fatalf("len(EntriesInPath(Root/Internet)) = %d, want 2", len(got))
}
}
func writeKDBXTestFile(t *testing.T, path string, model vault.Model, key vault.MasterKey) {
t.Helper()
var encoded bytes.Buffer
if err := vault.SaveKDBXWithKey(&encoded, model, key); err != nil {
t.Fatalf("SaveKDBXWithKey(%s) error = %v", path, err)
}
if err := os.WriteFile(path, encoded.Bytes(), 0o600); err != nil {
t.Fatalf("WriteFile(%s) error = %v", path, err)
}
}
+112
View File
@@ -0,0 +1,112 @@
package main
import (
"fmt"
"image"
"image/color"
"strings"
"gioui.org/layout"
"gioui.org/op/clip"
"gioui.org/op/paint"
"gioui.org/unit"
)
type focusAppearance struct {
BorderColor color.NRGBA
OutlineColor color.NRGBA
OutlineWidth int
MinHeight int
}
func fieldFocusAppearance(metric unit.Metric, focused bool) focusAppearance {
appearance := focusAppearance{
BorderColor: color.NRGBA{R: 202, G: 194, B: 180, A: 255},
OutlineColor: color.NRGBA{A: 0},
OutlineWidth: max(1, metric.Dp(unit.Dp(1))),
MinHeight: metric.Dp(unit.Dp(44)),
}
if focused {
appearance.BorderColor = accentColor
appearance.OutlineColor = color.NRGBA{R: 28, G: 83, B: 63, A: 72}
appearance.OutlineWidth = max(2, metric.Dp(unit.Dp(2)))
}
return appearance
}
func buttonFocusColors(focused bool) (background color.NRGBA, text color.NRGBA) {
background = color.NRGBA{R: 231, G: 239, B: 235, A: 255}
text = accentColor
if focused {
background = color.NRGBA{R: 214, G: 229, B: 221, A: 255}
}
return background, text
}
func (u *ui) accessibilityLabel(id focusID) string {
switch {
case id == focusSearch:
return "Search vault"
case strings.HasPrefix(string(id), "breadcrumb:"):
index := focusIndex(id)
crumbs := u.breadcrumbLabels()
if index >= 0 && index < len(crumbs) {
return fmt.Sprintf("Navigate to %s", crumbs[index])
}
case strings.HasPrefix(string(id), "list:"):
index := focusIndex(id)
if index >= 0 && index < len(u.visible) {
return fmt.Sprintf("Select entry %s", u.visible[index].Title)
}
case strings.HasPrefix(string(id), "detail:"):
name := strings.TrimPrefix(string(id), "detail:")
return fmt.Sprintf("Edit %s", detailFieldLabel(detailField(name)))
}
return ""
}
func drawFocusOutline(gtx layout.Context, appearance focusAppearance, size image.Point) layout.Dimensions {
if appearance.OutlineColor.A == 0 || appearance.OutlineWidth <= 0 {
return layout.Dimensions{Size: size}
}
width := appearance.OutlineWidth
paint.FillShape(gtx.Ops, appearance.OutlineColor, clip.Rect{Max: image.Pt(size.X, width)}.Op())
paint.FillShape(gtx.Ops, appearance.OutlineColor, clip.Rect{Min: image.Pt(0, size.Y-width), Max: image.Pt(size.X, size.Y)}.Op())
paint.FillShape(gtx.Ops, appearance.OutlineColor, clip.Rect{Max: image.Pt(width, size.Y)}.Op())
paint.FillShape(gtx.Ops, appearance.OutlineColor, clip.Rect{Min: image.Pt(size.X-width, 0), Max: image.Pt(size.X, size.Y)}.Op())
return layout.Dimensions{Size: size}
}
func (u *ui) isFocused(id focusID) bool {
return u.keyboardFocus == id
}
func detailFieldLabel(field detailField) string {
switch field {
case detailFieldID:
return "ID"
case detailFieldTitle:
return "Title"
case detailFieldUsername:
return "Username"
case detailFieldPassword:
return "Password"
case detailFieldURL:
return "URL"
case detailFieldPath:
return "Path"
case detailFieldTags:
return "Tags"
case detailFieldPasswordProfile:
return "Password Profile"
case detailFieldNotes:
return "Notes"
case detailFieldFields:
return "Custom Fields"
case detailFieldHistoryIndex:
return "History Index"
default:
return strings.ReplaceAll(string(field), "-", " ")
}
}
+181 -35
View File
@@ -3,16 +3,47 @@ package main
import (
"fmt"
"os"
"slices"
"strconv"
"strings"
"git.julianfamily.org/keepassgo/appstate"
"gioui.org/widget"
"git.julianfamily.org/keepassgo/clipboard"
"git.julianfamily.org/keepassgo/passwords"
"git.julianfamily.org/keepassgo/vault"
)
func (u *ui) attachmentInput() (string, []byte, error) {
name := strings.TrimSpace(u.attachmentName.Text())
if name == "" {
return "", nil, fmt.Errorf("attachment name is required")
}
path := strings.TrimSpace(u.attachmentPath.Text())
if path == "" {
return "", nil, fmt.Errorf("attachment path is required")
}
info, err := os.Stat(path)
if err != nil {
return "", nil, fmt.Errorf("stat attachment: %w", err)
}
if info.Size() > maxAttachmentBytes {
return "", nil, fmt.Errorf("attachment too large: %d bytes exceeds %d byte limit", info.Size(), maxAttachmentBytes)
}
content, err := os.ReadFile(path)
if err != nil {
return "", nil, fmt.Errorf("read attachment: %w", err)
}
return name, content, nil
}
func (u *ui) loadSelectedEntryIntoEditor() {
u.resetPasswordPeek()
u.selectedHistoryIndex = -1
u.historyIndex.SetText("")
item, ok := u.selectedEntry()
if !ok {
u.entryID.SetText("")
@@ -22,8 +53,9 @@ func (u *ui) loadSelectedEntryIntoEditor() {
u.entryURL.SetText("")
u.entryNotes.SetText("")
u.entryTags.SetText("")
u.entryPath.SetText(strings.Join(u.currentPath, " / "))
u.entryPath.SetText(strings.Join(u.displayPath(), " / "))
u.entryFields.SetText("")
u.setCustomFieldRows(nil)
u.attachmentName.SetText("")
u.attachmentPath.SetText("")
u.exportAttachmentPath.SetText("")
@@ -37,13 +69,103 @@ func (u *ui) loadSelectedEntryIntoEditor() {
u.entryURL.SetText(item.URL)
u.entryNotes.SetText(item.Notes)
u.entryTags.SetText(strings.Join(item.Tags, ", "))
u.entryPath.SetText(strings.Join(item.Path, " / "))
u.entryPath.SetText(strings.Join(u.displayEntryPath(item.Path), " / "))
u.entryFields.SetText(marshalFields(item.Fields))
u.setCustomFieldRows(item.Fields)
u.attachmentName.SetText("")
u.attachmentPath.SetText("")
u.exportAttachmentPath.SetText("")
}
func (u *ui) setCustomFieldRows(fields map[string]string) {
u.customFieldKeys = nil
u.customFieldValues = nil
u.removeCustomFields = nil
if len(fields) == 0 {
u.appendCustomFieldRow("", "")
return
}
keys := make([]string, 0, len(fields))
for key := range fields {
keys = append(keys, key)
}
slices.Sort(keys)
for _, key := range keys {
u.appendCustomFieldRow(key, fields[key])
}
}
func (u *ui) appendCustomFieldRow(key, value string) {
keyEditor := widget.Editor{SingleLine: true, Submit: false}
keyEditor.SetText(key)
valueEditor := widget.Editor{SingleLine: true, Submit: false}
valueEditor.SetText(value)
u.customFieldKeys = append(u.customFieldKeys, keyEditor)
u.customFieldValues = append(u.customFieldValues, valueEditor)
u.removeCustomFields = append(u.removeCustomFields, widget.Clickable{})
}
func (u *ui) removeCustomFieldRow(index int) {
if index < 0 || index >= len(u.customFieldKeys) {
return
}
u.customFieldKeys = append(u.customFieldKeys[:index], u.customFieldKeys[index+1:]...)
u.customFieldValues = append(u.customFieldValues[:index], u.customFieldValues[index+1:]...)
u.removeCustomFields = append(u.removeCustomFields[:index], u.removeCustomFields[index+1:]...)
if len(u.customFieldKeys) == 0 {
u.appendCustomFieldRow("", "")
}
}
func (u *ui) currentCustomFields() (map[string]string, error) {
fields := map[string]string{}
for i := range u.customFieldKeys {
key := strings.TrimSpace(u.customFieldKeys[i].Text())
value := strings.TrimSpace(u.customFieldValues[i].Text())
if key == "" && value == "" {
continue
}
if key == "" {
return nil, fmt.Errorf("custom field name is required")
}
fields[key] = value
}
if len(fields) == 0 && strings.TrimSpace(u.entryFields.Text()) != "" {
return parseFields(u.entryFields.Text())
}
if len(fields) == 0 {
return nil, nil
}
return fields, nil
}
func (u *ui) visibleHistory() []vault.Entry {
item, ok := u.selectedEntry()
if !ok || len(item.History) == 0 {
return nil
}
return append([]vault.Entry(nil), item.History...)
}
func (u *ui) selectedHistoryEntry() (vault.Entry, bool) {
history := u.visibleHistory()
if u.selectedHistoryIndex < 0 || u.selectedHistoryIndex >= len(history) {
return vault.Entry{}, false
}
return history[u.selectedHistoryIndex], true
}
func (u *ui) selectHistoryVersion(index int) error {
history := u.visibleHistory()
if index < 0 || index >= len(history) {
return fmt.Errorf("history index %d out of range", index)
}
u.selectedHistoryIndex = index
u.historyIndex.SetText(strconv.Itoa(index))
return nil
}
func (u *ui) saveEntryAction() error {
entry, err := u.editorEntry()
if err != nil {
@@ -52,6 +174,7 @@ func (u *ui) saveEntryAction() error {
if err := u.state.UpsertEntry(entry); err != nil {
return err
}
u.editingEntry = false
u.filter()
return nil
}
@@ -105,6 +228,7 @@ func (u *ui) saveTemplateAction() error {
if err := u.state.UpsertTemplate(entry); err != nil {
return err
}
u.editingEntry = false
u.filter()
return nil
}
@@ -123,32 +247,25 @@ func (u *ui) deleteSelectedTemplateAction() error {
}
func (u *ui) createGroupAction() error {
u.clearDeleteGroupConfirmation()
return u.state.CreateGroup(strings.TrimSpace(u.groupName.Text()))
}
func (u *ui) renameGroupAction() error {
u.clearDeleteGroupConfirmation()
return u.state.RenameCurrentGroup(strings.TrimSpace(u.groupName.Text()))
}
func (u *ui) deleteCurrentGroupAction() error {
session, ok := u.state.Session.(appstate.MutableSession)
if !ok {
return fmt.Errorf("session is not mutable")
if !u.deleteGroupPendingConfirmation() {
return fmt.Errorf("confirm deleting the empty group first")
}
model, err := session.Current()
if err != nil {
if err := u.state.DeleteCurrentGroup(); err != nil {
return err
}
if err := model.DeleteGroup(u.currentPath); err != nil {
return err
}
session.Replace(model)
if len(u.currentPath) > 0 {
u.currentPath = append([]string(nil), u.currentPath[:len(u.currentPath)-1]...)
u.state.CurrentPath = append([]string(nil), u.currentPath...)
}
u.state.Dirty = true
u.clearDeleteGroupConfirmation()
u.currentPath = append([]string(nil), u.state.CurrentPath...)
u.syncedPath = append([]string(nil), u.state.CurrentPath...)
u.filter()
return nil
}
@@ -171,16 +288,10 @@ func (u *ui) instantiateSelectedTemplateAction() error {
}
func (u *ui) addAttachmentAction() error {
content, err := os.ReadFile(strings.TrimSpace(u.attachmentPath.Text()))
name, content, err := u.attachmentInput()
if err != nil {
return fmt.Errorf("read attachment: %w", err)
return err
}
name := strings.TrimSpace(u.attachmentName.Text())
if name == "" {
return fmt.Errorf("attachment name is required")
}
if err := u.state.AddAttachmentToSelectedEntry(name, content); err != nil {
return err
}
@@ -190,6 +301,20 @@ func (u *ui) addAttachmentAction() error {
return nil
}
func (u *ui) replaceAttachmentAction() error {
name, content, err := u.attachmentInput()
if err != nil {
return err
}
if err := u.state.ReplaceAttachmentOnSelectedEntry(name, content); err != nil {
return err
}
u.loadSelectedEntryIntoEditor()
u.attachmentName.SetText(name)
u.filter()
return nil
}
func (u *ui) exportAttachmentAction() error {
item, ok := u.selectedEntry()
if !ok {
@@ -202,7 +327,11 @@ func (u *ui) exportAttachmentAction() error {
return fmt.Errorf("attachment not found")
}
if err := os.WriteFile(strings.TrimSpace(u.exportAttachmentPath.Text()), content, 0o600); err != nil {
exportPath := strings.TrimSpace(u.exportAttachmentPath.Text())
if exportPath == "" {
return fmt.Errorf("export attachment path is required")
}
if err := os.WriteFile(exportPath, content, 0o600); err != nil {
return fmt.Errorf("write attachment export: %w", err)
}
return nil
@@ -223,9 +352,9 @@ func (u *ui) removeAttachmentAction() error {
}
func (u *ui) restoreSelectedHistoryAction() error {
index, err := strconv.Atoi(strings.TrimSpace(u.historyIndex.Text()))
index, err := u.selectedHistoryVersionIndex()
if err != nil {
return fmt.Errorf("invalid history index: %w", err)
return err
}
if err := u.state.RestoreSelectedEntryVersion(index); err != nil {
return err
@@ -235,21 +364,35 @@ func (u *ui) restoreSelectedHistoryAction() error {
return nil
}
func (u *ui) selectedHistoryVersionIndex() (int, error) {
text := strings.TrimSpace(u.historyIndex.Text())
if text != "" {
index, err := strconv.Atoi(text)
if err != nil {
return 0, fmt.Errorf("invalid history index: %w", err)
}
return index, nil
}
if u.selectedHistoryIndex >= 0 {
return u.selectedHistoryIndex, nil
}
return 0, fmt.Errorf("no history version selected")
}
func (u *ui) copySelectedFieldAction(target clipboard.Target) error {
model, err := u.state.Session.Current()
if err != nil {
return err
}
service := clipboard.Service{}
service := clipboard.Service{Writer: u.clipboardWriter}
return service.Copy(model, u.state.SelectedEntryID, target)
}
func (u *ui) generatePasswordAction() error {
profiles := passwords.DefaultProfiles()
profile, ok := profiles[strings.TrimSpace(u.passwordProfile.Text())]
if !ok {
return fmt.Errorf("unknown password profile")
profile, err := passwords.LookupDefaultProfile(u.passwordProfile.Text())
if err != nil {
return err
}
password, err := passwords.Generate(profile)
@@ -262,7 +405,10 @@ func (u *ui) generatePasswordAction() error {
func (u *ui) editorEntry() (vault.Entry, error) {
path := parsePath(u.entryPath.Text())
fields, err := parseFields(u.entryFields.Text())
if root := u.hiddenVaultRoot(); root != "" && (len(path) == 0 || path[0] != root) {
path = append([]string{root}, path...)
}
fields, err := u.currentCustomFields()
if err != nil {
return vault.Entry{}, err
}
+525 -73
View File
@@ -1,131 +1,422 @@
package main
import (
"fmt"
"image/color"
"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(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)),
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
layout.Rigid(labeledEditor(u.theme, "Vault Path", &u.vaultPath, false)),
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
layout.Rigid(labeledEditor(u.theme, "Save As Path", &u.saveAsPath, false)),
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
layout.Rigid(labeledEditor(u.theme, "Remote Base URL", &u.remoteBaseURL, false)),
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
layout.Rigid(labeledEditor(u.theme, "Remote Path", &u.remotePath, false)),
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
layout.Rigid(labeledEditor(u.theme, "Remote Username", &u.remoteUsername, false)),
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
layout.Rigid(labeledEditor(u.theme, "Remote Password", &u.remotePassword, true)),
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.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.createVault, "New") }),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return tonedButton(gtx, u.theme, &u.showLocalLifecycle, "Local Vault")
}),
layout.Rigid(layout.Spacer{Width: unit.Dp(6)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions { return tonedButton(gtx, u.theme, &u.openVault, "Open") }),
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(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(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.showRemoteLifecycle, "Remote Vault")
}),
)
}),
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return u.masterPasswordField(gtx, "Leave blank if this vault is protected by key file only.")
}),
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
layout.Rigid(selectorEditorHelp(u.theme, "Key File", "Optional path to a KeePass-compatible key file.", &u.keyFilePath, &u.pickKeyFile, "Choose File", false)),
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
if u.lifecycleMode == "remote" {
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(labeledEditorHelp(u.theme, "Remote Base URL", "Base WebDAV endpoint, for example https://server/remote.php/webdav.", &u.remoteBaseURL, false)),
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
layout.Rigid(labeledEditorHelp(u.theme, "Remote Path", "Path to the remote .kdbx file under the WebDAV base URL.", &u.remotePath, false)),
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
layout.Rigid(labeledEditorHelp(u.theme, "Remote Username", "Username used to authenticate to the WebDAV server.", &u.remoteUsername, false)),
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return labeledEditorHelp(u.theme, "Remote Password", "Password or app token used to authenticate to the WebDAV server.", &u.remotePassword, true)(gtx)
}),
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
box := material.CheckBox(u.theme, &u.rememberRemoteAuth, "Remember username and password")
box.Color = accentColor
return box.Layout(gtx)
}),
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
layout.Rigid(u.recentRemoteList),
)
}
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(selectorEditorHelp(u.theme, "Vault Path", "Choose the existing .kdbx file to open.", &u.vaultPath, &u.pickVaultPath, "Choose File", false)),
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
layout.Rigid(u.recentVaultList),
)
}),
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")
}
return layout.Flex{Spacing: layout.SpaceStart}.Layout(gtx,
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return tonedButton(gtx, u.theme, &u.openVault, "Open Vault")
}),
layout.Rigid(layout.Spacer{Width: unit.Dp(6)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return tonedButton(gtx, u.theme, &u.createVault, "New Vault")
}),
)
}),
)
}
func (u *ui) recentVaultList(gtx layout.Context) layout.Dimensions {
if len(u.recentVaults) == 0 {
return layout.Dimensions{}
}
if len(u.recentVaultClicks) < len(u.recentVaults) {
u.recentVaultClicks = make([]widget.Clickable, len(u.recentVaults))
}
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
lbl := material.Label(u.theme, unit.Sp(12), "RECENTLY OPENED")
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{Axis: layout.Vertical}.Layout(gtx, func() []layout.FlexChild {
children := make([]layout.FlexChild, 0, len(u.recentVaults)*2)
for i, path := range u.recentVaults {
index := i
label := path
children = append(children, layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return tonedButton(gtx, u.theme, &u.recentVaultClicks[index], label)
}))
if i < len(u.recentVaults)-1 {
children = append(children, layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout))
}
}
return children
}()...)
}),
)
}
func (u *ui) recentRemoteList(gtx layout.Context) layout.Dimensions {
if len(u.recentRemotes) == 0 {
return layout.Dimensions{}
}
if len(u.recentRemoteClicks) < len(u.recentRemotes) {
u.recentRemoteClicks = make([]widget.Clickable, len(u.recentRemotes))
}
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
lbl := material.Label(u.theme, unit.Sp(12), "RECENT CONNECTIONS")
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{Axis: layout.Vertical}.Layout(gtx, func() []layout.FlexChild {
children := make([]layout.FlexChild, 0, len(u.recentRemotes)*2)
for i, record := range u.recentRemotes {
index := i
label := record.BaseURL + " / " + record.Path
children = append(children, layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return tonedButton(gtx, u.theme, &u.recentRemoteClicks[index], label)
}))
if i < len(u.recentRemotes)-1 {
children = append(children, layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout))
}
}
return children
}()...)
}),
)
}
func (u *ui) attachmentList(gtx layout.Context) layout.Dimensions {
items := u.selectedAttachmentItems()
if len(items) == 0 {
lbl := material.Label(u.theme, unit.Sp(13), "No attachments on this entry.")
lbl.Color = mutedColor
return lbl.Layout(gtx)
}
return layout.Flex{Axis: layout.Vertical}.Layout(gtx, func() []layout.FlexChild {
children := make([]layout.FlexChild, 0, len(items)*2)
for i, item := range items {
index := i
itemName := item.Name
label := fmt.Sprintf("%s (%d B)", itemName, item.Size)
children = append(children, layout.Rigid(func(gtx layout.Context) layout.Dimensions {
for u.attachmentClicks[index].Clicked(gtx) {
u.attachmentName.SetText(itemName)
}
return tonedButton(gtx, u.theme, &u.attachmentClicks[index], label)
}))
if i < len(items)-1 {
children = append(children, layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout))
}
}
return children
}()...)
}
func (u *ui) customFieldEditorPanel(gtx layout.Context) layout.Dimensions {
if len(u.customFieldKeys) == 0 {
u.setCustomFieldRows(nil)
}
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
lbl := material.Label(u.theme, unit.Sp(12), "CUSTOM FIELDS")
lbl.Color = mutedColor
return lbl.Layout(gtx)
}),
layout.Rigid(layout.Spacer{Height: unit.Dp(2)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
lbl := material.Label(u.theme, unit.Sp(11), "Add key/value pairs. Changes are only saved when you save the entry.")
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{Axis: layout.Vertical}.Layout(gtx, func() []layout.FlexChild {
children := make([]layout.FlexChild, 0, len(u.customFieldKeys)*2)
for i := range u.customFieldKeys {
index := i
children = append(children, layout.Rigid(func(gtx layout.Context) layout.Dimensions {
for u.removeCustomFields[index].Clicked(gtx) {
u.removeCustomFieldRow(index)
}
return layout.Flex{Alignment: layout.Middle}.Layout(gtx,
layout.Flexed(0.38, func(gtx layout.Context) layout.Dimensions {
return labeledEditor(u.theme, "Name", &u.customFieldKeys[index], false)(gtx)
}),
layout.Rigid(layout.Spacer{Width: unit.Dp(6)}.Layout),
layout.Flexed(0.52, func(gtx layout.Context) layout.Dimensions {
return labeledEditor(u.theme, "Value", &u.customFieldValues[index], false)(gtx)
}),
layout.Rigid(layout.Spacer{Width: unit.Dp(6)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
if len(u.customFieldKeys) == 1 && (strings.TrimSpace(u.customFieldKeys[index].Text()) != "" || strings.TrimSpace(u.customFieldValues[index].Text()) != "") {
return layout.Dimensions{}
}
return tonedButton(gtx, u.theme, &u.removeCustomFields[index], "-")
}),
)
}))
if i < len(u.customFieldKeys)-1 {
children = append(children, layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout))
}
}
return children
}()...)
}),
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
for u.addCustomField.Clicked(gtx) {
u.appendCustomFieldRow("", "")
}
return tonedButton(gtx, u.theme, &u.addCustomField, "+")
}),
)
}
func (u *ui) groupControls(gtx layout.Context) layout.Dimensions {
if u.state.Section == appstate.SectionRecycleBin {
if u.state.Section != appstate.SectionEntries {
return layout.Dimensions{}
}
deletable, deleteReason := u.currentGroupDeletionState()
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(labeledEditor(u.theme, "Group Name", &u.groupName, false)),
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(func(gtx layout.Context) layout.Dimensions {
if len(u.displayPath()) == 0 {
return layout.Dimensions{}
}
return layout.Flex{Spacing: layout.SpaceStart}.Layout(gtx,
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 Current Group")
}),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
if !deletable || u.deleteGroupPendingConfirmation() {
return layout.Dimensions{}
}
return layout.Flex{Spacing: layout.SpaceStart}.Layout(gtx,
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 Empty Group")
}),
)
}),
)
}),
)
}),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
if len(u.displayPath()) == 0 {
return layout.Dimensions{}
}
if u.deleteGroupPendingConfirmation() {
lbl := material.Label(u.theme, unit.Sp(12), fmt.Sprintf("Delete %q? This group is empty, and the deletion cannot be undone.", strings.Join(u.displayPath(), " / ")))
lbl.Color = mutedColor
return lbl.Layout(gtx)
}
if deletable || strings.TrimSpace(deleteReason) == "" {
return layout.Dimensions{}
}
lbl := material.Label(u.theme, unit.Sp(12), deleteReason)
lbl.Color = mutedColor
return lbl.Layout(gtx)
}),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
if !u.deleteGroupPendingConfirmation() {
return layout.Dimensions{}
}
return layout.Flex{Spacing: layout.SpaceStart}.Layout(gtx,
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return tonedButton(gtx, u.theme, &u.confirmDeleteGroup, "Confirm Delete")
}),
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(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.cancelDeleteGroup, "Cancel")
}),
)
}),
)
}
func (u *ui) groupControlsSection(gtx layout.Context) layout.Dimensions {
if u.state.Section != appstate.SectionEntries {
return layout.Dimensions{}
}
if u.groupControlsHidden {
return layout.Dimensions{}
}
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(u.groupControls),
)
}
func (u *ui) groupControlsDisclosure(gtx layout.Context) layout.Dimensions {
return u.toggleGroupControls.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
return layout.UniformInset(unit.Dp(2)).Layout(gtx, func(gtx layout.Context) layout.Dimensions {
return layout.Flex{Alignment: layout.Middle}.Layout(gtx,
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
icon := u.expandLessIcon
if u.groupControlsHidden {
icon = u.expandMoreIcon
}
if icon == nil {
lbl := material.Label(u.theme, unit.Sp(16), ">")
if !u.groupControlsHidden {
lbl.Text = "v"
}
lbl.Color = accentColor
return lbl.Layout(gtx)
}
return icon.Layout(gtx, accentColor)
}),
layout.Rigid(layout.Spacer{Width: unit.Dp(4)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
lbl := material.Label(u.theme, unit.Sp(12), "Group Tools")
lbl.Color = mutedColor
return lbl.Layout(gtx)
}),
)
})
})
}
func (u *ui) entryEditorPanel(gtx layout.Context) layout.Dimensions {
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(labeledEditor(u.theme, "ID", &u.entryID, false)),
layout.Rigid(labeledEditorWithFocus(u.theme, "Title", &u.entryTitle, false, u.isFocused(detailFocusID(detailFieldTitle)))),
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
layout.Rigid(labeledEditor(u.theme, "Title", &u.entryTitle, false)),
layout.Rigid(labeledEditorWithFocus(u.theme, "Username", &u.entryUsername, false, u.isFocused(detailFocusID(detailFieldUsername)))),
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
layout.Rigid(labeledEditor(u.theme, "Username", &u.entryUsername, false)),
layout.Rigid(labeledEditorWithFocus(u.theme, "Password", &u.entryPassword, true, u.isFocused(detailFocusID(detailFieldPassword)))),
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
layout.Rigid(labeledEditor(u.theme, "Password", &u.entryPassword, true)),
layout.Rigid(labeledEditorWithFocus(u.theme, "URL", &u.entryURL, false, u.isFocused(detailFocusID(detailFieldURL)))),
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
layout.Rigid(labeledEditor(u.theme, "URL", &u.entryURL, false)),
layout.Rigid(labeledEditorWithFocus(u.theme, "Path", &u.entryPath, false, u.isFocused(detailFocusID(detailFieldPath)))),
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
layout.Rigid(labeledEditor(u.theme, "Path", &u.entryPath, false)),
layout.Rigid(labeledEditorWithFocus(u.theme, "Tags", &u.entryTags, false, u.isFocused(detailFocusID(detailFieldTags)))),
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
layout.Rigid(labeledEditor(u.theme, "Tags", &u.entryTags, false)),
layout.Rigid(labeledEditorWithFocus(u.theme, "Password Profile", &u.passwordProfile, false, u.isFocused(detailFocusID(detailFieldPasswordProfile)))),
layout.Rigid(layout.Spacer{Height: unit.Dp(4)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
lbl := material.Label(u.theme, unit.Sp(12), u.passwordProfileOptionsText())
lbl.Color = mutedColor
return lbl.Layout(gtx)
}),
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
layout.Rigid(labeledEditor(u.theme, "Password Profile", &u.passwordProfile, false)),
layout.Rigid(labeledMultilineEditorWithFocus(u.theme, "Notes", &u.entryNotes, false, u.isFocused(detailFocusID(detailFieldNotes)), unit.Dp(120))),
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
layout.Rigid(labeledEditor(u.theme, "Notes", &u.entryNotes, false)),
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
layout.Rigid(labeledEditor(u.theme, "Custom Fields (key=value)", &u.entryFields, false)),
layout.Rigid(u.customFieldEditorPanel),
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
layout.Rigid(labeledEditor(u.theme, "History Index", &u.historyIndex, false)),
layout.Rigid(labeledEditorWithFocus(u.theme, "History Index", &u.historyIndex, false, u.isFocused(detailFocusID(detailFieldHistoryIndex)))),
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
layout.Rigid(func(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(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(layout.Spacer{Width: unit.Dp(6)}.Layout),
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(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(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(layout.Spacer{Width: unit.Dp(6)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions { return tonedButton(gtx, u.theme, &u.restoreHistory, "Restore History") }),
)
}
return layout.Flex{Spacing: layout.SpaceStart}.Layout(gtx,
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.cancelEdit, "Cancel")
}),
layout.Rigid(layout.Spacer{Width: unit.Dp(6)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
if u.state.Section == appstate.SectionTemplates {
return tonedButton(gtx, u.theme, &u.saveTemplate, "Save Template")
}
return tonedButton(gtx, u.theme, &u.saveEntry, "Save Entry")
}),
)
}),
layout.Rigid(layout.Spacer{Height: unit.Dp(4)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
lbl := material.Label(u.theme, unit.Sp(11), "Generate Password only updates the form. Nothing is persisted until you save.")
lbl.Color = mutedColor
return lbl.Layout(gtx)
}),
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.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.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") }),
)
}),
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
lbl := material.Label(u.theme, unit.Sp(12), "ATTACHMENTS")
lbl.Color = mutedColor
return lbl.Layout(gtx)
}),
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
layout.Rigid(u.attachmentList),
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
layout.Rigid(labeledEditor(u.theme, "Attachment Name", &u.attachmentName, false)),
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
layout.Rigid(labeledEditor(u.theme, "Attachment Path", &u.attachmentPath, false)),
@@ -134,17 +425,143 @@ 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.replaceAttachment, "Replace 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.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")
}),
)
}),
)
}
func labeledEditor(th *material.Theme, label string, editor *widget.Editor, sensitive bool) layout.Widget {
return labeledEditorWithFocus(th, label, editor, sensitive, false)
}
func labeledEditorHelp(th *material.Theme, label, help string, editor *widget.Editor, sensitive bool) layout.Widget {
return labeledEditorHelpFocus(th, label, help, editor, sensitive, false)
}
func labeledEditorHelpFocus(th *material.Theme, label, help string, editor *widget.Editor, sensitive bool, focused bool) layout.Widget {
return func(gtx layout.Context) layout.Dimensions {
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(labeledEditorWithFocus(th, label, editor, sensitive, focused)),
layout.Rigid(layout.Spacer{Height: unit.Dp(2)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
lbl := material.Label(th, unit.Sp(11), help)
lbl.Color = mutedColor
return lbl.Layout(gtx)
}),
)
}
}
func selectorEditorHelp(th *material.Theme, label, help string, editor *widget.Editor, click *widget.Clickable, buttonLabel string, sensitive bool) layout.Widget {
return func(gtx layout.Context) layout.Dimensions {
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return layout.Flex{Alignment: layout.Middle}.Layout(gtx,
layout.Flexed(1, func(gtx layout.Context) layout.Dimensions {
gtx.Constraints.Min.X = gtx.Constraints.Max.X
return labeledEditor(th, label, editor, sensitive)(gtx)
}),
layout.Rigid(layout.Spacer{Width: unit.Dp(8)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return tonedButton(gtx, th, click, buttonLabel)
}),
)
}),
layout.Rigid(layout.Spacer{Height: unit.Dp(2)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
lbl := material.Label(th, unit.Sp(11), help)
lbl.Color = mutedColor
return lbl.Layout(gtx)
}),
)
}
}
func (u *ui) unlockPanel(gtx layout.Context) layout.Dimensions {
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return u.masterPasswordField(gtx, "Used alone or together with a key file to unlock the vault.")
}),
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
layout.Rigid(selectorEditorHelp(u.theme, "Key File", "Optional path to a KeePass-compatible key file.", &u.keyFilePath, &u.pickKeyFile, "Choose File", false)),
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return tonedButton(gtx, u.theme, &u.unlockVault, "Unlock")
}),
)
}
func (u *ui) masterPasswordField(gtx layout.Context, help string) layout.Dimensions {
icon := u.eyeIcon
desc := "Show master password"
mask := rune('•')
if u.showPassword {
icon = u.eyeOffIcon
desc = "Hide master password"
mask = 0
}
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 PASSWORD")
lbl.Color = mutedColor
return lbl.Layout(gtx)
}),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return outlinedFieldState(gtx, false, func(gtx layout.Context) layout.Dimensions {
return layout.UniformInset(unit.Dp(8)).Layout(gtx, func(gtx layout.Context) layout.Dimensions {
return layout.Flex{Alignment: layout.Middle}.Layout(gtx,
layout.Flexed(1, func(gtx layout.Context) layout.Dimensions {
restore := u.masterPassword.Mask
u.masterPassword.Mask = mask
defer func() { u.masterPassword.Mask = restore }()
gtx.Constraints.Min.X = gtx.Constraints.Max.X
ed := material.Editor(u.theme, &u.masterPassword, "Master Password")
return ed.Layout(gtx)
}),
layout.Rigid(layout.Spacer{Width: unit.Dp(6)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
btn := material.IconButton(u.theme, &u.togglePassword, icon, desc)
btn.Background = color.NRGBA{R: 239, G: 236, B: 229, A: 255}
btn.Color = accentColor
btn.Size = unit.Dp(18)
btn.Inset = layout.UniformInset(unit.Dp(8))
return btn.Layout(gtx)
}),
)
})
})
}),
layout.Rigid(layout.Spacer{Height: unit.Dp(2)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
lbl := material.Label(u.theme, unit.Sp(11), help)
lbl.Color = mutedColor
return lbl.Layout(gtx)
}),
)
}
func labeledEditorWithFocus(
th *material.Theme,
label string,
editor *widget.Editor,
sensitive bool,
focused bool,
) layout.Widget {
return func(gtx layout.Context) layout.Dimensions {
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
@@ -153,12 +570,47 @@ func labeledEditor(th *material.Theme, label string, editor *widget.Editor, sens
return lbl.Layout(gtx)
}),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return outlinedField(gtx, func(gtx layout.Context) layout.Dimensions {
return outlinedFieldState(gtx, focused, func(gtx layout.Context) layout.Dimensions {
mask := editor.Mask
if sensitive {
editor.Mask = '•'
}
defer func() { editor.Mask = mask }()
gtx.Constraints.Min.X = gtx.Constraints.Max.X
ed := material.Editor(th, editor, label)
return layout.UniformInset(unit.Dp(8)).Layout(gtx, ed.Layout)
})
}),
)
}
}
func labeledMultilineEditorWithFocus(
th *material.Theme,
label string,
editor *widget.Editor,
sensitive bool,
focused bool,
minHeight unit.Dp,
) layout.Widget {
return func(gtx layout.Context) layout.Dimensions {
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
lbl := material.Label(th, unit.Sp(12), strings.ToUpper(label))
lbl.Color = mutedColor
return lbl.Layout(gtx)
}),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return outlinedFieldState(gtx, focused, func(gtx layout.Context) layout.Dimensions {
mask := editor.Mask
if sensitive {
editor.Mask = '•'
}
defer func() { editor.Mask = mask }()
gtx.Constraints.Min.X = gtx.Constraints.Max.X
if min := gtx.Dp(minHeight); gtx.Constraints.Min.Y < min {
gtx.Constraints.Min.Y = min
}
ed := material.Editor(th, editor, label)
return layout.UniformInset(unit.Dp(8)).Layout(gtx, ed.Layout)
})
+378
View File
@@ -0,0 +1,378 @@
package main
import (
"fmt"
"strconv"
"strings"
"gioui.org/io/key"
"git.julianfamily.org/keepassgo/appstate"
)
type focusID string
type detailField string
const (
focusSearch focusID = "search"
detailFieldID detailField = "id"
detailFieldTitle detailField = "title"
detailFieldUsername detailField = "username"
detailFieldPassword detailField = "password"
detailFieldURL detailField = "url"
detailFieldPath detailField = "path"
detailFieldTags detailField = "tags"
detailFieldPasswordProfile detailField = "password-profile"
detailFieldNotes detailField = "notes"
detailFieldFields detailField = "fields"
detailFieldHistoryIndex detailField = "history-index"
)
func breadcrumbFocusID(index int) focusID {
return focusID(fmt.Sprintf("breadcrumb:%d", index))
}
func listFocusID(index int) focusID {
return focusID(fmt.Sprintf("list:%d", index))
}
func detailFocusID(field detailField) focusID {
return focusID("detail:" + string(field))
}
func (u *ui) handleKeyPress(name key.Name, modifiers key.Modifiers) bool {
if u.handleShortcutKey(name, modifiers) {
return true
}
if u.isVaultLocked() && name == key.NameReturn {
u.runAction("unlock vault", u.unlockAction)
return true
}
if u.shouldShowLifecycleSetup() && name == key.NameReturn {
if u.lifecycleMode == "remote" {
u.runAction("open remote vault", u.openRemoteAction)
} else {
u.runAction("open vault", u.openVaultAction)
}
return true
}
switch name {
case key.NameTab:
delta := 1
if modifiers.Contain(key.ModShift) {
delta = -1
}
u.moveKeyboardFocus(delta)
return true
case key.NameLeftArrow, key.NameRightArrow, key.NameUpArrow, key.NameDownArrow, key.NameReturn:
return u.handleFocusedKey(name)
default:
return false
}
}
func (u *ui) moveKeyboardFocus(delta int) {
order := u.focusOrder()
if len(order) == 0 {
return
}
current := canonicalFocusID(u.keyboardFocus)
index := 0
for i, item := range order {
if canonicalFocusID(item) == current {
index = i
break
}
}
index += delta
if index < 0 {
index = len(order) - 1
}
if index >= len(order) {
index = 0
}
u.setKeyboardFocus(order[index])
}
func (u *ui) focusOrder() []focusID {
if u.isVaultLocked() {
return []focusID{detailFocusID(detailFieldPassword)}
}
order := []focusID{focusSearch}
if u.state.Section != appstate.SectionRecycleBin {
order = append(order, breadcrumbFocusID(0))
}
if len(u.visible) > 0 {
order = append(order, listFocusID(u.focusedListIndexOrZero()))
}
order = append(order, detailFocusID(u.focusedDetailFieldOrDefault()))
return order
}
func (u *ui) setKeyboardFocus(id focusID) {
u.keyboardFocus = id
if strings.HasPrefix(string(id), "list:") {
u.focusListIndex(focusIndex(id))
}
}
func (u *ui) handleFocusedKey(name key.Name) bool {
switch {
case u.keyboardFocus == focusSearch:
if name == key.NameDownArrow && len(u.visible) > 0 {
u.setKeyboardFocus(listFocusID(u.focusedListIndexOrZero()))
return true
}
case strings.HasPrefix(string(u.keyboardFocus), "breadcrumb:"):
return u.handleBreadcrumbKey(name)
case strings.HasPrefix(string(u.keyboardFocus), "list:"):
return u.handleListKey(name)
case strings.HasPrefix(string(u.keyboardFocus), "detail:"):
return u.handleDetailKey(name)
}
return false
}
func (u *ui) handleBreadcrumbKey(name key.Name) bool {
crumbs := u.breadcrumbLabels()
if len(crumbs) == 0 {
return false
}
index := focusIndex(u.keyboardFocus)
switch name {
case key.NameLeftArrow:
if index > 0 {
u.keyboardFocus = breadcrumbFocusID(index - 1)
}
return true
case key.NameRightArrow:
if index < len(crumbs)-1 {
u.keyboardFocus = breadcrumbFocusID(index + 1)
}
return true
case key.NameDownArrow:
if len(u.visible) > 0 {
u.setKeyboardFocus(listFocusID(u.focusedListIndexOrZero()))
}
return true
case key.NameReturn:
u.activateBreadcrumb(index)
return true
default:
return false
}
}
func (u *ui) handleListKey(name key.Name) bool {
if len(u.visible) == 0 {
return false
}
index := focusIndex(u.keyboardFocus)
switch name {
case key.NameUpArrow:
if index > 0 {
u.setKeyboardFocus(listFocusID(index - 1))
}
return true
case key.NameDownArrow:
if index < len(u.visible)-1 {
u.setKeyboardFocus(listFocusID(index + 1))
}
return true
case key.NameLeftArrow:
u.keyboardFocus = breadcrumbFocusID(len(u.breadcrumbLabels()) - 1)
return true
case key.NameRightArrow, key.NameReturn:
u.keyboardFocus = detailFocusID(u.focusedDetailFieldOrDefault())
return true
default:
return false
}
}
func (u *ui) handleDetailKey(name key.Name) bool {
fields := detailFocusOrder()
index := u.focusedDetailIndex()
switch name {
case key.NameUpArrow:
if index > 0 {
u.keyboardFocus = detailFocusID(fields[index-1])
}
return true
case key.NameDownArrow:
if index < len(fields)-1 {
u.keyboardFocus = detailFocusID(fields[index+1])
}
return true
case key.NameLeftArrow:
if len(u.visible) > 0 {
u.setKeyboardFocus(listFocusID(u.focusedListIndexOrZero()))
}
return true
default:
return false
}
}
func (u *ui) handleShortcutKey(name key.Name, modifiers key.Modifiers) bool {
if !modifiers.Contain(key.ModShortcut) {
return false
}
switch name {
case "F":
_ = u.performShortcut(shortcutSearch)
case "S":
_ = u.performShortcut(shortcutSave)
case "L":
_ = u.performShortcut(shortcutLock)
case "N":
_ = u.performShortcut(shortcutNewEntry)
case "U":
_ = u.performShortcut(shortcutCopyUser)
case "P":
_ = u.performShortcut(shortcutCopyPassword)
case "O":
_ = u.performShortcut(shortcutCopyURL)
default:
return false
}
return true
}
func (u *ui) activateBreadcrumb(index int) {
var path []string
if index <= 0 {
path = nil
} else {
crumbs := u.breadcrumbLabels()
path = append([]string{}, crumbs[1:index+1]...)
}
u.state.NavigateToPath(path)
u.filter()
if index >= len(u.breadcrumbLabels()) {
index = len(u.breadcrumbLabels()) - 1
}
if index < 0 {
index = 0
}
u.keyboardFocus = breadcrumbFocusID(index)
}
func (u *ui) breadcrumbLabels() []string {
if u.state.Section == appstate.SectionRecycleBin {
return nil
}
labels := append([]string{"Vault"}, u.state.CurrentPath...)
if u.state.Section == appstate.SectionTemplates {
labels = append([]string{"Templates"}, u.state.CurrentPath...)
}
return labels
}
func (u *ui) focusListIndex(index int) {
if len(u.visible) == 0 {
return
}
if index < 0 {
index = 0
}
if index >= len(u.visible) {
index = len(u.visible) - 1
}
u.keyboardFocus = listFocusID(index)
u.state.SelectedEntryID = u.visible[index].ID
u.loadSelectedEntryIntoEditor()
}
func (u *ui) focusedListIndexOrZero() int {
if strings.HasPrefix(string(u.keyboardFocus), "list:") {
index := focusIndex(u.keyboardFocus)
if index >= 0 && index < len(u.visible) {
return index
}
}
for i, item := range u.visible {
if item.ID == u.state.SelectedEntryID {
return i
}
}
return 0
}
func (u *ui) focusedDetailFieldOrDefault() detailField {
if strings.HasPrefix(string(u.keyboardFocus), "detail:") {
name := strings.TrimPrefix(string(u.keyboardFocus), "detail:")
for _, field := range detailFocusOrder() {
if string(field) == name {
return field
}
}
}
return detailFieldTitle
}
func (u *ui) focusedDetailIndex() int {
current := u.focusedDetailFieldOrDefault()
for i, field := range detailFocusOrder() {
if field == current {
return i
}
}
return 0
}
func detailFocusOrder() []detailField {
return []detailField{
detailFieldID,
detailFieldTitle,
detailFieldUsername,
detailFieldPassword,
detailFieldURL,
detailFieldPath,
detailFieldTags,
detailFieldPasswordProfile,
detailFieldNotes,
detailFieldFields,
detailFieldHistoryIndex,
}
}
func canonicalFocusID(id focusID) focusID {
switch {
case strings.HasPrefix(string(id), "breadcrumb:"):
return breadcrumbFocusID(0)
case strings.HasPrefix(string(id), "list:"):
return listFocusID(0)
case strings.HasPrefix(string(id), "detail:"):
return detailFocusID(detailFieldTitle)
default:
return id
}
}
func focusIndex(id focusID) int {
_, value, ok := strings.Cut(string(id), ":")
if !ok {
return 0
}
index, err := strconv.Atoi(value)
if err != nil {
return 0
}
return index
}
+18 -25
View File
@@ -24,13 +24,19 @@ func (u *ui) processShortcuts(gtx layout.Context) {
event.Op(gtx.Ops, u)
for {
ev, ok := gtx.Event(
key.Filter{Focus: u, Name: "F", Required: key.ModShortcut},
key.Filter{Focus: u, Name: "S", Required: key.ModShortcut},
key.Filter{Focus: u, Name: "L", Required: key.ModShortcut},
key.Filter{Focus: u, Name: "N", Required: key.ModShortcut},
key.Filter{Focus: u, Name: "U", Required: key.ModShortcut},
key.Filter{Focus: u, Name: "P", Required: key.ModShortcut},
key.Filter{Focus: u, Name: "O", Required: key.ModShortcut},
key.Filter{Name: "F", Required: key.ModShortcut},
key.Filter{Name: "S", Required: key.ModShortcut},
key.Filter{Name: "L", Required: key.ModShortcut},
key.Filter{Name: "N", Required: key.ModShortcut},
key.Filter{Name: "U", Required: key.ModShortcut},
key.Filter{Name: "P", Required: key.ModShortcut},
key.Filter{Name: "O", Required: key.ModShortcut},
key.Filter{Name: key.NameTab, Optional: key.ModShift},
key.Filter{Name: key.NameLeftArrow},
key.Filter{Name: key.NameRightArrow},
key.Filter{Name: key.NameUpArrow},
key.Filter{Name: key.NameDownArrow},
key.Filter{Name: key.NameReturn},
)
if !ok {
break
@@ -41,37 +47,24 @@ func (u *ui) processShortcuts(gtx layout.Context) {
continue
}
switch ke.Name {
case "F":
_ = u.performShortcut(shortcutSearch)
case "S":
_ = u.performShortcut(shortcutSave)
case "L":
_ = u.performShortcut(shortcutLock)
case "N":
_ = u.performShortcut(shortcutNewEntry)
case "U":
_ = u.performShortcut(shortcutCopyUser)
case "P":
_ = u.performShortcut(shortcutCopyPassword)
case "O":
_ = u.performShortcut(shortcutCopyURL)
}
u.handleKeyPress(ke.Name, ke.Modifiers)
}
}
func (u *ui) performShortcut(name string) error {
switch name {
case shortcutSearch:
u.keyboardFocus = focusSearch
return nil
case shortcutSave:
return u.saveAction()
case shortcutLock:
return u.lockAction()
case shortcutNewEntry:
u.state.SelectedEntryID = ""
u.state.BeginNewEntry()
u.loadSelectedEntryIntoEditor()
u.entryPath.SetText(strings.Join(u.currentPath, " / "))
u.entryPath.SetText(strings.Join(u.state.CurrentPath, " / "))
u.keyboardFocus = detailFocusID(detailFieldTitle)
return nil
case shortcutCopyUser:
return u.copySelectedFieldAction(clipboard.TargetUsername)
+92 -51
View File
@@ -23,8 +23,8 @@ type KDBXConfig struct {
var ErrInvalidMasterKey = errors.New("invalid master key")
const (
templatesRoot = "Templates"
recycleBinRoot = "Recycle Bin"
templatesRoot = "Templates"
recycleBinRoot = "Recycle Bin"
keepassGOIDField = "KeePassGO-ID"
)
@@ -46,33 +46,29 @@ func SaveKDBXWithConfigAndKey(wr io.Writer, model Model, key MasterKey, config *
return err
}
header := gokeepasslib.NewHeader()
db := gokeepasslib.NewDatabase(gokeepasslib.WithDatabaseKDBXVersion4())
db.Credentials = credentials
db.Content.Meta = gokeepasslib.NewMetaData()
db.Content.Root = &gokeepasslib.RootData{}
if config != nil && config.Header != nil {
header = cloneHeader(config.Header)
db.Header = cloneHeader(config.Header)
db.Hashes = gokeepasslib.NewHashes(db.Header)
}
content := &gokeepasslib.DBContent{
Meta: gokeepasslib.NewMetaData(),
Root: &gokeepasslib.RootData{},
}
if header.IsKdbx4() {
if db.Header.IsKdbx4() {
if config != nil && config.InnerHeader != nil {
content.InnerHeader = cloneInnerHeader(config.InnerHeader)
} else {
content.InnerHeader = &gokeepasslib.InnerHeader{
db.Content.InnerHeader = cloneInnerHeader(config.InnerHeader)
db.Content.InnerHeader.Binaries = nil
} else if db.Content.InnerHeader == nil {
db.Content.InnerHeader = &gokeepasslib.InnerHeader{
InnerRandomStreamID: gokeepasslib.ChaChaStreamID,
InnerRandomStreamKey: randomBytes(64),
}
}
} else {
db.Content.InnerHeader = nil
}
db := &gokeepasslib.Database{
Header: header,
Credentials: credentials,
Content: content,
Hashes: gokeepasslib.NewHashes(header),
}
db.Content.Root.Groups = buildGroupTree(db, entriesForPersistence(model))
db.Content.Root.DeletedObjects = marshalDeletedObjects(model.RecycleBin)
db.Content.Root.Groups = buildGroupTree(db, model)
db.Content.Root.DeletedObjects = nil
if err := db.LockProtectedEntries(); err != nil {
return fmt.Errorf("lock protected entries: %w", err)
@@ -87,20 +83,21 @@ func SaveKDBXWithConfigAndKey(wr io.Writer, model Model, key MasterKey, config *
func appendGroupEntries(model *Model, db *gokeepasslib.Database, group gokeepasslib.Group, path []string) {
path = append(clonePath(path), group.Name)
model.CreateGroup(path[:len(path)-1], group.Name)
for _, entry := range group.Entries {
appendModelEntry(model, Entry{
ID: extractEntryID(entry),
Title: entry.GetTitle(),
Username: entry.GetContent("UserName"),
Password: entry.GetPassword(),
URL: entry.GetContent("URL"),
Notes: entry.GetContent("Notes"),
Tags: splitTags(entry.Tags),
Fields: extractCustomFields(entry),
ID: extractEntryID(entry),
Title: entry.GetTitle(),
Username: entry.GetContent("UserName"),
Password: entry.GetPassword(),
URL: entry.GetContent("URL"),
Notes: entry.GetContent("Notes"),
Tags: splitTags(entry.Tags),
Fields: extractCustomFields(entry),
Attachments: extractAttachments(db, entry),
History: extractHistory(db, entry, path),
Path: clonePath(path),
History: extractHistory(db, entry, path),
Path: clonePath(path),
})
}
@@ -207,7 +204,7 @@ func extractHistory(db *gokeepasslib.Database, entry gokeepasslib.Entry, path []
for _, item := range entry.Histories {
for _, historical := range item.Entries {
history = append(history, Entry{
ID: marshalUUID(historical.UUID),
ID: extractEntryID(historical),
Title: historical.GetTitle(),
Username: historical.GetContent("UserName"),
Password: historical.GetPassword(),
@@ -235,7 +232,8 @@ type MasterKey struct {
KeyFileData []byte
}
func buildGroupTree(db *gokeepasslib.Database, entries []Entry) []gokeepasslib.Group {
func buildGroupTree(db *gokeepasslib.Database, model Model) []gokeepasslib.Group {
entries := entriesForPersistence(model)
root := &groupNode{children: map[string]*groupNode{}}
for _, entry := range entries {
node := root
@@ -250,6 +248,18 @@ func buildGroupTree(db *gokeepasslib.Database, entries []Entry) []gokeepasslib.G
}
node.entries = append(node.entries, entry)
}
for _, path := range groupPathsForPersistence(model, entries) {
node := root
for _, segment := range path {
if node.children[segment] == nil {
node.children[segment] = &groupNode{
name: segment,
children: map[string]*groupNode{},
}
}
node = node.children[segment]
}
}
groups := marshalGroups(db, root)
if len(groups) > 0 {
@@ -261,6 +271,31 @@ func buildGroupTree(db *gokeepasslib.Database, entries []Entry) []gokeepasslib.G
return []gokeepasslib.Group{group}
}
func groupPathsForPersistence(model Model, entries []Entry) [][]string {
seen := map[string]bool{}
var groups [][]string
appendPath := func(path []string) {
key := strings.Join(path, "\x00")
if seen[key] {
return
}
seen[key] = true
groups = append(groups, slices.Clone(path))
}
for _, entry := range entries {
for i := 1; i <= len(entry.Path); i++ {
appendPath(entry.Path[:i])
}
}
for _, path := range model.Groups {
for i := 1; i <= len(path); i++ {
appendPath(path[:i])
}
}
return groups
}
func LoadKDBXWithKey(r io.Reader, key MasterKey) (Model, error) {
model, _, err := LoadKDBXWithConfig(r, key)
return model, err
@@ -407,7 +442,7 @@ func isInvalidCredentialError(err error) bool {
func marshalGroups(db *gokeepasslib.Database, node *groupNode) []gokeepasslib.Group {
names := slices.Collect(maps.Keys(node.children))
slices.Sort(names)
slices.SortFunc(names, compareGroupNames)
var groups []gokeepasslib.Group
for _, name := range names {
@@ -422,6 +457,29 @@ func marshalGroups(db *gokeepasslib.Database, node *groupNode) []gokeepasslib.Gr
return groups
}
func compareGroupNames(a, b string) int {
switch {
case a == b:
return 0
case a == "Root":
return -1
case b == "Root":
return 1
case a == templatesRoot:
return -1
case b == templatesRoot:
return 1
case a == recycleBinRoot:
return 1
case b == recycleBinRoot:
return -1
case a < b:
return -1
default:
return 1
}
}
func marshalEntries(db *gokeepasslib.Database, entries []Entry) []gokeepasslib.Entry {
slices.SortFunc(entries, func(a, b Entry) int {
switch {
@@ -477,23 +535,6 @@ func marshalEntry(db *gokeepasslib.Database, entry Entry) gokeepasslib.Entry {
return item
}
func marshalDeletedObjects(entries []Entry) []gokeepasslib.DeletedObjectData {
if len(entries) == 0 {
return nil
}
deletionTime := w.Now()
out := make([]gokeepasslib.DeletedObjectData, 0, len(entries))
for _, entry := range entries {
out = append(out, gokeepasslib.DeletedObjectData{
UUID: uuidForEntryID(entry.ID),
DeletionTime: &deletionTime,
})
}
return out
}
func uuidForEntryID(id string) gokeepasslib.UUID {
if id != "" {
var uuid gokeepasslib.UUID
+109
View File
@@ -3,6 +3,7 @@ package vault
import (
"bytes"
"errors"
"slices"
"testing"
"github.com/tobischo/gokeepasslib/v3"
@@ -602,6 +603,114 @@ func TestKDBXRoundTripsEntryAttachments(t *testing.T) {
}
}
func TestKDBXReopenCyclesPreserveStableIDsAndCrossFeatureState(t *testing.T) {
t.Parallel()
model := Model{
Entries: []Entry{
{
ID: "entry-1",
Title: "Git Server",
Username: "joejulian",
Password: "token-2",
URL: "https://git.julianfamily.org",
Notes: "Current credential",
Path: []string{"Root", "Internet"},
Attachments: map[string][]byte{
"token.txt": []byte("secret attachment contents"),
},
History: []Entry{
{
ID: "entry-1-history-1",
Title: "Git Server",
Username: "joejulian",
Password: "token-1",
URL: "https://git.julianfamily.org",
Notes: "Original credential",
Path: []string{"Root", "Internet"},
},
},
},
},
Templates: []Entry{
{
ID: "tpl-1",
Title: "Website Login",
Username: "template-user",
Password: "template-password",
Path: []string{"Templates", "Web"},
},
},
RecycleBin: []Entry{
{
ID: "deleted-1",
Title: "Retired Entry",
Username: "archived-user",
Password: "retired-token",
Path: []string{"Root", "Archive"},
},
},
Groups: [][]string{
{"Root", "Archive"},
{"Root", "Empty Group"},
{"Templates", "Web"},
},
}
var encoded bytes.Buffer
if err := SaveKDBX(&encoded, model, "correct horse battery staple"); err != nil {
t.Fatalf("SaveKDBX(first cycle) error = %v", err)
}
reopened, err := LoadKDBX(bytes.NewReader(encoded.Bytes()), "correct horse battery staple")
if err != nil {
t.Fatalf("LoadKDBX(first cycle) error = %v", err)
}
encoded.Reset()
if err := SaveKDBX(&encoded, reopened, "correct horse battery staple"); err != nil {
t.Fatalf("SaveKDBX(second cycle) error = %v", err)
}
reopened, err = LoadKDBX(bytes.NewReader(encoded.Bytes()), "correct horse battery staple")
if err != nil {
t.Fatalf("LoadKDBX(second cycle) error = %v", err)
}
got := reopened.EntriesInPath([]string{"Root", "Internet"})
if len(got) != 1 {
t.Fatalf("len(EntriesInPath(Root/Internet)) = %d, want 1", len(got))
}
if got[0].ID != "entry-1" {
t.Fatalf("entry ID after reopen cycles = %q, want %q", got[0].ID, "entry-1")
}
if len(got[0].History) != 1 {
t.Fatalf("len(History) after reopen cycles = %d, want 1", len(got[0].History))
}
if got[0].History[0].ID != "entry-1-history-1" {
t.Fatalf("history ID after reopen cycles = %q, want %q", got[0].History[0].ID, "entry-1-history-1")
}
if string(got[0].Attachments["token.txt"]) != "secret attachment contents" {
t.Fatalf("attachment after reopen cycles = %q, want %q", string(got[0].Attachments["token.txt"]), "secret attachment contents")
}
if len(reopened.Templates) != 1 || reopened.Templates[0].Path[1] != "Web" {
t.Fatalf("Templates after reopen cycles = %#v, want Website Login in Templates/Web", reopened.Templates)
}
if len(reopened.RecycleBin) != 1 || reopened.RecycleBin[0].Path[1] != "Archive" {
t.Fatalf("RecycleBin after reopen cycles = %#v, want recycled entry in Root/Archive", reopened.RecycleBin)
}
rootGroups := reopened.ChildGroups([]string{"Root"})
if !slices.Equal(rootGroups, []string{"Archive", "Empty Group", "Internet"}) {
t.Fatalf("ChildGroups(Root) after reopen cycles = %v, want [Archive Empty Group Internet]", rootGroups)
}
templateGroups := reopened.ChildGroups([]string{"Templates"})
if !slices.Equal(templateGroups, []string{"Web"}) {
t.Fatalf("ChildGroups(Templates) after reopen cycles = %v, want [Web]", templateGroups)
}
}
func mustGroup(name string, children ...any) gokeepasslib.Group {
group := gokeepasslib.NewGroup()
group.Name = name
+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"
)
+13 -12
View File
@@ -7,19 +7,20 @@ import (
)
var ErrEntryNotFound = errors.New("entry not found")
var ErrGroupNotEmpty = errors.New("group is not empty")
type Entry struct {
ID string
Title string
Username string
Password string
URL string
Notes string
Tags []string
Fields map[string]string
ID string
Title string
Username string
Password string
URL string
Notes string
Tags []string
Fields map[string]string
Attachments map[string][]byte
History []Entry
Path []string
History []Entry
Path []string
}
type SearchResult struct {
@@ -323,12 +324,12 @@ func (m *Model) MoveTemplate(id string, path []string) error {
func (m *Model) DeleteGroup(path []string) error {
for _, entry := range m.Entries {
if slices.Equal(entry.Path, path) || hasPathPrefix(entry.Path, path) {
return errors.New("group is not empty")
return ErrGroupNotEmpty
}
}
for _, entry := range m.Templates {
if slices.Equal(entry.Path, path) || hasPathPrefix(entry.Path, path) {
return errors.New("group is not empty")
return ErrGroupNotEmpty
}
}