Compare commits
72 Commits
a4b3d5c1e1
...
ed2118223f
| Author | SHA1 | Date | |
|---|---|---|---|
| ed2118223f | |||
| 9e30f654b2 | |||
| 1ecad1fae9 | |||
| fc34508689 | |||
| 9dea7a4a34 | |||
| e5904dd224 | |||
| da769a9728 | |||
| f47682f3a1 | |||
| 9dcc5f1d6f | |||
| ee9b00bd85 | |||
| 8ce924e08f | |||
| bda28eef4b | |||
| bf56e38bc5 | |||
| d137018d3a | |||
| fcc0291dd9 | |||
| cc127a013e | |||
| b2f1d9a66d | |||
| b391cea295 | |||
| fba135ff09 | |||
| 71b383114d | |||
| 75dc6453f7 | |||
| 72b413a11a | |||
| 75002a4c47 | |||
| e98256709d | |||
| 5e15ad3265 | |||
| d492743eb1 | |||
| 4a629c16bd | |||
| 62fc343ecf | |||
| 1e51eff76e | |||
| 4509634a15 | |||
| 47bbbdb05e | |||
| 4fbaee3970 | |||
| d2b49ca0ad | |||
| fedeab2fc1 | |||
| b56401b5c6 | |||
| 01559a3a2b | |||
| 16dc1de3c8 | |||
| bd9674a1f5 | |||
| e2e38a97a0 | |||
| 34a6e0150e | |||
| ec063ac81f | |||
| ac5b6894cf | |||
| d6bc213474 | |||
| 706a72e135 | |||
| f8111f626f | |||
| 0bfa30de91 | |||
| 24dcf9c8ad | |||
| ea5c8b3c31 | |||
| c355eebed5 | |||
| 7651b3b237 | |||
| da942b41d7 | |||
| 1c0cd20bcc | |||
| cffe05af82 | |||
| 8deccced9e | |||
| 4fe912b41f | |||
| 55ca6352b4 | |||
| 0cfccb58d6 | |||
| b043ecdc83 | |||
| d3be07f252 | |||
| 0f30c5b1c5 | |||
| 985150c36f | |||
| f39bdcd3be | |||
| 48ffa78fa2 | |||
| 1349fe6e38 | |||
| 19ccab1d8d | |||
| ae1921506d | |||
| 1f51904567 | |||
| ef867adfa1 | |||
| 5eb2068d3e | |||
| 77407e0510 | |||
| dd0b0b6f6e | |||
| 566256ab79 |
+2
-1
@@ -1 +1,2 @@
|
||||
gio-keepass-mock
|
||||
build/
|
||||
*.apk
|
||||
|
||||
@@ -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 user’s 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.
|
||||
|
||||
@@ -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 ...
|
||||
```
|
||||
@@ -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) \
|
||||
.
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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=
|
||||
|
||||
+2481
-18
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -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 {
|
||||
|
||||
+314
-194
File diff suppressed because it is too large
Load Diff
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user