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 `golangci-lint` passing.
|
||||||
- Keep `go test ./...` 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.
|
- 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
|
||||||
|
|
||||||
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
|
## Current Capabilities
|
||||||
|
|
||||||
- KDBX load and save
|
- KDBX load and save
|
||||||
- password, key-file, and composite master-key support in the storage layer
|
- password-only, key-file-only, and composite master-key flows through the desktop product UI
|
||||||
|
- master-key changes for existing vault sessions
|
||||||
- WebDAV-backed open and save support in the session layer
|
- WebDAV-backed open and save support in the session layer
|
||||||
- password generation profiles
|
- password generation profiles
|
||||||
- gRPC integration surface for trusted automation
|
- gRPC integration surface for trusted automation
|
||||||
|
|||||||
@@ -6,6 +6,195 @@ These segments are intended to be independently executable wherever possible.
|
|||||||
Each segment has its own local exit criteria.
|
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.
|
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
|
### Segment 1: Application State Ownership
|
||||||
|
|
||||||
Scope:
|
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.
|
- Build and run instructions exist for desktop, and packaging guidance exists for Android.
|
||||||
- `go test ./...` passes.
|
- `go test ./...` passes.
|
||||||
- `go tool golangci-lint run ./...` 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"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"maps"
|
"maps"
|
||||||
|
"os"
|
||||||
"slices"
|
"slices"
|
||||||
"sync"
|
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
|
|
||||||
"git.julianfamily.org/keepassgo/clipboard"
|
"git.julianfamily.org/keepassgo/clipboard"
|
||||||
"git.julianfamily.org/keepassgo/passwords"
|
"git.julianfamily.org/keepassgo/passwords"
|
||||||
keepassgov1 "git.julianfamily.org/keepassgo/proto/keepassgo/v1"
|
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/vault"
|
||||||
|
"git.julianfamily.org/keepassgo/webdav"
|
||||||
"google.golang.org/grpc"
|
"google.golang.org/grpc"
|
||||||
"google.golang.org/grpc/codes"
|
"google.golang.org/grpc/codes"
|
||||||
"google.golang.org/grpc/metadata"
|
"google.golang.org/grpc/metadata"
|
||||||
@@ -36,6 +38,8 @@ type lifecycleBackend interface {
|
|||||||
Open(string, vault.MasterKey) error
|
Open(string, vault.MasterKey) error
|
||||||
OpenRemote(webdav.Client, string, vault.MasterKey) error
|
OpenRemote(webdav.Client, string, vault.MasterKey) error
|
||||||
Save() error
|
Save() error
|
||||||
|
Lock() error
|
||||||
|
Unlock(vault.MasterKey) error
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewServer(model vault.Model, profiles map[string]passwords.Profile, clipboardWriter clipboard.Writer) *Server {
|
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()
|
defer s.mu.RUnlock()
|
||||||
|
|
||||||
return &keepassgov1.GetSessionStatusResponse{
|
return &keepassgov1.GetSessionStatusResponse{
|
||||||
Locked: s.locked,
|
Locked: s.locked,
|
||||||
Dirty: s.dirty,
|
Dirty: s.dirty,
|
||||||
EntryCount: uint32(len(s.model.Entries)),
|
EntryCount: uint32(len(s.model.Entries)),
|
||||||
}, nil
|
}, 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()...)}
|
key := vault.MasterKey{Password: req.GetPassword(), KeyFileData: append([]byte(nil), req.GetKeyFileData()...)}
|
||||||
if err := s.lifecycle.Open(req.GetPath(), key); err != nil {
|
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()
|
model, err := s.lifecycle.Current()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, status.Errorf(codes.Internal, "load opened vault: %v", err)
|
return nil, mapLifecycleError("load opened vault", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
s.mu.Lock()
|
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()...)}
|
key := vault.MasterKey{Password: req.GetMasterPassword(), KeyFileData: append([]byte(nil), req.GetKeyFileData()...)}
|
||||||
if err := s.lifecycle.OpenRemote(client, req.GetPath(), key); err != nil {
|
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()
|
model, err := s.lifecycle.Current()
|
||||||
if err != nil {
|
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()
|
s.mu.Lock()
|
||||||
@@ -122,7 +126,7 @@ func (s *Server) SaveVault(_ context.Context, _ *keepassgov1.SaveVaultRequest) (
|
|||||||
}
|
}
|
||||||
|
|
||||||
if err := s.lifecycle.Save(); err != nil {
|
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()
|
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) {
|
func (s *Server) LockVault(_ context.Context, _ *keepassgov1.LockVaultRequest) (*keepassgov1.LockVaultResponse, error) {
|
||||||
s.mu.Lock()
|
if s.lifecycle == nil {
|
||||||
defer s.mu.Unlock()
|
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.locked = true
|
||||||
|
s.mu.Unlock()
|
||||||
|
|
||||||
return &keepassgov1.LockVaultResponse{}, nil
|
return &keepassgov1.LockVaultResponse{}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) UnlockVault(_ context.Context, _ *keepassgov1.UnlockVaultRequest) (*keepassgov1.UnlockVaultResponse, error) {
|
func (s *Server) UnlockVault(_ context.Context, req *keepassgov1.UnlockVaultRequest) (*keepassgov1.UnlockVaultResponse, error) {
|
||||||
s.mu.Lock()
|
if s.lifecycle == nil {
|
||||||
defer s.mu.Unlock()
|
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.locked = false
|
||||||
|
s.mu.Unlock()
|
||||||
|
|
||||||
return &keepassgov1.UnlockVaultResponse{}, nil
|
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) {
|
func (s *Server) ListEntries(_ context.Context, req *keepassgov1.ListEntriesRequest) (*keepassgov1.ListEntriesResponse, error) {
|
||||||
s.mu.RLock()
|
s.mu.RLock()
|
||||||
defer s.mu.RUnlock()
|
defer s.mu.RUnlock()
|
||||||
@@ -224,6 +264,29 @@ func (s *Server) RenameGroup(_ context.Context, req *keepassgov1.RenameGroupRequ
|
|||||||
return &keepassgov1.RenameGroupResponse{}, nil
|
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) {
|
func (s *Server) UpsertEntry(_ context.Context, req *keepassgov1.UpsertEntryRequest) (*keepassgov1.UpsertEntryResponse, error) {
|
||||||
if req.GetEntry() == nil {
|
if req.GetEntry() == nil {
|
||||||
return nil, status.Error(codes.InvalidArgument, "missing entry")
|
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")
|
return nil, status.Error(codes.FailedPrecondition, "vault is locked")
|
||||||
}
|
}
|
||||||
|
|
||||||
profile, ok := s.profiles[req.GetProfile()]
|
profile, err := passwords.LookupProfile(req.GetProfile(), s.profiles)
|
||||||
if !ok {
|
if err != nil {
|
||||||
return nil, status.Errorf(codes.InvalidArgument, "unknown password profile %q", req.GetProfile())
|
return nil, status.Error(codes.InvalidArgument, err.Error())
|
||||||
}
|
}
|
||||||
|
|
||||||
password, err := passwords.Generate(profile)
|
password, err := passwords.Generate(profile)
|
||||||
@@ -565,6 +628,7 @@ func entryToProto(entry vault.Entry) *keepassgov1.Entry {
|
|||||||
Notes: entry.Notes,
|
Notes: entry.Notes,
|
||||||
Tags: append([]string(nil), entry.Tags...),
|
Tags: append([]string(nil), entry.Tags...),
|
||||||
Path: append([]string(nil), entry.Path...),
|
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(),
|
Notes: entry.GetNotes(),
|
||||||
Tags: append([]string(nil), entry.GetTags()...),
|
Tags: append([]string(nil), entry.GetTags()...),
|
||||||
Path: append([]string(nil), entry.GetPath()...),
|
Path: append([]string(nil), entry.GetPath()...),
|
||||||
|
Fields: maps.Clone(entry.GetFields()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+371
-15
@@ -4,6 +4,7 @@ import (
|
|||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"net"
|
"net"
|
||||||
|
"os"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"git.julianfamily.org/keepassgo/passwords"
|
"git.julianfamily.org/keepassgo/passwords"
|
||||||
@@ -34,7 +35,21 @@ func TestVaultServiceRejectsRequestsWithoutBearerToken(t *testing.T) {
|
|||||||
func TestVaultServiceReportsSessionStatusAndSupportsLockUnlock(t *testing.T) {
|
func TestVaultServiceReportsSessionStatusAndSupportsLockUnlock(t *testing.T) {
|
||||||
t.Parallel()
|
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()
|
defer cleanup()
|
||||||
|
|
||||||
ctx := metadata.AppendToOutgoingContext(context.Background(), "authorization", "Bearer test-token")
|
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) {
|
func TestVaultServiceOpensAndSavesVaultThroughLifecycleBackend(t *testing.T) {
|
||||||
t.Parallel()
|
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) {
|
func TestVaultServiceListsEntriesForAuthorizedClients(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -150,6 +378,9 @@ func TestVaultServiceListsEntriesForAuthorizedClients(t *testing.T) {
|
|||||||
if resp.Entries[0].Title != "Git Server" {
|
if resp.Entries[0].Title != "Git Server" {
|
||||||
t.Fatalf("ListEntries().Entries[0].Title = %q, want %q", 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) {
|
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) {
|
func TestVaultServiceGeneratesPasswordsForAuthorizedClients(t *testing.T) {
|
||||||
t.Parallel()
|
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) {
|
func TestVaultServiceCopiesEntryFieldsForAuthorizedClients(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -249,7 +529,10 @@ func TestVaultServiceUpsertsEntriesForAuthorizedClients(t *testing.T) {
|
|||||||
Username: "codex",
|
Username: "codex",
|
||||||
Password: "token-2",
|
Password: "token-2",
|
||||||
Url: "https://lights.julianfamily.org",
|
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 {
|
if err != nil {
|
||||||
@@ -259,6 +542,9 @@ func TestVaultServiceUpsertsEntriesForAuthorizedClients(t *testing.T) {
|
|||||||
if upserted.Entry.Title != "Home Assistant (Codex)" {
|
if upserted.Entry.Title != "Home Assistant (Codex)" {
|
||||||
t.Fatalf("UpsertEntry().Entry.Title = %q, want %q", 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"}})
|
listed, err := client.ListEntries(ctx, &keepassgov1.ListEntriesRequest{Path: []string{"Root", "Home Assistant"}})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -268,6 +554,9 @@ func TestVaultServiceUpsertsEntriesForAuthorizedClients(t *testing.T) {
|
|||||||
if len(listed.Entries) != 1 || listed.Entries[0].Password != "token-2" {
|
if len(listed.Entries) != 1 || listed.Entries[0].Password != "token-2" {
|
||||||
t.Fatalf("ListEntries().Entries = %#v, want persisted Home Assistant entry", listed.Entries)
|
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) {
|
func TestVaultServiceDeletesAndRestoresEntriesForAuthorizedClients(t *testing.T) {
|
||||||
@@ -324,6 +613,9 @@ func TestVaultServiceListsAndInstantiatesTemplatesForAuthorizedClients(t *testin
|
|||||||
if len(templates.Templates) != 1 || templates.Templates[0].Title != "Website Login" {
|
if len(templates.Templates) != 1 || templates.Templates[0].Title != "Website Login" {
|
||||||
t.Fatalf("ListTemplates().Templates = %#v, want Website Login template", templates.Templates)
|
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{
|
instantiated, err := client.InstantiateTemplate(ctx, &keepassgov1.InstantiateTemplateRequest{
|
||||||
TemplateId: "website-login",
|
TemplateId: "website-login",
|
||||||
@@ -333,8 +625,11 @@ func TestVaultServiceListsAndInstantiatesTemplatesForAuthorizedClients(t *testin
|
|||||||
Username: "jjulian",
|
Username: "jjulian",
|
||||||
Password: "hunter2",
|
Password: "hunter2",
|
||||||
Url: "https://www.dynadot.com",
|
Url: "https://www.dynadot.com",
|
||||||
Path: []string{"Root", "Internet"},
|
Fields: map[string]string{
|
||||||
Tags: []string{"dns"},
|
"Environment": "staging",
|
||||||
|
},
|
||||||
|
Path: []string{"Root", "Internet"},
|
||||||
|
Tags: []string{"dns"},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -344,6 +639,9 @@ func TestVaultServiceListsAndInstantiatesTemplatesForAuthorizedClients(t *testin
|
|||||||
if instantiated.Entry.Title != "Dynadot" || instantiated.Entry.Notes != "Reusable template for website accounts." {
|
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)
|
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"}})
|
listed, err := client.ListEntries(ctx, &keepassgov1.ListEntriesRequest{Path: []string{"Root", "Internet"}})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -368,7 +666,10 @@ func TestVaultServiceUpsertsAndDeletesTemplatesForAuthorizedClients(t *testing.T
|
|||||||
Title: "Website Login Updated",
|
Title: "Website Login Updated",
|
||||||
Username: "template-user",
|
Username: "template-user",
|
||||||
Password: "template-password",
|
Password: "template-password",
|
||||||
Path: []string{"Templates", "Web"},
|
Fields: map[string]string{
|
||||||
|
"Environment": "dev",
|
||||||
|
},
|
||||||
|
Path: []string{"Templates", "Web"},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -377,6 +678,9 @@ func TestVaultServiceUpsertsAndDeletesTemplatesForAuthorizedClients(t *testing.T
|
|||||||
if upserted.Template.Title != "Website Login Updated" {
|
if upserted.Template.Title != "Website Login Updated" {
|
||||||
t.Fatalf("UpsertTemplate().Template.Title = %q, want updated title", upserted.Template.Title)
|
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{})
|
listed, err := client.ListTemplates(ctx, &keepassgov1.ListTemplatesRequest{})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -385,6 +689,9 @@ func TestVaultServiceUpsertsAndDeletesTemplatesForAuthorizedClients(t *testing.T
|
|||||||
if len(listed.Templates) != 1 || listed.Templates[0].Title != "Website Login Updated" {
|
if len(listed.Templates) != 1 || listed.Templates[0].Title != "Website Login Updated" {
|
||||||
t.Fatalf("ListTemplates().Templates = %#v, want updated template", listed.Templates)
|
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 {
|
if _, err := client.DeleteTemplate(ctx, &keepassgov1.DeleteTemplateRequest{Id: "website-login"}); err != nil {
|
||||||
t.Fatalf("DeleteTemplate() error = %v", err)
|
t.Fatalf("DeleteTemplate() error = %v", err)
|
||||||
@@ -493,6 +800,9 @@ func newTestClient(t *testing.T) (keepassgov1.VaultServiceClient, *memoryClipboa
|
|||||||
Username: "joejulian",
|
Username: "joejulian",
|
||||||
Password: "token-1",
|
Password: "token-1",
|
||||||
URL: "https://git.julianfamily.org",
|
URL: "https://git.julianfamily.org",
|
||||||
|
Fields: map[string]string{
|
||||||
|
"X-Role": "automation",
|
||||||
|
},
|
||||||
History: []vault.Entry{
|
History: []vault.Entry{
|
||||||
{
|
{
|
||||||
ID: "git-server-h1",
|
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"},
|
Path: []string{"Root", "Internet"},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
ID: "ha-codex",
|
ID: "ha-codex",
|
||||||
@@ -522,8 +832,11 @@ func newTestClient(t *testing.T) (keepassgov1.VaultServiceClient, *memoryClipboa
|
|||||||
Password: "template-password",
|
Password: "template-password",
|
||||||
URL: "https://example.com",
|
URL: "https://example.com",
|
||||||
Notes: "Reusable template for website accounts.",
|
Notes: "Reusable template for website accounts.",
|
||||||
Tags: []string{"template", "web"},
|
Fields: map[string]string{
|
||||||
Path: []string{"Templates"},
|
"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")))
|
server := grpc.NewServer(grpc.UnaryInterceptor(BearerTokenInterceptor("test-token")))
|
||||||
clipboardWriter := &memoryClipboardWriter{}
|
clipboardWriter := &memoryClipboardWriter{}
|
||||||
keepassgov1.RegisterVaultServiceServer(server, NewServerWithLifecycle(
|
keepassgov1.RegisterVaultServiceServer(server, NewServerWithLifecycle(
|
||||||
vault.Model{},
|
lifecycle.model,
|
||||||
passwords.DefaultProfiles(),
|
passwords.DefaultProfiles(),
|
||||||
clipboardWriter,
|
clipboardWriter,
|
||||||
lifecycle,
|
lifecycle,
|
||||||
@@ -598,12 +911,16 @@ func (w *memoryClipboardWriter) WriteText(text string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type stubLifecycle struct {
|
type stubLifecycle struct {
|
||||||
model vault.Model
|
model vault.Model
|
||||||
openPath string
|
openPath string
|
||||||
remoteBaseURL string
|
remoteBaseURL string
|
||||||
remotePath string
|
remotePath string
|
||||||
saved bool
|
saved bool
|
||||||
locked bool
|
locked bool
|
||||||
|
err error
|
||||||
|
unlockPassword string
|
||||||
|
unlockKeyFile []byte
|
||||||
|
lastUnlockKey vault.MasterKey
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *stubLifecycle) Current() (vault.Model, error) {
|
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 {
|
func (s *stubLifecycle) Open(path string, _ vault.MasterKey) error {
|
||||||
|
if s.err != nil {
|
||||||
|
return s.err
|
||||||
|
}
|
||||||
s.openPath = path
|
s.openPath = path
|
||||||
|
s.locked = false
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *stubLifecycle) OpenRemote(client webdav.Client, path string, _ vault.MasterKey) error {
|
func (s *stubLifecycle) OpenRemote(client webdav.Client, path string, _ vault.MasterKey) error {
|
||||||
|
if s.err != nil {
|
||||||
|
return s.err
|
||||||
|
}
|
||||||
s.remoteBaseURL = client.BaseURL
|
s.remoteBaseURL = client.BaseURL
|
||||||
s.remotePath = path
|
s.remotePath = path
|
||||||
|
s.locked = false
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *stubLifecycle) Save() error {
|
func (s *stubLifecycle) Save() error {
|
||||||
|
if s.err != nil {
|
||||||
|
return s.err
|
||||||
|
}
|
||||||
s.saved = true
|
s.saved = true
|
||||||
return nil
|
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
|
package appstate
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"slices"
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -11,6 +12,11 @@ import (
|
|||||||
|
|
||||||
type Section string
|
type Section string
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrAttachmentAlreadyExists = errors.New("attachment already exists")
|
||||||
|
ErrAttachmentNotFound = errors.New("attachment not found")
|
||||||
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
SectionEntries Section = ""
|
SectionEntries Section = ""
|
||||||
SectionTemplates Section = "templates"
|
SectionTemplates Section = "templates"
|
||||||
@@ -32,11 +38,29 @@ type LockableSession interface {
|
|||||||
Unlock(vault.MasterKey) error
|
Unlock(vault.MasterKey) error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type MasterKeyChangeableSession interface {
|
||||||
|
CurrentSession
|
||||||
|
ChangeMasterKey(vault.MasterKey) error
|
||||||
|
}
|
||||||
|
|
||||||
type SaveableSession interface {
|
type SaveableSession interface {
|
||||||
CurrentSession
|
CurrentSession
|
||||||
Save() error
|
Save() error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 {
|
type CreateableSession interface {
|
||||||
CurrentSession
|
CurrentSession
|
||||||
Create(vault.Model, vault.MasterKey) error
|
Create(vault.Model, vault.MasterKey) error
|
||||||
@@ -64,6 +88,35 @@ type State struct {
|
|||||||
SearchQuery string
|
SearchQuery string
|
||||||
SelectedEntryID string
|
SelectedEntryID string
|
||||||
Dirty bool
|
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) {
|
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 {
|
func entriesInPath(entries []vault.Entry, path []string) []vault.Entry {
|
||||||
var out []vault.Entry
|
var out []vault.Entry
|
||||||
for _, entry := range entries {
|
for _, entry := range entries {
|
||||||
@@ -419,6 +485,20 @@ func (s *State) Unlock(key vault.MasterKey) error {
|
|||||||
return session.Unlock(key)
|
return session.Unlock(key)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *State) ChangeMasterKey(key vault.MasterKey) error {
|
||||||
|
session, ok := s.Session.(MasterKeyChangeableSession)
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("session does not support master key changes")
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := session.ChangeMasterKey(key); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
s.Dirty = true
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func (s *State) EnterGroup(name string) {
|
func (s *State) EnterGroup(name string) {
|
||||||
s.CurrentPath = append(append([]string(nil), s.CurrentPath...), name)
|
s.CurrentPath = append(append([]string(nil), s.CurrentPath...), name)
|
||||||
s.SelectedEntryID = ""
|
s.SelectedEntryID = ""
|
||||||
@@ -443,6 +523,68 @@ func (s *State) Save() error {
|
|||||||
return nil
|
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 {
|
func (s *State) CreateVault(key vault.MasterKey) error {
|
||||||
session, ok := s.Session.(CreateableSession)
|
session, ok := s.Session.(CreateableSession)
|
||||||
if !ok {
|
if !ok {
|
||||||
@@ -565,6 +707,30 @@ func (s *State) MoveSelectedEntry(path []string) error {
|
|||||||
return nil
|
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 {
|
func (s *State) AddAttachmentToSelectedEntry(name string, content []byte) error {
|
||||||
session, ok := s.Session.(MutableSession)
|
session, ok := s.Session.(MutableSession)
|
||||||
if !ok {
|
if !ok {
|
||||||
@@ -583,6 +749,36 @@ func (s *State) AddAttachmentToSelectedEntry(name string, content []byte) error
|
|||||||
if model.Entries[i].Attachments == nil {
|
if model.Entries[i].Attachments == nil {
|
||||||
model.Entries[i].Attachments = map[string][]byte{}
|
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...)
|
model.Entries[i].Attachments[name] = append([]byte(nil), content...)
|
||||||
session.Replace(model)
|
session.Replace(model)
|
||||||
s.Dirty = true
|
s.Dirty = true
|
||||||
@@ -607,6 +803,9 @@ func (s *State) DeleteAttachmentFromSelectedEntry(name string) error {
|
|||||||
if model.Entries[i].ID != s.SelectedEntryID {
|
if model.Entries[i].ID != s.SelectedEntryID {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
if _, exists := model.Entries[i].Attachments[name]; !exists {
|
||||||
|
return ErrAttachmentNotFound
|
||||||
|
}
|
||||||
delete(model.Entries[i].Attachments, name)
|
delete(model.Entries[i].Attachments, name)
|
||||||
if len(model.Entries[i].Attachments) == 0 {
|
if len(model.Entries[i].Attachments) == 0 {
|
||||||
model.Entries[i].Attachments = nil
|
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) {
|
func TestChildGroupsUsesCurrentModelAndCurrentPath(t *testing.T) {
|
||||||
t.Parallel()
|
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) {
|
func TestRestoreSelectedEntryVersionReplacesCurrentVersion(t *testing.T) {
|
||||||
t.Parallel()
|
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) {
|
func TestEnterGroupAppendsPathAndClearsSelection(t *testing.T) {
|
||||||
t.Parallel()
|
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) {
|
func TestCreateGroupPersistsGroupAndMarksDirty(t *testing.T) {
|
||||||
t.Parallel()
|
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) {
|
func TestMoveSelectedEntryPersistsPathChangeAndMarksDirty(t *testing.T) {
|
||||||
t.Parallel()
|
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) {
|
func TestDeleteAttachmentFromSelectedEntryPersistsAndMarksDirty(t *testing.T) {
|
||||||
t.Parallel()
|
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 {
|
type mutableStubSession struct {
|
||||||
model vault.Model
|
model vault.Model
|
||||||
err error
|
err error
|
||||||
@@ -929,6 +1347,7 @@ type lifecycleStubSession struct {
|
|||||||
openPath string
|
openPath string
|
||||||
saveAsPath string
|
saveAsPath string
|
||||||
remotePath string
|
remotePath string
|
||||||
|
changedKey vault.MasterKey
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *lifecycleStubSession) Current() (vault.Model, error) {
|
func (s *lifecycleStubSession) Current() (vault.Model, error) {
|
||||||
@@ -954,3 +1373,8 @@ func (s *lifecycleStubSession) OpenRemote(_ webdav.Client, path string, _ vault.
|
|||||||
s.remotePath = path
|
s.remotePath = path
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *lifecycleStubSession) ChangeMasterKey(key vault.MasterKey) error {
|
||||||
|
s.changedKey = key
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|||||||
+18
-2
@@ -2,7 +2,6 @@ package clipboard
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
|
||||||
|
|
||||||
systemclipboard "github.com/atotto/clipboard"
|
systemclipboard "github.com/atotto/clipboard"
|
||||||
|
|
||||||
@@ -10,6 +9,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
var ErrUnsupportedTarget = errors.New("unsupported clipboard target")
|
var ErrUnsupportedTarget = errors.New("unsupported clipboard target")
|
||||||
|
var ErrWriteFailed = errors.New("clipboard write failed")
|
||||||
|
|
||||||
type Target string
|
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 {
|
if err := s.writer().WriteText(content); err != nil {
|
||||||
return fmt.Errorf("write clipboard text: %w", err)
|
return writeError{err: err}
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
@@ -79,3 +79,19 @@ type systemWriter struct{}
|
|||||||
func (systemWriter) WriteText(text string) error {
|
func (systemWriter) WriteText(text string) error {
|
||||||
return systemclipboard.WriteAll(text)
|
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 {
|
type memoryWriter struct {
|
||||||
content string
|
content string
|
||||||
}
|
}
|
||||||
@@ -76,3 +95,11 @@ func (w *memoryWriter) WriteText(text string) error {
|
|||||||
w.content = text
|
w.content = text
|
||||||
return nil
|
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 password-only vaults
|
||||||
- open and save key-file-only vaults
|
- open and save key-file-only vaults
|
||||||
- open and save composite password-plus-key-file vaults
|
- open and save composite password-plus-key-file vaults
|
||||||
|
- select the active master-key mode in the product UI for create, open, and unlock flows
|
||||||
|
- change an existing session to a new master-key mode before saving
|
||||||
- preserve the original opened vault's KDBX format version during save
|
- preserve the original opened vault's KDBX format version during save
|
||||||
- preserve the original opened vault's cipher selection during save
|
- preserve the original opened vault's cipher selection during save
|
||||||
- preserve the original opened vault's KDF selection during save
|
- preserve the original opened vault's KDF selection during save
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ require (
|
|||||||
require (
|
require (
|
||||||
4d63.com/gocheckcompilerdirectives v1.3.0 // indirect
|
4d63.com/gocheckcompilerdirectives v1.3.0 // indirect
|
||||||
4d63.com/gochecknoglobals v0.2.2 // indirect
|
4d63.com/gochecknoglobals v0.2.2 // indirect
|
||||||
|
gioui.org/cmd v0.9.0 // indirect
|
||||||
gioui.org/shader v1.0.8 // indirect
|
gioui.org/shader v1.0.8 // indirect
|
||||||
github.com/4meepo/tagalign v1.4.2 // indirect
|
github.com/4meepo/tagalign v1.4.2 // indirect
|
||||||
github.com/Abirdcfly/dupword v0.1.3 // 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/GaijinEntertainment/go-exhaustruct/v3 v3.3.1 // indirect
|
||||||
github.com/Masterminds/semver/v3 v3.3.0 // indirect
|
github.com/Masterminds/semver/v3 v3.3.0 // indirect
|
||||||
github.com/OpenPeeDeeP/depguard/v2 v2.2.1 // 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/alecthomas/go-check-sumtype v0.3.1 // indirect
|
||||||
github.com/alexkohler/nakedret/v2 v2.0.5 // indirect
|
github.com/alexkohler/nakedret/v2 v2.0.5 // indirect
|
||||||
github.com/alexkohler/prealloc v1.0.0 // indirect
|
github.com/alexkohler/prealloc v1.0.0 // indirect
|
||||||
@@ -207,4 +209,7 @@ require (
|
|||||||
mvdan.cc/unparam v0.0.0-20240528143540-8a5130ca722f // indirect
|
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=
|
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 h1:4u7XZwnb5kzQW91Nz/vR0wKD6LdW9CaVF96r3rfy4kc=
|
||||||
gioui.org v0.9.0/go.mod h1:CjNig0wAhLt9WZxOPAusgFD8x8IRvqt26LdDBa3Jvao=
|
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/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 h1:6ks0o/A+b0ne7RzEqRZK5f4Gboz2CfG+mVliciy6+qA=
|
||||||
gioui.org/shader v1.0.8/go.mod h1:mWdiME581d/kV7/iEhLmUgUK5iZ09XR5XpduXzbePVM=
|
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/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 h1:vckeWVESWp6Qog7UZSARNqfu/cZqvki8zsuj3piCMx4=
|
||||||
github.com/OpenPeeDeeP/depguard/v2 v2.2.1/go.mod h1:q4DKzC4UcVaAvcfd41CZh0PWpGgzrVxUYBlgKNGquUo=
|
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 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0=
|
||||||
github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=
|
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=
|
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"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"math/big"
|
"math/big"
|
||||||
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -16,6 +17,7 @@ const (
|
|||||||
)
|
)
|
||||||
|
|
||||||
var ErrImpossibleProfile = errors.New("impossible password profile")
|
var ErrImpossibleProfile = errors.New("impossible password profile")
|
||||||
|
var ErrUnknownProfile = errors.New("unknown password profile")
|
||||||
|
|
||||||
type Profile struct {
|
type Profile struct {
|
||||||
Name string
|
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) {
|
func Generate(profile Profile) (string, error) {
|
||||||
if err := validateProfile(profile); err != nil {
|
if err := validateProfile(profile); err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
|
|||||||
+40
-11
@@ -1,6 +1,8 @@
|
|||||||
package passwords
|
package passwords
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
@@ -9,17 +11,17 @@ func TestGenerateRespectsProfileRequirements(t *testing.T) {
|
|||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
profile := Profile{
|
profile := Profile{
|
||||||
Name: "strong",
|
Name: "strong",
|
||||||
Length: 24,
|
Length: 24,
|
||||||
Lowercase: true,
|
Lowercase: true,
|
||||||
Uppercase: true,
|
Uppercase: true,
|
||||||
Digits: true,
|
Digits: true,
|
||||||
Symbols: true,
|
Symbols: true,
|
||||||
MinLowercase: 2,
|
MinLowercase: 2,
|
||||||
MinUppercase: 2,
|
MinUppercase: 2,
|
||||||
MinDigits: 2,
|
MinDigits: 2,
|
||||||
MinSymbols: 2,
|
MinSymbols: 2,
|
||||||
ExcludeSimilar: true,
|
ExcludeSimilar: true,
|
||||||
}
|
}
|
||||||
|
|
||||||
password, err := Generate(profile)
|
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 {
|
func countFromSet(password, chars string) int {
|
||||||
count := 0
|
count := 0
|
||||||
for _, r := range password {
|
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 ListGroups(ListGroupsRequest) returns (ListGroupsResponse);
|
||||||
rpc CreateGroup(CreateGroupRequest) returns (CreateGroupResponse);
|
rpc CreateGroup(CreateGroupRequest) returns (CreateGroupResponse);
|
||||||
rpc RenameGroup(RenameGroupRequest) returns (RenameGroupResponse);
|
rpc RenameGroup(RenameGroupRequest) returns (RenameGroupResponse);
|
||||||
|
rpc DeleteGroup(DeleteGroupRequest) returns (DeleteGroupResponse);
|
||||||
rpc UpsertEntry(UpsertEntryRequest) returns (UpsertEntryResponse);
|
rpc UpsertEntry(UpsertEntryRequest) returns (UpsertEntryResponse);
|
||||||
rpc DeleteEntry(DeleteEntryRequest) returns (DeleteEntryResponse);
|
rpc DeleteEntry(DeleteEntryRequest) returns (DeleteEntryResponse);
|
||||||
rpc RestoreEntry(RestoreEntryRequest) returns (RestoreEntryResponse);
|
rpc RestoreEntry(RestoreEntryRequest) returns (RestoreEntryResponse);
|
||||||
@@ -67,7 +68,10 @@ message LockVaultRequest {}
|
|||||||
|
|
||||||
message LockVaultResponse {}
|
message LockVaultResponse {}
|
||||||
|
|
||||||
message UnlockVaultRequest {}
|
message UnlockVaultRequest {
|
||||||
|
string password = 1;
|
||||||
|
bytes key_file_data = 2;
|
||||||
|
}
|
||||||
|
|
||||||
message UnlockVaultResponse {}
|
message UnlockVaultResponse {}
|
||||||
|
|
||||||
@@ -85,6 +89,7 @@ message Entry {
|
|||||||
string notes = 6;
|
string notes = 6;
|
||||||
repeated string tags = 7;
|
repeated string tags = 7;
|
||||||
repeated string path = 8;
|
repeated string path = 8;
|
||||||
|
map<string, string> fields = 9;
|
||||||
}
|
}
|
||||||
|
|
||||||
message ListEntriesResponse {
|
message ListEntriesResponse {
|
||||||
@@ -113,6 +118,12 @@ message RenameGroupRequest {
|
|||||||
|
|
||||||
message RenameGroupResponse {}
|
message RenameGroupResponse {}
|
||||||
|
|
||||||
|
message DeleteGroupRequest {
|
||||||
|
repeated string path = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
message DeleteGroupResponse {}
|
||||||
|
|
||||||
message UpsertEntryRequest {
|
message UpsertEntryRequest {
|
||||||
Entry entry = 1;
|
Entry entry = 1;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ const (
|
|||||||
VaultService_ListGroups_FullMethodName = "/keepassgo.v1.VaultService/ListGroups"
|
VaultService_ListGroups_FullMethodName = "/keepassgo.v1.VaultService/ListGroups"
|
||||||
VaultService_CreateGroup_FullMethodName = "/keepassgo.v1.VaultService/CreateGroup"
|
VaultService_CreateGroup_FullMethodName = "/keepassgo.v1.VaultService/CreateGroup"
|
||||||
VaultService_RenameGroup_FullMethodName = "/keepassgo.v1.VaultService/RenameGroup"
|
VaultService_RenameGroup_FullMethodName = "/keepassgo.v1.VaultService/RenameGroup"
|
||||||
|
VaultService_DeleteGroup_FullMethodName = "/keepassgo.v1.VaultService/DeleteGroup"
|
||||||
VaultService_UpsertEntry_FullMethodName = "/keepassgo.v1.VaultService/UpsertEntry"
|
VaultService_UpsertEntry_FullMethodName = "/keepassgo.v1.VaultService/UpsertEntry"
|
||||||
VaultService_DeleteEntry_FullMethodName = "/keepassgo.v1.VaultService/DeleteEntry"
|
VaultService_DeleteEntry_FullMethodName = "/keepassgo.v1.VaultService/DeleteEntry"
|
||||||
VaultService_RestoreEntry_FullMethodName = "/keepassgo.v1.VaultService/RestoreEntry"
|
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)
|
ListGroups(ctx context.Context, in *ListGroupsRequest, opts ...grpc.CallOption) (*ListGroupsResponse, error)
|
||||||
CreateGroup(ctx context.Context, in *CreateGroupRequest, opts ...grpc.CallOption) (*CreateGroupResponse, error)
|
CreateGroup(ctx context.Context, in *CreateGroupRequest, opts ...grpc.CallOption) (*CreateGroupResponse, error)
|
||||||
RenameGroup(ctx context.Context, in *RenameGroupRequest, opts ...grpc.CallOption) (*RenameGroupResponse, 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)
|
UpsertEntry(ctx context.Context, in *UpsertEntryRequest, opts ...grpc.CallOption) (*UpsertEntryResponse, error)
|
||||||
DeleteEntry(ctx context.Context, in *DeleteEntryRequest, opts ...grpc.CallOption) (*DeleteEntryResponse, error)
|
DeleteEntry(ctx context.Context, in *DeleteEntryRequest, opts ...grpc.CallOption) (*DeleteEntryResponse, error)
|
||||||
RestoreEntry(ctx context.Context, in *RestoreEntryRequest, opts ...grpc.CallOption) (*RestoreEntryResponse, 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
|
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) {
|
func (c *vaultServiceClient) UpsertEntry(ctx context.Context, in *UpsertEntryRequest, opts ...grpc.CallOption) (*UpsertEntryResponse, error) {
|
||||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||||
out := new(UpsertEntryResponse)
|
out := new(UpsertEntryResponse)
|
||||||
@@ -349,6 +361,7 @@ type VaultServiceServer interface {
|
|||||||
ListGroups(context.Context, *ListGroupsRequest) (*ListGroupsResponse, error)
|
ListGroups(context.Context, *ListGroupsRequest) (*ListGroupsResponse, error)
|
||||||
CreateGroup(context.Context, *CreateGroupRequest) (*CreateGroupResponse, error)
|
CreateGroup(context.Context, *CreateGroupRequest) (*CreateGroupResponse, error)
|
||||||
RenameGroup(context.Context, *RenameGroupRequest) (*RenameGroupResponse, error)
|
RenameGroup(context.Context, *RenameGroupRequest) (*RenameGroupResponse, error)
|
||||||
|
DeleteGroup(context.Context, *DeleteGroupRequest) (*DeleteGroupResponse, error)
|
||||||
UpsertEntry(context.Context, *UpsertEntryRequest) (*UpsertEntryResponse, error)
|
UpsertEntry(context.Context, *UpsertEntryRequest) (*UpsertEntryResponse, error)
|
||||||
DeleteEntry(context.Context, *DeleteEntryRequest) (*DeleteEntryResponse, error)
|
DeleteEntry(context.Context, *DeleteEntryRequest) (*DeleteEntryResponse, error)
|
||||||
RestoreEntry(context.Context, *RestoreEntryRequest) (*RestoreEntryResponse, 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) {
|
func (UnimplementedVaultServiceServer) RenameGroup(context.Context, *RenameGroupRequest) (*RenameGroupResponse, error) {
|
||||||
return nil, status.Errorf(codes.Unimplemented, "method RenameGroup not implemented")
|
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) {
|
func (UnimplementedVaultServiceServer) UpsertEntry(context.Context, *UpsertEntryRequest) (*UpsertEntryResponse, error) {
|
||||||
return nil, status.Errorf(codes.Unimplemented, "method UpsertEntry not implemented")
|
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)
|
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) {
|
func _VaultService_UpsertEntry_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||||
in := new(UpsertEntryRequest)
|
in := new(UpsertEntryRequest)
|
||||||
if err := dec(in); err != nil {
|
if err := dec(in); err != nil {
|
||||||
@@ -967,6 +1001,10 @@ var VaultService_ServiceDesc = grpc.ServiceDesc{
|
|||||||
MethodName: "RenameGroup",
|
MethodName: "RenameGroup",
|
||||||
Handler: _VaultService_RenameGroup_Handler,
|
Handler: _VaultService_RenameGroup_Handler,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
MethodName: "DeleteGroup",
|
||||||
|
Handler: _VaultService_DeleteGroup_Handler,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
MethodName: "UpsertEntry",
|
MethodName: "UpsertEntry",
|
||||||
Handler: _VaultService_UpsertEntry_Handler,
|
Handler: _VaultService_UpsertEntry_Handler,
|
||||||
|
|||||||
+720
-13
@@ -5,6 +5,10 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"reflect"
|
||||||
|
"slices"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"git.julianfamily.org/keepassgo/vault"
|
"git.julianfamily.org/keepassgo/vault"
|
||||||
"git.julianfamily.org/keepassgo/webdav"
|
"git.julianfamily.org/keepassgo/webdav"
|
||||||
@@ -20,6 +24,7 @@ type Manager struct {
|
|||||||
config *vault.KDBXConfig
|
config *vault.KDBXConfig
|
||||||
path string
|
path string
|
||||||
key vault.MasterKey
|
key vault.MasterKey
|
||||||
|
vaultRoot string
|
||||||
locked bool
|
locked bool
|
||||||
encoded []byte
|
encoded []byte
|
||||||
remoteClient *webdav.Client
|
remoteClient *webdav.Client
|
||||||
@@ -28,6 +33,8 @@ type Manager struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (m *Manager) Create(model vault.Model, key vault.MasterKey) error {
|
func (m *Manager) Create(model vault.Model, key vault.MasterKey) error {
|
||||||
|
root := detectSingleVaultRoot(model)
|
||||||
|
model = normalizeUnderRoot(model, root)
|
||||||
var encoded bytes.Buffer
|
var encoded bytes.Buffer
|
||||||
if err := vault.SaveKDBXWithConfigAndKey(&encoded, model, key, m.config); err != nil {
|
if err := vault.SaveKDBXWithConfigAndKey(&encoded, model, key, m.config); err != nil {
|
||||||
return fmt.Errorf("encode new vault: %w", err)
|
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.model = model
|
||||||
m.key = key
|
m.key = key
|
||||||
|
m.vaultRoot = root
|
||||||
m.encoded = encoded.Bytes()
|
m.encoded = encoded.Bytes()
|
||||||
m.locked = false
|
m.locked = false
|
||||||
return nil
|
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 {
|
func (m *Manager) Open(path string, key vault.MasterKey) error {
|
||||||
content, err := os.ReadFile(path)
|
content, err := os.ReadFile(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -55,6 +75,7 @@ func (m *Manager) Open(path string, key vault.MasterKey) error {
|
|||||||
m.config = config
|
m.config = config
|
||||||
m.path = path
|
m.path = path
|
||||||
m.key = key
|
m.key = key
|
||||||
|
m.vaultRoot = detectSingleVaultRoot(model)
|
||||||
m.encoded = content
|
m.encoded = content
|
||||||
m.locked = false
|
m.locked = false
|
||||||
return nil
|
return nil
|
||||||
@@ -86,6 +107,7 @@ func (m *Manager) OpenRemote(client webdav.Client, path string, key vault.Master
|
|||||||
m.model = model
|
m.model = model
|
||||||
m.config = config
|
m.config = config
|
||||||
m.key = key
|
m.key = key
|
||||||
|
m.vaultRoot = detectSingleVaultRoot(model)
|
||||||
m.encoded = content
|
m.encoded = content
|
||||||
m.locked = false
|
m.locked = false
|
||||||
m.remoteClient = &client
|
m.remoteClient = &client
|
||||||
@@ -99,21 +121,92 @@ func (m *Manager) SaveRemote() error {
|
|||||||
return ErrNoPath
|
return ErrNoPath
|
||||||
}
|
}
|
||||||
|
|
||||||
var encoded bytes.Buffer
|
encoded, err := m.persistableBytes()
|
||||||
if err := vault.SaveKDBXWithConfigAndKey(&encoded, m.model, m.key, m.config); err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("encode vault: %w", err)
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
version, err := m.remoteClient.Save(m.remotePath, bytes.NewReader(encoded.Bytes()), m.remoteVersion)
|
version, err := m.remoteClient.Save(m.remotePath, bytes.NewReader(encoded), m.remoteVersion)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("save remote %s: %w", m.remotePath, err)
|
return fmt.Errorf("save remote %s: %w", m.remotePath, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
m.encoded = encoded.Bytes()
|
m.encoded = encoded
|
||||||
m.remoteVersion = version
|
m.remoteVersion = version
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 {
|
func (m *Manager) SaveAs(path string) error {
|
||||||
if err := m.saveToPath(path); err != nil {
|
if err := m.saveToPath(path); err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -124,7 +217,12 @@ func (m *Manager) SaveAs(path string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (m *Manager) Replace(model vault.Model) {
|
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
|
m.locked = false
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -142,7 +240,8 @@ func (m *Manager) Lock() error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var encoded bytes.Buffer
|
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)
|
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.model = model
|
||||||
m.config = config
|
m.config = config
|
||||||
m.key = key
|
m.key = key
|
||||||
|
m.vaultRoot = detectSingleVaultRoot(model)
|
||||||
m.locked = false
|
m.locked = false
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *Manager) saveToPath(path string) error {
|
func (m *Manager) ChangeMasterKey(key vault.MasterKey) error {
|
||||||
var encoded bytes.Buffer
|
var (
|
||||||
if err := vault.SaveKDBXWithConfigAndKey(&encoded, m.model, m.key, m.config); err != nil {
|
model vault.Model
|
||||||
return fmt.Errorf("encode vault: %w", err)
|
config *vault.KDBXConfig
|
||||||
|
err error
|
||||||
|
)
|
||||||
|
|
||||||
|
if m.locked {
|
||||||
|
model, config, err = vault.LoadKDBXWithConfig(bytes.NewReader(m.encoded), m.key)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("decode locked vault: %w", err)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
model = m.model
|
||||||
|
config = m.config
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := os.WriteFile(path, encoded.Bytes(), 0o600); err != nil {
|
var encoded bytes.Buffer
|
||||||
|
if err := vault.SaveKDBXWithConfigAndKey(&encoded, model, key, config); err != nil {
|
||||||
|
return fmt.Errorf("encode vault with updated master key: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
m.key = key
|
||||||
|
m.config = config
|
||||||
|
m.encoded = encoded.Bytes()
|
||||||
|
if !m.locked {
|
||||||
|
m.model = model
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) saveToPath(path string) error {
|
||||||
|
encoded, err := m.persistableBytes()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := os.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)
|
return fmt.Errorf("write %s: %w", path, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
m.encoded = encoded.Bytes()
|
m.encoded = encoded
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m *Manager) persistableBytes() ([]byte, error) {
|
||||||
|
if m.locked {
|
||||||
|
return append([]byte(nil), m.encoded...), nil
|
||||||
|
}
|
||||||
|
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) {
|
func TestSaveWithoutPathFails(t *testing.T) {
|
||||||
t.Parallel()
|
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) {
|
func TestSavePreservesOpenedKDBXSecuritySettings(t *testing.T) {
|
||||||
t.Parallel()
|
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)
|
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 (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
|
"slices"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"git.julianfamily.org/keepassgo/appstate"
|
"gioui.org/widget"
|
||||||
"git.julianfamily.org/keepassgo/clipboard"
|
"git.julianfamily.org/keepassgo/clipboard"
|
||||||
"git.julianfamily.org/keepassgo/passwords"
|
"git.julianfamily.org/keepassgo/passwords"
|
||||||
"git.julianfamily.org/keepassgo/vault"
|
"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() {
|
func (u *ui) loadSelectedEntryIntoEditor() {
|
||||||
|
u.resetPasswordPeek()
|
||||||
|
u.selectedHistoryIndex = -1
|
||||||
|
u.historyIndex.SetText("")
|
||||||
|
|
||||||
item, ok := u.selectedEntry()
|
item, ok := u.selectedEntry()
|
||||||
if !ok {
|
if !ok {
|
||||||
u.entryID.SetText("")
|
u.entryID.SetText("")
|
||||||
@@ -22,8 +53,9 @@ func (u *ui) loadSelectedEntryIntoEditor() {
|
|||||||
u.entryURL.SetText("")
|
u.entryURL.SetText("")
|
||||||
u.entryNotes.SetText("")
|
u.entryNotes.SetText("")
|
||||||
u.entryTags.SetText("")
|
u.entryTags.SetText("")
|
||||||
u.entryPath.SetText(strings.Join(u.currentPath, " / "))
|
u.entryPath.SetText(strings.Join(u.displayPath(), " / "))
|
||||||
u.entryFields.SetText("")
|
u.entryFields.SetText("")
|
||||||
|
u.setCustomFieldRows(nil)
|
||||||
u.attachmentName.SetText("")
|
u.attachmentName.SetText("")
|
||||||
u.attachmentPath.SetText("")
|
u.attachmentPath.SetText("")
|
||||||
u.exportAttachmentPath.SetText("")
|
u.exportAttachmentPath.SetText("")
|
||||||
@@ -37,13 +69,103 @@ func (u *ui) loadSelectedEntryIntoEditor() {
|
|||||||
u.entryURL.SetText(item.URL)
|
u.entryURL.SetText(item.URL)
|
||||||
u.entryNotes.SetText(item.Notes)
|
u.entryNotes.SetText(item.Notes)
|
||||||
u.entryTags.SetText(strings.Join(item.Tags, ", "))
|
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.entryFields.SetText(marshalFields(item.Fields))
|
||||||
|
u.setCustomFieldRows(item.Fields)
|
||||||
u.attachmentName.SetText("")
|
u.attachmentName.SetText("")
|
||||||
u.attachmentPath.SetText("")
|
u.attachmentPath.SetText("")
|
||||||
u.exportAttachmentPath.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 {
|
func (u *ui) saveEntryAction() error {
|
||||||
entry, err := u.editorEntry()
|
entry, err := u.editorEntry()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -52,6 +174,7 @@ func (u *ui) saveEntryAction() error {
|
|||||||
if err := u.state.UpsertEntry(entry); err != nil {
|
if err := u.state.UpsertEntry(entry); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
u.editingEntry = false
|
||||||
u.filter()
|
u.filter()
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -105,6 +228,7 @@ func (u *ui) saveTemplateAction() error {
|
|||||||
if err := u.state.UpsertTemplate(entry); err != nil {
|
if err := u.state.UpsertTemplate(entry); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
u.editingEntry = false
|
||||||
u.filter()
|
u.filter()
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -123,32 +247,25 @@ func (u *ui) deleteSelectedTemplateAction() error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (u *ui) createGroupAction() error {
|
func (u *ui) createGroupAction() error {
|
||||||
|
u.clearDeleteGroupConfirmation()
|
||||||
return u.state.CreateGroup(strings.TrimSpace(u.groupName.Text()))
|
return u.state.CreateGroup(strings.TrimSpace(u.groupName.Text()))
|
||||||
}
|
}
|
||||||
|
|
||||||
func (u *ui) renameGroupAction() error {
|
func (u *ui) renameGroupAction() error {
|
||||||
|
u.clearDeleteGroupConfirmation()
|
||||||
return u.state.RenameCurrentGroup(strings.TrimSpace(u.groupName.Text()))
|
return u.state.RenameCurrentGroup(strings.TrimSpace(u.groupName.Text()))
|
||||||
}
|
}
|
||||||
|
|
||||||
func (u *ui) deleteCurrentGroupAction() error {
|
func (u *ui) deleteCurrentGroupAction() error {
|
||||||
session, ok := u.state.Session.(appstate.MutableSession)
|
if !u.deleteGroupPendingConfirmation() {
|
||||||
if !ok {
|
return fmt.Errorf("confirm deleting the empty group first")
|
||||||
return fmt.Errorf("session is not mutable")
|
|
||||||
}
|
}
|
||||||
|
if err := u.state.DeleteCurrentGroup(); err != nil {
|
||||||
model, err := session.Current()
|
|
||||||
if err != nil {
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if err := model.DeleteGroup(u.currentPath); err != nil {
|
u.clearDeleteGroupConfirmation()
|
||||||
return err
|
u.currentPath = append([]string(nil), u.state.CurrentPath...)
|
||||||
}
|
u.syncedPath = append([]string(nil), u.state.CurrentPath...)
|
||||||
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.filter()
|
u.filter()
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -171,16 +288,10 @@ func (u *ui) instantiateSelectedTemplateAction() error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (u *ui) addAttachmentAction() error {
|
func (u *ui) addAttachmentAction() error {
|
||||||
content, err := os.ReadFile(strings.TrimSpace(u.attachmentPath.Text()))
|
name, content, err := u.attachmentInput()
|
||||||
if err != nil {
|
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 {
|
if err := u.state.AddAttachmentToSelectedEntry(name, content); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -190,6 +301,20 @@ func (u *ui) addAttachmentAction() error {
|
|||||||
return nil
|
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 {
|
func (u *ui) exportAttachmentAction() error {
|
||||||
item, ok := u.selectedEntry()
|
item, ok := u.selectedEntry()
|
||||||
if !ok {
|
if !ok {
|
||||||
@@ -202,7 +327,11 @@ func (u *ui) exportAttachmentAction() error {
|
|||||||
return fmt.Errorf("attachment not found")
|
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 fmt.Errorf("write attachment export: %w", err)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
@@ -223,9 +352,9 @@ func (u *ui) removeAttachmentAction() error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (u *ui) restoreSelectedHistoryAction() error {
|
func (u *ui) restoreSelectedHistoryAction() error {
|
||||||
index, err := strconv.Atoi(strings.TrimSpace(u.historyIndex.Text()))
|
index, err := u.selectedHistoryVersionIndex()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("invalid history index: %w", err)
|
return err
|
||||||
}
|
}
|
||||||
if err := u.state.RestoreSelectedEntryVersion(index); err != nil {
|
if err := u.state.RestoreSelectedEntryVersion(index); err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -235,21 +364,35 @@ func (u *ui) restoreSelectedHistoryAction() error {
|
|||||||
return nil
|
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 {
|
func (u *ui) copySelectedFieldAction(target clipboard.Target) error {
|
||||||
model, err := u.state.Session.Current()
|
model, err := u.state.Session.Current()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
service := clipboard.Service{}
|
service := clipboard.Service{Writer: u.clipboardWriter}
|
||||||
return service.Copy(model, u.state.SelectedEntryID, target)
|
return service.Copy(model, u.state.SelectedEntryID, target)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (u *ui) generatePasswordAction() error {
|
func (u *ui) generatePasswordAction() error {
|
||||||
profiles := passwords.DefaultProfiles()
|
profile, err := passwords.LookupDefaultProfile(u.passwordProfile.Text())
|
||||||
profile, ok := profiles[strings.TrimSpace(u.passwordProfile.Text())]
|
if err != nil {
|
||||||
if !ok {
|
return err
|
||||||
return fmt.Errorf("unknown password profile")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
password, err := passwords.Generate(profile)
|
password, err := passwords.Generate(profile)
|
||||||
@@ -262,7 +405,10 @@ func (u *ui) generatePasswordAction() error {
|
|||||||
|
|
||||||
func (u *ui) editorEntry() (vault.Entry, error) {
|
func (u *ui) editorEntry() (vault.Entry, error) {
|
||||||
path := parsePath(u.entryPath.Text())
|
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 {
|
if err != nil {
|
||||||
return vault.Entry{}, err
|
return vault.Entry{}, err
|
||||||
}
|
}
|
||||||
|
|||||||
+525
-73
@@ -1,131 +1,422 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
|
"image/color"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"git.julianfamily.org/keepassgo/appstate"
|
|
||||||
"gioui.org/layout"
|
"gioui.org/layout"
|
||||||
"gioui.org/unit"
|
"gioui.org/unit"
|
||||||
"gioui.org/widget"
|
"gioui.org/widget"
|
||||||
"gioui.org/widget/material"
|
"gioui.org/widget/material"
|
||||||
|
"git.julianfamily.org/keepassgo/appstate"
|
||||||
)
|
)
|
||||||
|
|
||||||
func (u *ui) lifecycleControls(gtx layout.Context) layout.Dimensions {
|
func (u *ui) lifecycleControls(gtx layout.Context) layout.Dimensions {
|
||||||
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
|
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
|
||||||
layout.Rigid(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 {
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
return layout.Flex{Spacing: layout.SpaceStart}.Layout(gtx,
|
return layout.Flex{Spacing: layout.SpaceStart}.Layout(gtx,
|
||||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions { return tonedButton(gtx, u.theme, &u.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(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(func(gtx layout.Context) layout.Dimensions {
|
||||||
layout.Rigid(layout.Spacer{Width: unit.Dp(6)}.Layout),
|
return tonedButton(gtx, u.theme, &u.showRemoteLifecycle, "Remote Vault")
|
||||||
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(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 {
|
func (u *ui) groupControls(gtx layout.Context) layout.Dimensions {
|
||||||
if u.state.Section == appstate.SectionRecycleBin {
|
if u.state.Section != appstate.SectionEntries {
|
||||||
return layout.Dimensions{}
|
return layout.Dimensions{}
|
||||||
}
|
}
|
||||||
|
deletable, deleteReason := u.currentGroupDeletionState()
|
||||||
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
|
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
|
||||||
layout.Rigid(labeledEditor(u.theme, "Group Name", &u.groupName, false)),
|
layout.Rigid(labeledEditor(u.theme, "Group Name", &u.groupName, false)),
|
||||||
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
|
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
|
||||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
return layout.Flex{Spacing: layout.SpaceStart}.Layout(gtx,
|
return layout.Flex{Spacing: layout.SpaceStart}.Layout(gtx,
|
||||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions { return tonedButton(gtx, u.theme, &u.createGroup, "Create Group") }),
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
|
return tonedButton(gtx, u.theme, &u.createGroup, "Create Group")
|
||||||
|
}),
|
||||||
|
layout.Rigid(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(layout.Spacer{Width: unit.Dp(6)}.Layout),
|
||||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions { return tonedButton(gtx, u.theme, &u.renameGroup, "Rename Group") }),
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
layout.Rigid(layout.Spacer{Width: unit.Dp(6)}.Layout),
|
return tonedButton(gtx, u.theme, &u.cancelDeleteGroup, "Cancel")
|
||||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions { return tonedButton(gtx, u.theme, &u.deleteGroup, "Delete Group") }),
|
}),
|
||||||
)
|
)
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 {
|
func (u *ui) entryEditorPanel(gtx layout.Context) layout.Dimensions {
|
||||||
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
|
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(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(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(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(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(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(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(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(layout.Spacer{Height: unit.Dp(6)}.Layout),
|
||||||
layout.Rigid(labeledEditor(u.theme, "Notes", &u.entryNotes, false)),
|
layout.Rigid(u.customFieldEditorPanel),
|
||||||
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
|
|
||||||
layout.Rigid(labeledEditor(u.theme, "Custom Fields (key=value)", &u.entryFields, false)),
|
|
||||||
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
|
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(layout.Spacer{Height: unit.Dp(6)}.Layout),
|
||||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
switch u.state.Section {
|
return layout.Flex{Spacing: layout.SpaceStart}.Layout(gtx,
|
||||||
case appstate.SectionTemplates:
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
return layout.Flex{Spacing: layout.SpaceStart}.Layout(gtx,
|
return tonedButton(gtx, u.theme, &u.generatePassword, "Generate Password")
|
||||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions { return tonedButton(gtx, u.theme, &u.saveTemplate, "Save Template") }),
|
}),
|
||||||
layout.Rigid(layout.Spacer{Width: unit.Dp(6)}.Layout),
|
layout.Rigid(layout.Spacer{Width: unit.Dp(6)}.Layout),
|
||||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions { return tonedButton(gtx, u.theme, &u.deleteTemplate, "Delete Template") }),
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
layout.Rigid(layout.Spacer{Width: unit.Dp(6)}.Layout),
|
return tonedButton(gtx, u.theme, &u.cancelEdit, "Cancel")
|
||||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions { return tonedButton(gtx, u.theme, &u.instantiateTemplate, "Instantiate") }),
|
}),
|
||||||
)
|
layout.Rigid(layout.Spacer{Width: unit.Dp(6)}.Layout),
|
||||||
case appstate.SectionRecycleBin:
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
return tonedButton(gtx, u.theme, &u.restoreEntry, "Restore Entry")
|
if u.state.Section == appstate.SectionTemplates {
|
||||||
default:
|
return tonedButton(gtx, u.theme, &u.saveTemplate, "Save Template")
|
||||||
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") }),
|
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{Height: unit.Dp(4)}.Layout),
|
||||||
layout.Rigid(layout.Spacer{Width: unit.Dp(6)}.Layout),
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions { return tonedButton(gtx, u.theme, &u.generatePassword, "Generate Password") }),
|
lbl := material.Label(u.theme, unit.Sp(11), "Generate Password only updates the form. Nothing is persisted until you save.")
|
||||||
layout.Rigid(layout.Spacer{Width: unit.Dp(6)}.Layout),
|
lbl.Color = mutedColor
|
||||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions { return tonedButton(gtx, u.theme, &u.restoreHistory, "Restore History") }),
|
return lbl.Layout(gtx)
|
||||||
)
|
|
||||||
}
|
|
||||||
}),
|
}),
|
||||||
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
|
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
|
||||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
return layout.Flex{Spacing: layout.SpaceStart}.Layout(gtx,
|
return layout.Flex{Spacing: layout.SpaceStart}.Layout(gtx,
|
||||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions { return tonedButton(gtx, u.theme, &u.copyUser, "Copy User") }),
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions { return tonedButton(gtx, u.theme, &u.copyUser, "Copy User") }),
|
||||||
layout.Rigid(layout.Spacer{Width: unit.Dp(6)}.Layout),
|
layout.Rigid(layout.Spacer{Width: unit.Dp(6)}.Layout),
|
||||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions { return tonedButton(gtx, u.theme, &u.copyPass, "Copy Password") }),
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
|
return tonedButton(gtx, u.theme, &u.copyPass, "Copy Password")
|
||||||
|
}),
|
||||||
layout.Rigid(layout.Spacer{Width: unit.Dp(6)}.Layout),
|
layout.Rigid(layout.Spacer{Width: unit.Dp(6)}.Layout),
|
||||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions { return tonedButton(gtx, u.theme, &u.copyURL, "Copy URL") }),
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions { return tonedButton(gtx, u.theme, &u.copyURL, "Copy URL") }),
|
||||||
)
|
)
|
||||||
}),
|
}),
|
||||||
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
|
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(labeledEditor(u.theme, "Attachment Name", &u.attachmentName, false)),
|
||||||
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
|
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
|
||||||
layout.Rigid(labeledEditor(u.theme, "Attachment Path", &u.attachmentPath, false)),
|
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(layout.Spacer{Height: unit.Dp(6)}.Layout),
|
||||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
return layout.Flex{Spacing: layout.SpaceStart}.Layout(gtx,
|
return layout.Flex{Spacing: layout.SpaceStart}.Layout(gtx,
|
||||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions { return tonedButton(gtx, u.theme, &u.addAttachment, "Add Attachment") }),
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
|
return tonedButton(gtx, u.theme, &u.addAttachment, "Add Attachment")
|
||||||
|
}),
|
||||||
layout.Rigid(layout.Spacer{Width: unit.Dp(6)}.Layout),
|
layout.Rigid(layout.Spacer{Width: unit.Dp(6)}.Layout),
|
||||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions { return tonedButton(gtx, u.theme, &u.removeAttachment, "Remove Attachment") }),
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
|
return tonedButton(gtx, u.theme, &u.replaceAttachment, "Replace Attachment")
|
||||||
|
}),
|
||||||
layout.Rigid(layout.Spacer{Width: unit.Dp(6)}.Layout),
|
layout.Rigid(layout.Spacer{Width: unit.Dp(6)}.Layout),
|
||||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions { return tonedButton(gtx, u.theme, &u.exportAttachment, "Export Attachment") }),
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
|
return tonedButton(gtx, u.theme, &u.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 {
|
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 func(gtx layout.Context) layout.Dimensions {
|
||||||
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
|
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
|
||||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
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)
|
return lbl.Layout(gtx)
|
||||||
}),
|
}),
|
||||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
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
|
mask := editor.Mask
|
||||||
if sensitive {
|
if sensitive {
|
||||||
editor.Mask = '•'
|
editor.Mask = '•'
|
||||||
}
|
}
|
||||||
defer func() { editor.Mask = 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)
|
ed := material.Editor(th, editor, label)
|
||||||
return layout.UniformInset(unit.Dp(8)).Layout(gtx, ed.Layout)
|
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)
|
event.Op(gtx.Ops, u)
|
||||||
for {
|
for {
|
||||||
ev, ok := gtx.Event(
|
ev, ok := gtx.Event(
|
||||||
key.Filter{Focus: u, Name: "F", Required: key.ModShortcut},
|
key.Filter{Name: "F", Required: key.ModShortcut},
|
||||||
key.Filter{Focus: u, Name: "S", Required: key.ModShortcut},
|
key.Filter{Name: "S", Required: key.ModShortcut},
|
||||||
key.Filter{Focus: u, Name: "L", Required: key.ModShortcut},
|
key.Filter{Name: "L", Required: key.ModShortcut},
|
||||||
key.Filter{Focus: u, Name: "N", Required: key.ModShortcut},
|
key.Filter{Name: "N", Required: key.ModShortcut},
|
||||||
key.Filter{Focus: u, Name: "U", Required: key.ModShortcut},
|
key.Filter{Name: "U", Required: key.ModShortcut},
|
||||||
key.Filter{Focus: u, Name: "P", Required: key.ModShortcut},
|
key.Filter{Name: "P", Required: key.ModShortcut},
|
||||||
key.Filter{Focus: u, Name: "O", 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 {
|
if !ok {
|
||||||
break
|
break
|
||||||
@@ -41,37 +47,24 @@ func (u *ui) processShortcuts(gtx layout.Context) {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
switch ke.Name {
|
u.handleKeyPress(ke.Name, ke.Modifiers)
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (u *ui) performShortcut(name string) error {
|
func (u *ui) performShortcut(name string) error {
|
||||||
switch name {
|
switch name {
|
||||||
case shortcutSearch:
|
case shortcutSearch:
|
||||||
|
u.keyboardFocus = focusSearch
|
||||||
return nil
|
return nil
|
||||||
case shortcutSave:
|
case shortcutSave:
|
||||||
return u.saveAction()
|
return u.saveAction()
|
||||||
case shortcutLock:
|
case shortcutLock:
|
||||||
return u.lockAction()
|
return u.lockAction()
|
||||||
case shortcutNewEntry:
|
case shortcutNewEntry:
|
||||||
u.state.SelectedEntryID = ""
|
u.state.BeginNewEntry()
|
||||||
u.loadSelectedEntryIntoEditor()
|
u.loadSelectedEntryIntoEditor()
|
||||||
u.entryPath.SetText(strings.Join(u.currentPath, " / "))
|
u.entryPath.SetText(strings.Join(u.state.CurrentPath, " / "))
|
||||||
|
u.keyboardFocus = detailFocusID(detailFieldTitle)
|
||||||
return nil
|
return nil
|
||||||
case shortcutCopyUser:
|
case shortcutCopyUser:
|
||||||
return u.copySelectedFieldAction(clipboard.TargetUsername)
|
return u.copySelectedFieldAction(clipboard.TargetUsername)
|
||||||
|
|||||||
+92
-51
@@ -23,8 +23,8 @@ type KDBXConfig struct {
|
|||||||
var ErrInvalidMasterKey = errors.New("invalid master key")
|
var ErrInvalidMasterKey = errors.New("invalid master key")
|
||||||
|
|
||||||
const (
|
const (
|
||||||
templatesRoot = "Templates"
|
templatesRoot = "Templates"
|
||||||
recycleBinRoot = "Recycle Bin"
|
recycleBinRoot = "Recycle Bin"
|
||||||
keepassGOIDField = "KeePassGO-ID"
|
keepassGOIDField = "KeePassGO-ID"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -46,33 +46,29 @@ func SaveKDBXWithConfigAndKey(wr io.Writer, model Model, key MasterKey, config *
|
|||||||
return err
|
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 {
|
if config != nil && config.Header != nil {
|
||||||
header = cloneHeader(config.Header)
|
db.Header = cloneHeader(config.Header)
|
||||||
|
db.Hashes = gokeepasslib.NewHashes(db.Header)
|
||||||
}
|
}
|
||||||
content := &gokeepasslib.DBContent{
|
if db.Header.IsKdbx4() {
|
||||||
Meta: gokeepasslib.NewMetaData(),
|
|
||||||
Root: &gokeepasslib.RootData{},
|
|
||||||
}
|
|
||||||
if header.IsKdbx4() {
|
|
||||||
if config != nil && config.InnerHeader != nil {
|
if config != nil && config.InnerHeader != nil {
|
||||||
content.InnerHeader = cloneInnerHeader(config.InnerHeader)
|
db.Content.InnerHeader = cloneInnerHeader(config.InnerHeader)
|
||||||
} else {
|
db.Content.InnerHeader.Binaries = nil
|
||||||
content.InnerHeader = &gokeepasslib.InnerHeader{
|
} else if db.Content.InnerHeader == nil {
|
||||||
|
db.Content.InnerHeader = &gokeepasslib.InnerHeader{
|
||||||
InnerRandomStreamID: gokeepasslib.ChaChaStreamID,
|
InnerRandomStreamID: gokeepasslib.ChaChaStreamID,
|
||||||
InnerRandomStreamKey: randomBytes(64),
|
InnerRandomStreamKey: randomBytes(64),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
db.Content.InnerHeader = nil
|
||||||
}
|
}
|
||||||
|
db.Content.Root.Groups = buildGroupTree(db, model)
|
||||||
db := &gokeepasslib.Database{
|
db.Content.Root.DeletedObjects = nil
|
||||||
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)
|
|
||||||
|
|
||||||
if err := db.LockProtectedEntries(); err != nil {
|
if err := db.LockProtectedEntries(); err != nil {
|
||||||
return fmt.Errorf("lock protected entries: %w", err)
|
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) {
|
func appendGroupEntries(model *Model, db *gokeepasslib.Database, group gokeepasslib.Group, path []string) {
|
||||||
path = append(clonePath(path), group.Name)
|
path = append(clonePath(path), group.Name)
|
||||||
|
model.CreateGroup(path[:len(path)-1], group.Name)
|
||||||
|
|
||||||
for _, entry := range group.Entries {
|
for _, entry := range group.Entries {
|
||||||
appendModelEntry(model, Entry{
|
appendModelEntry(model, Entry{
|
||||||
ID: extractEntryID(entry),
|
ID: extractEntryID(entry),
|
||||||
Title: entry.GetTitle(),
|
Title: entry.GetTitle(),
|
||||||
Username: entry.GetContent("UserName"),
|
Username: entry.GetContent("UserName"),
|
||||||
Password: entry.GetPassword(),
|
Password: entry.GetPassword(),
|
||||||
URL: entry.GetContent("URL"),
|
URL: entry.GetContent("URL"),
|
||||||
Notes: entry.GetContent("Notes"),
|
Notes: entry.GetContent("Notes"),
|
||||||
Tags: splitTags(entry.Tags),
|
Tags: splitTags(entry.Tags),
|
||||||
Fields: extractCustomFields(entry),
|
Fields: extractCustomFields(entry),
|
||||||
Attachments: extractAttachments(db, entry),
|
Attachments: extractAttachments(db, entry),
|
||||||
History: extractHistory(db, entry, path),
|
History: extractHistory(db, entry, path),
|
||||||
Path: clonePath(path),
|
Path: clonePath(path),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -207,7 +204,7 @@ func extractHistory(db *gokeepasslib.Database, entry gokeepasslib.Entry, path []
|
|||||||
for _, item := range entry.Histories {
|
for _, item := range entry.Histories {
|
||||||
for _, historical := range item.Entries {
|
for _, historical := range item.Entries {
|
||||||
history = append(history, Entry{
|
history = append(history, Entry{
|
||||||
ID: marshalUUID(historical.UUID),
|
ID: extractEntryID(historical),
|
||||||
Title: historical.GetTitle(),
|
Title: historical.GetTitle(),
|
||||||
Username: historical.GetContent("UserName"),
|
Username: historical.GetContent("UserName"),
|
||||||
Password: historical.GetPassword(),
|
Password: historical.GetPassword(),
|
||||||
@@ -235,7 +232,8 @@ type MasterKey struct {
|
|||||||
KeyFileData []byte
|
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{}}
|
root := &groupNode{children: map[string]*groupNode{}}
|
||||||
for _, entry := range entries {
|
for _, entry := range entries {
|
||||||
node := root
|
node := root
|
||||||
@@ -250,6 +248,18 @@ func buildGroupTree(db *gokeepasslib.Database, entries []Entry) []gokeepasslib.G
|
|||||||
}
|
}
|
||||||
node.entries = append(node.entries, entry)
|
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)
|
groups := marshalGroups(db, root)
|
||||||
if len(groups) > 0 {
|
if len(groups) > 0 {
|
||||||
@@ -261,6 +271,31 @@ func buildGroupTree(db *gokeepasslib.Database, entries []Entry) []gokeepasslib.G
|
|||||||
return []gokeepasslib.Group{group}
|
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) {
|
func LoadKDBXWithKey(r io.Reader, key MasterKey) (Model, error) {
|
||||||
model, _, err := LoadKDBXWithConfig(r, key)
|
model, _, err := LoadKDBXWithConfig(r, key)
|
||||||
return model, err
|
return model, err
|
||||||
@@ -407,7 +442,7 @@ func isInvalidCredentialError(err error) bool {
|
|||||||
|
|
||||||
func marshalGroups(db *gokeepasslib.Database, node *groupNode) []gokeepasslib.Group {
|
func marshalGroups(db *gokeepasslib.Database, node *groupNode) []gokeepasslib.Group {
|
||||||
names := slices.Collect(maps.Keys(node.children))
|
names := slices.Collect(maps.Keys(node.children))
|
||||||
slices.Sort(names)
|
slices.SortFunc(names, compareGroupNames)
|
||||||
|
|
||||||
var groups []gokeepasslib.Group
|
var groups []gokeepasslib.Group
|
||||||
for _, name := range names {
|
for _, name := range names {
|
||||||
@@ -422,6 +457,29 @@ func marshalGroups(db *gokeepasslib.Database, node *groupNode) []gokeepasslib.Gr
|
|||||||
return groups
|
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 {
|
func marshalEntries(db *gokeepasslib.Database, entries []Entry) []gokeepasslib.Entry {
|
||||||
slices.SortFunc(entries, func(a, b Entry) int {
|
slices.SortFunc(entries, func(a, b Entry) int {
|
||||||
switch {
|
switch {
|
||||||
@@ -477,23 +535,6 @@ func marshalEntry(db *gokeepasslib.Database, entry Entry) gokeepasslib.Entry {
|
|||||||
return item
|
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 {
|
func uuidForEntryID(id string) gokeepasslib.UUID {
|
||||||
if id != "" {
|
if id != "" {
|
||||||
var uuid gokeepasslib.UUID
|
var uuid gokeepasslib.UUID
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package vault
|
|||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"errors"
|
"errors"
|
||||||
|
"slices"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/tobischo/gokeepasslib/v3"
|
"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 {
|
func mustGroup(name string, children ...any) gokeepasslib.Group {
|
||||||
group := gokeepasslib.NewGroup()
|
group := gokeepasslib.NewGroup()
|
||||||
group.Name = name
|
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 ErrEntryNotFound = errors.New("entry not found")
|
||||||
|
var ErrGroupNotEmpty = errors.New("group is not empty")
|
||||||
|
|
||||||
type Entry struct {
|
type Entry struct {
|
||||||
ID string
|
ID string
|
||||||
Title string
|
Title string
|
||||||
Username string
|
Username string
|
||||||
Password string
|
Password string
|
||||||
URL string
|
URL string
|
||||||
Notes string
|
Notes string
|
||||||
Tags []string
|
Tags []string
|
||||||
Fields map[string]string
|
Fields map[string]string
|
||||||
Attachments map[string][]byte
|
Attachments map[string][]byte
|
||||||
History []Entry
|
History []Entry
|
||||||
Path []string
|
Path []string
|
||||||
}
|
}
|
||||||
|
|
||||||
type SearchResult struct {
|
type SearchResult struct {
|
||||||
@@ -323,12 +324,12 @@ func (m *Model) MoveTemplate(id string, path []string) error {
|
|||||||
func (m *Model) DeleteGroup(path []string) error {
|
func (m *Model) DeleteGroup(path []string) error {
|
||||||
for _, entry := range m.Entries {
|
for _, entry := range m.Entries {
|
||||||
if slices.Equal(entry.Path, path) || hasPathPrefix(entry.Path, path) {
|
if slices.Equal(entry.Path, path) || hasPathPrefix(entry.Path, path) {
|
||||||
return errors.New("group is not empty")
|
return ErrGroupNotEmpty
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for _, entry := range m.Templates {
|
for _, entry := range m.Templates {
|
||||||
if slices.Equal(entry.Path, path) || hasPathPrefix(entry.Path, path) {
|
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