Compare commits

...

41 Commits

Author SHA1 Message Date
joejulian 3b323ea4fd Merge pull request 'Complete browser extension gRPC flow' (#4) from feature/browser-extension-grpc into main
ci / lint-test (push) Successful in 3m49s
ci / build (push) Successful in 6m7s
Reviewed-on: #4
2026-04-12 18:58:28 +00:00
Joe Julian 8117e3e8c1 Move browser bridge gRPC address to flag 2026-04-12 07:38:23 -07:00
Joe Julian 77e92a2368 Simplify browser extension manifest descriptions 2026-04-12 07:03:06 -07:00
Joe Julian 4b8c1de1a6 Remove extension gRPC address setting 2026-04-12 06:59:59 -07:00
Joe Julian af2ce66b78 Remove completed browser extension todo items 2026-04-12 06:52:23 -07:00
Joe Julian a02d4a3b1c Unify browser bridge request normalization 2026-04-12 06:48:12 -07:00
Joe Julian 57870ca4f1 Clean up browser bridge and mutation helpers 2026-04-12 00:02:50 -07:00
Joe Julian dc7dd19543 Fix browser authorization edge cases 2026-04-11 23:56:48 -07:00
Joe Julian d522af7d51 Complete browser extension gRPC flow 2026-04-11 23:45:48 -07:00
Joe Julian 2f2338f6f2 update todo 2026-04-11 17:13:06 -07:00
Joe Julian 12796ef639 Allow scoped tokens to read session status 2026-04-11 16:51:24 -07:00
Joe Julian e16067b345 Fix hidden root navigation and browser fill matching 2026-04-11 11:53:42 -07:00
Joe Julian c8f91b300b Share hidden vault root logic across UI and API 2026-04-11 11:26:00 -07:00
Joe Julian ebb8d4f4ff Hide synthetic vault root beside recycle bin 2026-04-11 11:18:32 -07:00
Joe Julian 83bd1334d0 Fix API token policy action buttons 2026-04-11 11:14:00 -07:00
Joe Julian 675aeebdeb Fix scoped gRPC persistence and autosave behavior 2026-04-11 11:03:05 -07:00
Joe Julian 0de682a3af Sync API mutations into shared session state 2026-04-11 10:26:55 -07:00
Joe Julian 852c115b2a Invalidate UI on approval queue changes 2026-04-11 09:53:23 -07:00
Joe Julian 2ef571c241 Use runtime-dir Unix sockets for local gRPC 2026-04-11 08:26:37 -07:00
Joe Julian c017308aa1 Add browser extension gRPC bridge 2026-04-11 00:52:01 -07:00
joejulian 885d599db1 Merge pull request 'Complete API token gRPC authorization' (#3) from feature/api-token-grpc-authz into main
ci / lint-test (push) Successful in 1m14s
ci / build (push) Successful in 2m39s
Reviewed-on: #3
2026-04-11 07:11:01 +00:00
Joe Julian e757be66d9 Complete API token authz UI flows 2026-04-11 00:03:30 -07:00
Joe Julian bc226647e1 Remove redundant gRPC auth interceptor 2026-04-10 23:48:25 -07:00
Joe Julian 533fb2d550 Audit API token lifecycle actions 2026-04-10 23:40:34 -07:00
Joe Julian 8dfba6e94f Enforce API token authz across gRPC methods 2026-04-10 23:36:56 -07:00
Joe Julian 6cc86bb944 Trim completed TODO items
ci / lint-test (push) Successful in 1m15s
ci / build (push) Successful in 2m30s
2026-04-10 23:25:01 -07:00
joejulian a9c15c2d23 Merge pull request 'Local-first remote sync and cross-platform UI parity' (#2) from feature/local-first-remote-sync into main
ci / lint-test (push) Successful in 1m13s
ci / build (push) Successful in 2m34s
2026-04-11 06:15:41 +00:00
Joe Julian b7d6dbdc97 Keep desktop detail pane visible 2026-04-10 23:09:47 -07:00
Joe Julian 2deca549f5 Fix desktop header menu layout 2026-04-10 23:05:30 -07:00
Joe Julian fe3c07e3dd Unify action menus and collapse empty detail pane 2026-04-10 22:12:50 -07:00
Joe Julian c4f110e0ad Refine compact header menus 2026-04-10 21:48:05 -07:00
Joe Julian 56a0711860 Right-align compact header menus 2026-04-10 19:23:13 -07:00
Joe Julian 54f13d352c Fix compact header overlay ordering 2026-04-10 18:55:02 -07:00
Joe Julian 550d9f362c Add ship-it skill and menu placement logs 2026-04-10 16:08:08 -07:00
Joe Julian ac3478889c Make header action cluster own menus 2026-04-10 15:48:57 -07:00
Joe Julian 44da1e6599 Confirm debug logging is enabled 2026-04-10 15:36:23 -07:00
Joe Julian b59cf8044b Log header bounds to Android logcat 2026-04-10 15:32:35 -07:00
Joe Julian 5838588fc5 Log compact header button bounds 2026-04-10 15:28:33 -07:00
Joe Julian 0e9fd478e5 Align header action cluster with layout.E 2026-04-10 15:04:41 -07:00
Joe Julian 2f1cd7876c Normalize app UI pane packages 2026-04-09 13:20:12 -07:00
Joe Julian ccaee9fa34 Split app UI layout packages 2026-04-09 09:20:57 -07:00
79 changed files with 13291 additions and 6153 deletions
+109
View File
@@ -0,0 +1,109 @@
---
name: keepassgo-ship-it
description: KeePassGO-specific ship workflow. Use when the user says `ship it` in this repository and expects the current work to be committed, the Arch package rebuilt and installed, the Android APK rebuilt and zipped, the ZIP uploaded to Nextcloud, and the rebuilt app launched in the emulator with a controlled demo vault opened.
---
# KeePassGO Ship It
Use this skill only in the KeePassGO repository. This is not a global shorthand.
Use it together with:
- `android-emulator-debug` for emulator and `adb` mechanics
- `keepass-credentials` for Nextcloud credentials
- `public-repo-sanitization` before the commit/push step
## Meaning Of `ship it`
When the user says `ship it`, do all of this unless they narrow the scope:
1. Commit the relevant KeePassGO source changes first.
2. Build and install the Arch package from that committed source.
3. Build the Android APK from that same committed source.
4. Zip the APK.
5. Upload the ZIP to the user's configured Nextcloud DAV destination for this repository.
6. Install the rebuilt APK in the emulator.
7. Launch the rebuilt app in the emulator.
8. Open a controlled demo vault in the emulator.
Do not stop after the commit or after the package build. `ship it` means finish the full loop.
## Required Sequence
### 1. Commit First
- Make sure the worktree state intended for shipping is committed before building.
- If the repo is dirty in unrelated ways, commit only the relevant changes.
- Before the commit or push, run the public-repo sanitization checks.
### 2. Build And Install The Arch Package
From the repo root:
```sh
make archlinux-pkgbuild
cd packaging/archlinux/keepassgo-git
makepkg -si --noconfirm
```
The installed package version must correspond to the committed source, not a dirty worktree.
### 3. Build The APK
Use the repo's known-good local JDK unless the environment already proves otherwise:
```sh
JAVA_HOME=/usr/lib/jvm/java-25-openjdk make apk
```
If that JDK is unavailable on the current host, use the working replacement already established for the machine and say so in the closeout.
### 4. Zip The APK
- Create the ZIP under the globally required temporary secret-safe directory.
- Use a name that includes the commit, for example:
`keepassgo-<shortsha>-apk.zip`
### 5. Upload To Nextcloud
- Get credentials and the DAV endpoint with `keepass-http`, not by asking the user if KeePass likely has them.
- Prefer the established KeePass entry and DAV destination already in use for this repository's shipping workflow.
- Use the globally required temporary secret-safe directory for any temporary curl config or secret material.
- Ensure that directory exists with mode `700`.
- Create secret temp files with mode `600`.
- After upload, zero and unlink the temp secret file. Do not use `rm -f` or `rm -rf`.
### 6. Emulator Install And Launch
- Reuse the existing emulator session if one is already running.
- Install with replacement:
```sh
adb install -r build/keepassgo.apk
```
- Launch KeePassGO and confirm it is focused.
- Treat the emulator as timing-sensitive. If Android shows a transient "Wait" style ANR dialog and the user says the app is otherwise fine, do not misclassify that as an app-logic failure.
### 7. Open A Controlled Demo Vault
- Do not rely on the user's real vault for this step.
- Use a controlled/sanitized demo vault that you can unlock yourself.
- Open it in the emulator before closing out `ship it`.
- Capture a screenshot if needed to verify the app really rendered and opened the vault.
## Closeout Requirements
When reporting back after `ship it`, include:
- the commit that was shipped
- the installed Arch package version
- the APK path
- the uploaded ZIP URL
- confirmation that the emulator app was launched
- confirmation that the controlled demo vault was opened
## Constraints
- Keep this workflow specific to KeePassGO.
- Preserve emulator state; do not kill or reset it unless the user explicitly asks.
- Do not use `rm -rf`.
- Do not use `rm -f`.
+1
View File
@@ -1,6 +1,7 @@
build/
*.apk
/keepassgo
/keepassgo-browser-bridge
android/keepassgo-android.jar
packaging/archlinux/keepassgo-git/*.pkg.tar.zst
packaging/archlinux/keepassgo-git/PKGBUILD
+10 -1
View File
@@ -25,7 +25,7 @@ ifneq ($(strip $(SIGNPASS)),)
GOGIO_SIGN_FLAGS += -signpass $(SIGNPASS)
endif
.PHONY: apk archlinux-pkgbuild
.PHONY: apk archlinux-pkgbuild browser-bridge browser-extension-validate
apk: android/keepassgo-android.jar
@test -x "$(JAVA_HOME)/bin/java" || { echo "JAVA_HOME must point to a working JDK install"; exit 1; }
@test -d "$(ANDROID_SDK_ROOT)" || { echo "ANDROID_SDK_ROOT must point to an Android SDK install"; exit 1; }
@@ -68,3 +68,12 @@ archlinux-pkgbuild: $(ARCH_PKG_TMPL) Makefile
-e 's|@PKGVER@|$(ARCH_PKGVER)|g' \
-e 's|@REPO_DIR@|$(ARCH_REPO_DIR)|g' \
"$(ARCH_PKG_TMPL)" > "$(ARCH_PKGBUILD)"
browser-bridge:
go build ./cmd/keepassgo-browser-bridge
browser-extension-validate:
@command -v xvfb-run >/dev/null 2>&1 || { echo "xvfb-run is required"; exit 1; }
@command -v firefox >/dev/null 2>&1 || { echo "firefox is required"; exit 1; }
@command -v openssl >/dev/null 2>&1 || { echo "openssl is required"; exit 1; }
xvfb-run -a python scripts/validate_browser_extension.py $(if $(BROWSER),--browser $(BROWSER),)
+9
View File
@@ -63,6 +63,7 @@ makepkg -si
The package installs:
- `/usr/bin/keepassgo`
- `/usr/bin/keepassgo-browser-bridge`
- a desktop entry at `/usr/share/applications/keepassgo.desktop`
- application icons under the hicolor theme
@@ -98,3 +99,11 @@ You will need the Android SDK and NDK installed and configured for real device o
Desktop automation is resolved through the secure gRPC API rather than synthetic auto-type.
See [`docs/desktop-automation.md`](./docs/desktop-automation.md).
On desktop, KeePassGO now listens on a Unix socket by default under the user runtime directory.
Set `KEEPASSGO_GRPC_ADDR` or `-grpc-addr` to override it, for example `tcp://127.0.0.1:47777`.
## Browser Extension
Firefox and Chromium browser integration is available through the local gRPC API plus a native messaging bridge.
See [`docs/browser-extension.md`](./docs/browser-extension.md).
+6 -241
View File
@@ -45,6 +45,7 @@ feeling like the same application rather than three related UIs.
These should remain in the main user flow rather than being hidden behind a settings gear.
- Browser extension:
- Local open flow:
make the start screen primarily about opening a vault, not configuring one.
- Local open flow:
@@ -97,6 +98,10 @@ These should remain in the main user flow rather than being hidden behind a sett
keep the split-button pattern, but reduce the visual weight of the sync controls and make advanced sync affordances clearer.
- Synchronize:
avoid layout-shifting success banners and keep noncritical notifications ephemeral.
- Synchronize:
define exact local-versus-remote merge semantics for cases where both sides changed, and make the user-facing action names describe the real behavior instead of ambiguous `push`/`pull` labels if those actions perform two-way reconciliation.
- Synchronize:
choose sync wording and defaults that maximize user comprehension and safety, especially around merge, overwrite, conflict, and retry behavior.
- Phone layout:
continue reducing header and control density so content appears sooner.
- Mobile reliability:
@@ -132,195 +137,6 @@ These are important, but they should likely move behind a dedicated settings gea
- Phone and desktop layouts both present a clear information hierarchy.
- The Android open flow is reliable enough to review and use without ANR during ordinary vault-open operations.
## API Token And gRPC Authorization Parallel Segments
These segments define the work for programmatic access control over gRPC.
They are designed to be independently landable wherever file overlap permits.
The feature is not complete until all segment exit criteria and the global exit criteria are satisfied.
### API Segment A: Token Domain Model
Scope:
- Represent API tokens as first-class vault-backed records.
- Mark token entries explicitly as API credentials rather than generic passwords.
- Store token metadata:
token id,
hashed secret or verifier,
display name,
client name,
created at,
expires at,
disabled state.
- Keep the persisted representation compatible with KDBX entry fields.
Exit criteria:
- A domain type exists for API tokens and round-trips through the persisted vault model.
- Generic entry listing can distinguish API token entries from ordinary secrets.
- Tests cover create, load, save, and parse behavior for API token entries.
- `go test ./...` passes.
### API Segment B: Token Issuance And Rotation
Scope:
- Generate new API tokens for external tools.
- Return the cleartext token only at creation or explicit rotation time.
- Rotate an existing token while preserving its identity and policy linkage.
- Revoke or disable a token without deleting policy history.
Exit criteria:
- Token issuance, rotation, disable, and revoke operations exist in the domain/service layer.
- Cleartext token material is only exposed on creation or rotation paths.
- Tests cover generation, rotation, and disable/revoke semantics.
- `go test ./...` passes.
### API Segment C: Token Expiration
Scope:
- Allow tokens to have optional expiration timestamps.
- Treat expired tokens as unauthenticated.
- Surface expiration in UI and gRPC management views.
- Support non-expiring tokens explicitly.
Exit criteria:
- Expired tokens are rejected by the gRPC authentication path.
- Token expiration can be created, edited, and removed through the service layer.
- Tests cover valid, expired, and non-expiring token behavior.
- `go test ./...` passes.
### API Segment D: Authorization Policy Model
Scope:
- Define an authorization model for token-scoped access.
- Support allow and deny rules over:
folders/groups,
specific entries,
entry fields where needed,
and operation types.
- Keep specific deny rules higher priority than broad allow rules.
- Model “not yet decided” separately from “denied”.
Exit criteria:
- A policy evaluator exists for token, resource, and operation tuples.
- Explicit deny overrides allow.
- Unspecified access is distinguishable from denied access.
- Tests cover allow, deny, inherited group scope, and exact-entry scope behavior.
- `go test ./...` passes.
### API Segment E: gRPC Authentication And Authorization Enforcement
Scope:
- Replace the current single static bearer-token interceptor with token-backed auth.
- Authenticate callers using issued KeePassGO API tokens.
- Authorize every gRPC method against token policy.
- Apply scope checks to lifecycle, list, read, mutation, copy, and password-generation RPCs.
Exit criteria:
- gRPC requests authenticate through stored API tokens rather than one static shared secret.
- Every RPC enforces token-specific authorization before mutating or revealing vault data.
- Unauthorized requests return the correct authz/authn gRPC status.
- Integration tests cover permitted, denied, expired, and revoked token behavior.
- `go test ./...` passes.
### API Segment F: Approval Queue And Pending Access Requests
Scope:
- When a token requests access to a resource that is neither explicitly allowed nor denied:
create a pending approval request.
- Include:
token identity,
client name,
requested operation,
requested group/entry scope,
requested time,
and permanence choice.
- Allow the request to be accepted, denied, or canceled by the user.
Exit criteria:
- Unspecified access creates a pending approval instead of silently denying or allowing.
- Pending approvals are queryable from the application layer.
- Canceling the prompt results in the API request failing without granting access.
- Tests cover pending creation, approval, denial, and cancellation.
- `go test ./...` passes.
### API Segment G: Approval UI
Scope:
- Show a user-facing approval screen/dialog when a pending API request needs a decision.
- Provide actions:
allow once,
deny once,
allow permanently,
deny permanently,
cancel.
- Make the requested scope and operation clear to the user.
- Ensure the dialog appears only for requests not already decided.
Exit criteria:
- A pending request triggers a visible approval surface in the app.
- The user can allow, deny, or cancel from the UI.
- Permanent decisions become persisted policy rules.
- UI tests cover each approval outcome.
- `go test ./...` passes.
### API Segment H: gRPC Request Blocking And Resume Behavior
Scope:
- Define how an in-flight gRPC call waits for or fails on user approval.
- Hold the request while approval is pending within a bounded timeout.
- Return unauthenticated or permission-denied when denied/canceled/expired.
- Resume the original call automatically when approval is granted.
Exit criteria:
- Pending requests block safely without leaking goroutines.
- Allowed requests resume and complete without the client reissuing the call where practical.
- Denied and canceled requests return a consistent gRPC status code and message.
- Tests cover timeout, allow, deny, and cancel paths.
- `go test ./...` passes.
### API Segment I: Token Management UI
Scope:
- Add UI for listing API tokens.
- Create token flow with one-time secret display.
- Edit token display metadata and expiration.
- Disable, revoke, and rotate tokens.
- Show effective policy summary per token.
Exit criteria:
- Users can manage API tokens from the app UI end to end.
- One-time token display is explicit and not re-shown later.
- Expiration and disable state are visible.
- UI tests cover create, rotate, disable, revoke, and edit flows.
- `go test ./...` passes.
### API Segment J: Policy Management UI
Scope:
- Let users define folder, entry, and operation scopes for each token.
- Show explicit allow and deny rules.
- Show inherited implications of a folder-level rule.
- Let users review prior permanent decisions created from approval prompts.
Exit criteria:
- Users can inspect and edit token policy from the UI.
- Folder-level and entry-level rules are distinguishable and editable.
- Permanent prompt decisions are visible as policy.
- UI tests cover rule creation, update, and deletion.
- `go test ./...` passes.
### API Segment K: Audit And Event History
Scope:
- Record token issuance, rotation, revoke, approval, deny, and prompt outcomes.
- Record authorization failures and expirations without logging secret material.
- Provide a bounded event history visible in the UI and/or gRPC admin surface.
Exit criteria:
- Security-relevant API token events are captured without secret leakage.
- Approval outcomes and policy changes are auditable.
- Tests cover audit generation for the main token lifecycle and approval actions.
- `go test ./...` passes.
### Segment 1: Application State Ownership
Scope:
@@ -389,19 +205,6 @@ Exit criteria:
- Validation and visible error states exist for missing or invalid key material.
- `go test ./...` passes.
### Segment 5: KDBX Security Settings Preservation
Scope:
- Preserve supported cipher and KDF settings when reopening and saving.
- Surface relevant settings in product-facing docs or UI where appropriate.
- Document unsupported settings explicitly.
Exit criteria:
- Reopen-and-save cycles preserve supported KDBX security settings.
- Compatibility notes are current in `docs/kdbx-compatibility.md`.
- Tests cover settings preservation across save cycles.
- `go test ./...` passes.
### Segment 6: Entry CRUD UI
Scope:
@@ -607,33 +410,6 @@ Exit criteria:
- UI tests or controller-integrated tests cover these states.
- `go test ./...` passes.
### Segment 18: Desktop Automation Resolution
Scope:
- Either implement a desktop login automation mechanism comparable in purpose to KeePass auto-type,
- or explicitly finalize the design that secure gRPC supersedes auto-type.
- Keep the decision documented in-repo.
Exit criteria:
- The desktop automation requirement is explicitly resolved in code or docs.
- The chosen approach is documented in `docs/desktop-automation.md`.
- Any implemented behavior is tested.
- `go test ./...` passes.
### Segment 19: Packaging And Runbook
Scope:
- Keep the app runnable from source.
- Document desktop build and run steps.
- Document Android packaging with `gogio`.
- Add icon and metadata placeholders if missing.
Exit criteria:
- `README.md` is accurate for local build, run, and Android packaging guidance.
- Placeholder metadata exists where needed for packaging.
- The app still builds from the repo.
- `go test ./...` passes.
### Segment 20: Regression And Integration Coverage
Scope:
@@ -651,11 +427,10 @@ Exit criteria:
Do not treat the product as complete until all of the following are true:
- Segment 1 through Segment 20 are all complete.
- All remaining numbered segments, API segments, and UI review follow-ups are complete.
- KeePassGO can create, open, edit, save, save-as, lock, and unlock local KDBX databases through the UI.
- KeePassGO can open and save remote WebDAV-backed KDBX databases through the UI, including visible conflict and error handling.
- KeePassGO supports master password, key file, and composite key workflows in the product.
- KeePassGO preserves supported KDBX security and KDF settings and documents unsupported settings.
- KeePassGO supports nested groups, path-aware navigation, explicit template navigation, and explicit recycle-bin navigation.
- KeePassGO supports entry create, edit, duplicate, delete, restore, history browse, and history restore through the UI.
- KeePassGO supports title, username, password, URL, notes, tags, and custom string fields through the UI.
@@ -665,17 +440,7 @@ Do not treat the product as complete until all of the following are true:
- KeePassGO supports copy username, copy password, copy URL, and reveal or hide password behavior end to end.
- KeePassGO exposes password generation profiles through both UI and gRPC.
- The secure gRPC API is broad enough for trusted automation and browser-extension-style integration.
- The desktop automation requirement is explicitly resolved.
- Keyboard-first navigation and common shortcuts exist for major product workflows.
- The UI no longer depends on prototype-only mock behavior for any core workflow.
- Build and run instructions exist for desktop, and packaging guidance exists for Android.
- `go test ./...` passes.
- `go tool golangci-lint run ./...` passes.
## Remaining Gaps Against AGENTS.md
None currently identified.
The last explicitly tracked gaps are now closed:
- KDBX security settings are product-configurable at the major cipher/KDF family level for both new vault creation and existing sessions.
- The current accessibility support boundary is documented in `docs/accessibility.md`, while in-repo focus and labeling behavior remains tested.
+27
View File
@@ -0,0 +1,27 @@
# KeePassGO Browser Extension
Shared extension assets for Firefox and Chromium-based browsers live here.
The Arch package installs this directory under `/usr/share/keepassgo/browser-extension/`. On Linux desktop builds, launching KeePassGO refreshes the user-scoped native messaging manifests for Firefox and for any installed Chrome or Chromium `KeePassGO Browser` extension ids it can discover from browser profiles.
- `manifest.firefox.json` uses the fixed Firefox extension id `browser@keepassgo.com`
- `manifest.chromium.json` is the Chromium/Chrome manifest template
- `background.js` caches per-tab match state, updates the toolbar badge, keeps token-scoped approval state visible, and talks to the native messaging host `com.keepassgo.browser`
- `content.js` fills username and password fields on the current page, keeps fills tied to the focused form when possible, and shows inline KeePassGO field affordances when matches exist
- `options.html` stores the API token in browser extension storage
The extension sends the API token to the native host on each request. The bridge does not store the token on disk.
Quick extension-side checks:
```bash
node --test browser/extension/background.test.cjs browser/extension/content.test.cjs
```
Reproducible Chromium validation:
```bash
make browser-extension-validate
```
That command validates Firefox by default. Use `make browser-extension-validate BROWSER=chromium` for the Chromium harness.
+744
View File
@@ -0,0 +1,744 @@
const ext = globalThis.browser ?? globalThis.chrome;
const nativeHost = "com.keepassgo.browser";
const isNodeTestEnv = typeof module !== "undefined" && module.exports;
const usePromiseAPI = typeof globalThis.browser !== "undefined";
const defaultSettings = {
bearerToken: ""
};
const pageStatePrefix = "keepassgo-page-state:";
const matchCacheTTL = 30 * 1000;
const pendingPollMillis = 1500;
const pageStates = new Map();
const refreshJobs = new Map();
const pendingPollers = new Map();
function storageGet(keys) {
if (usePromiseAPI) {
return ext.storage.local.get(keys);
}
return new Promise((resolve, reject) => {
ext.storage.local.get(keys, (value) => {
const error = ext.runtime.lastError;
if (error) {
reject(new Error(error.message));
return;
}
resolve(value);
});
});
}
function storageSet(value) {
if (usePromiseAPI) {
return ext.storage.local.set(value);
}
return new Promise((resolve, reject) => {
ext.storage.local.set(value, () => {
const error = ext.runtime.lastError;
if (error) {
reject(new Error(error.message));
return;
}
resolve();
});
});
}
function sessionArea() {
return ext.storage?.session ?? null;
}
function sessionStorageGet(keys) {
const area = sessionArea();
if (!area) {
return Promise.resolve({});
}
if (usePromiseAPI) {
return area.get(keys).then((value) => value || {});
}
return new Promise((resolve, reject) => {
area.get(keys, (value) => {
const error = ext.runtime.lastError;
if (error) {
reject(new Error(error.message));
return;
}
resolve(value || {});
});
});
}
function sessionStorageSet(value) {
const area = sessionArea();
if (!area) {
return Promise.resolve();
}
if (usePromiseAPI) {
return area.set(value);
}
return new Promise((resolve, reject) => {
area.set(value, () => {
const error = ext.runtime.lastError;
if (error) {
reject(new Error(error.message));
return;
}
resolve();
});
});
}
function sessionStorageRemove(keys) {
const area = sessionArea();
if (!area) {
return Promise.resolve();
}
if (usePromiseAPI) {
return area.remove(keys);
}
return new Promise((resolve, reject) => {
area.remove(keys, () => {
const error = ext.runtime.lastError;
if (error) {
reject(new Error(error.message));
return;
}
resolve();
});
});
}
function tabsQuery(query) {
if (usePromiseAPI) {
return ext.tabs.query(query);
}
return new Promise((resolve, reject) => {
ext.tabs.query(query, (tabs) => {
const error = ext.runtime.lastError;
if (error) {
reject(new Error(error.message));
return;
}
resolve(tabs);
});
});
}
function tabsGet(tabId) {
if (usePromiseAPI) {
return ext.tabs.get(tabId);
}
return new Promise((resolve, reject) => {
ext.tabs.get(tabId, (tab) => {
const error = ext.runtime.lastError;
if (error) {
reject(new Error(error.message));
return;
}
resolve(tab);
});
});
}
function tabsSendMessage(tabId, message) {
if (usePromiseAPI) {
return ext.tabs.sendMessage(tabId, message);
}
return new Promise((resolve, reject) => {
ext.tabs.sendMessage(tabId, message, (response) => {
const error = ext.runtime.lastError;
if (error) {
reject(new Error(error.message));
return;
}
resolve(response);
});
});
}
function connectNative(message) {
if (usePromiseAPI) {
return ext.runtime.sendNativeMessage(nativeHost, message);
}
return new Promise((resolve, reject) => {
ext.runtime.sendNativeMessage(nativeHost, message, (response) => {
const error = ext.runtime.lastError;
if (error) {
reject(new Error(error.message));
return;
}
resolve(response);
});
});
}
async function loadSettings() {
const stored = await storageGet(["bearerToken"]);
return {
bearerToken: (stored.bearerToken || defaultSettings.bearerToken).trim()
};
}
function supportsPageStateURL(rawURL) {
return typeof rawURL === "string" && /^https?:\/\//i.test(rawURL);
}
function pageStateKey(tabId) {
return `${pageStatePrefix}${String(tabId)}`;
}
function cloneTarget(target) {
return target && typeof target === "object" ? { ...target } : null;
}
function normalizePageState(state) {
return {
tabId: Number.isInteger(state?.tabId) ? state.tabId : null,
pageUrl: typeof state?.pageUrl === "string" ? state.pageUrl : "",
configured: Boolean(state?.configured),
success: state?.success !== false,
status: state?.status ?? null,
matches: Array.isArray(state?.matches) ? state.matches : [],
error: typeof state?.error === "string" ? state.error : "",
pageHasLoginForm: Boolean(state?.pageHasLoginForm),
signature: typeof state?.signature === "string" ? state.signature : "",
focusTarget: cloneTarget(state?.focusTarget),
pendingFill: Boolean(state?.pendingFill),
pendingEntryId: typeof state?.pendingEntryId === "string" ? state.pendingEntryId : "",
pendingTarget: cloneTarget(state?.pendingTarget),
pendingMessage: typeof state?.pendingMessage === "string" ? state.pendingMessage : "",
lastFilledEntryId: typeof state?.lastFilledEntryId === "string" ? state.lastFilledEntryId : "",
updatedAt: Number.isFinite(state?.updatedAt) ? state.updatedAt : 0
};
}
function defaultPageState(tabId, pageUrl) {
return normalizePageState({
tabId,
pageUrl,
configured: true,
success: true,
status: null,
matches: [],
error: "",
pageHasLoginForm: false,
signature: "",
focusTarget: null,
pendingFill: false,
pendingEntryId: "",
pendingTarget: null,
pendingMessage: "",
lastFilledEntryId: "",
updatedAt: 0
});
}
async function getPageState(tabId, pageUrl) {
if (!Number.isInteger(tabId)) {
return defaultPageState(null, pageUrl || "");
}
const existing = pageStates.get(tabId);
if (existing && (!pageUrl || existing.pageUrl === pageUrl)) {
return normalizePageState(existing);
}
const stored = await sessionStorageGet(pageStateKey(tabId));
const state = normalizePageState(stored[pageStateKey(tabId)] || defaultPageState(tabId, pageUrl || ""));
if (pageUrl && state.pageUrl !== pageUrl) {
return defaultPageState(tabId, pageUrl);
}
pageStates.set(tabId, state);
return state;
}
async function setPageState(tabId, nextState) {
const state = normalizePageState({ ...nextState, tabId });
if (!Number.isInteger(tabId)) {
return state;
}
pageStates.set(tabId, state);
await sessionStorageSet({ [pageStateKey(tabId)]: state });
await updateActionState(tabId, state);
await notifyContentState(tabId, state);
return state;
}
function clearPendingPoll(tabId) {
const timer = pendingPollers.get(tabId);
if (timer !== undefined) {
clearTimeout(timer);
pendingPollers.delete(tabId);
}
}
async function clearPageState(tabId) {
if (!Number.isInteger(tabId)) {
return;
}
pageStates.delete(tabId);
refreshJobs.delete(tabId);
clearPendingPoll(tabId);
await sessionStorageRemove(pageStateKey(tabId));
await clearActionState(tabId);
}
function describeError(error) {
return error instanceof Error ? error.message : String(error || "Unknown error");
}
function approvalHintForState(state) {
if (!state.pendingFill) {
return "";
}
return state.pendingMessage || "Approve or deny the fill request in KeePassGO.";
}
function schedulePendingPoll(tabId, pageUrl) {
if (!Number.isInteger(tabId)) {
return;
}
clearPendingPoll(tabId);
const timer = setTimeout(() => {
pendingPollers.delete(tabId);
void refreshPageState(tabId, pageUrl, { force: true }).catch(() => null);
}, pendingPollMillis);
pendingPollers.set(tabId, timer);
}
async function notifyContentState(tabId, state) {
if (!Number.isInteger(tabId)) {
return;
}
try {
await tabsSendMessage(tabId, {
type: "keepassgo-page-state",
state
});
} catch (_error) {
// Ignore pages without a ready content script.
}
}
async function clearActionState(tabId) {
if (!Number.isInteger(tabId) || !ext.action) {
return;
}
await Promise.allSettled([
ext.action.setBadgeText({ tabId, text: "" }),
ext.action.setTitle({ tabId, title: "KeePassGO Browser" })
]);
}
function actionPresentationForState(state) {
let badgeText = "";
let title = "KeePassGO Browser";
let color = "#255f4a";
if (state.pendingFill) {
badgeText = "!";
color = "#9f5f0e";
title = approvalHintForState(state) || "KeePassGO approval needed for this page";
} else if (!state.configured) {
title = "Configure KeePassGO Browser in extension settings";
} else if (!state.success) {
badgeText = "!";
color = "#9f2f2f";
title = state.error || "KeePassGO is unavailable for this page";
} else if (state.status?.locked) {
title = "Unlock KeePassGO to fill this page";
} else if (state.pageHasLoginForm && state.matches.length > 0) {
badgeText = String(Math.min(state.matches.length, 9));
title = `KeePassGO found ${state.matches.length} matching entr${state.matches.length === 1 ? "y" : "ies"} on this page`;
} else if (state.pageHasLoginForm) {
title = "KeePassGO found no matching entries on this page";
}
return { badgeText, title, color };
}
async function updateActionState(tabId, state) {
if (!Number.isInteger(tabId) || !ext.action) {
return;
}
const presentation = actionPresentationForState(state);
await Promise.allSettled([
ext.action.setBadgeText({ tabId, text: presentation.badgeText }),
ext.action.setBadgeBackgroundColor({ tabId, color: presentation.color }),
ext.action.setTitle({ tabId, title: presentation.title })
]);
}
async function activePageContext() {
const [tab] = await tabsQuery({ active: true, currentWindow: true });
return {
tabId: Number.isInteger(tab?.id) ? tab.id : null,
url: typeof tab?.url === "string" ? tab.url : ""
};
}
async function scanTabForLoginForm(tabId) {
if (!Number.isInteger(tabId)) {
return { pageHasLoginForm: false, focusTarget: null, signature: "" };
}
try {
const response = await tabsSendMessage(tabId, { type: "keepassgo-page-scan" });
return {
pageHasLoginForm: Boolean(response?.pageHasLoginForm),
focusTarget: cloneTarget(response?.focusTarget),
signature: typeof response?.signature === "string" ? response.signature : ""
};
} catch (_error) {
return { pageHasLoginForm: false, focusTarget: null, signature: "" };
}
}
function shouldReuseMatches(state, force) {
if (force || state.pendingFill) {
return false;
}
if (!state.pageHasLoginForm || !Array.isArray(state.matches)) {
return false;
}
return Date.now() - (state.updatedAt || 0) < matchCacheTTL;
}
function tokenPendingApprovalCount(status) {
return Number(status?.tokenPendingApprovalCount || 0);
}
async function fetchStatus(settings) {
if (!settings.bearerToken) {
return {
success: false,
configured: false,
status: null,
error: "Set an API token in extension settings."
};
}
const status = await connectNative({
action: "status",
bearerToken: settings.bearerToken
});
return {
success: Boolean(status?.success),
configured: true,
status: status?.status ?? null,
error: status?.error ?? ""
};
}
async function refreshPageState(tabId, pageUrl, options = {}) {
if (!Number.isInteger(tabId)) {
return defaultPageState(null, pageUrl || "");
}
const force = Boolean(options.force);
const existingJob = refreshJobs.get(tabId);
if (existingJob && !force) {
return existingJob;
}
const job = (async () => {
let resolvedURL = typeof pageUrl === "string" ? pageUrl : "";
if (!supportsPageStateURL(resolvedURL)) {
const tab = await tabsGet(tabId).catch(() => null);
resolvedURL = typeof tab?.url === "string" ? tab.url : resolvedURL;
}
if (!supportsPageStateURL(resolvedURL)) {
await clearPageState(tabId);
return defaultPageState(tabId, resolvedURL || "");
}
let state = await getPageState(tabId, resolvedURL);
if (state.pageUrl !== resolvedURL) {
state = defaultPageState(tabId, resolvedURL);
}
const scan = typeof options.pageHasLoginForm === "boolean"
? {
pageHasLoginForm: options.pageHasLoginForm,
focusTarget: cloneTarget(options.focusTarget) || state.focusTarget,
signature: typeof options.signature === "string" ? options.signature : state.signature
}
: await scanTabForLoginForm(tabId);
state = {
...state,
pageUrl: resolvedURL,
pageHasLoginForm: scan.pageHasLoginForm,
focusTarget: cloneTarget(scan.focusTarget) || state.focusTarget,
signature: typeof scan.signature === "string" ? scan.signature : state.signature
};
const settings = await loadSettings();
const statusInfo = await fetchStatus(settings).catch((error) => ({
success: false,
configured: true,
status: null,
error: describeError(error)
}));
state = {
...state,
configured: statusInfo.configured,
success: statusInfo.success,
status: statusInfo.status,
pendingFill: state.pendingFill || tokenPendingApprovalCount(statusInfo.status) > 0,
pendingMessage: tokenPendingApprovalCount(statusInfo.status) > 0
? approvalHintForState(state) || "Approve or deny the browser fill request in KeePassGO."
: "",
error: statusInfo.error
};
if (!statusInfo.configured || !statusInfo.success || statusInfo.status?.locked || !state.pageHasLoginForm) {
state.matches = [];
state.updatedAt = Date.now();
const saved = await setPageState(tabId, state);
if (saved.pendingFill) {
schedulePendingPoll(tabId, resolvedURL);
} else {
clearPendingPoll(tabId);
}
return saved;
}
if (shouldReuseMatches(state, force)) {
const saved = await setPageState(tabId, state);
if (saved.pendingFill) {
schedulePendingPoll(tabId, resolvedURL);
} else {
clearPendingPoll(tabId);
}
return saved;
}
const matches = await connectNative({
action: "find-logins",
bearerToken: settings.bearerToken,
url: resolvedURL
});
state = {
...state,
success: Boolean(matches?.success),
status: matches?.status ?? state.status,
pendingFill: state.pendingFill || tokenPendingApprovalCount(matches?.status ?? state.status) > 0,
pendingMessage: tokenPendingApprovalCount(matches?.status ?? state.status) > 0
? approvalHintForState(state) || "Approve or deny the browser fill request in KeePassGO."
: "",
matches: Array.isArray(matches?.matches) ? matches.matches : [],
error: matches?.error ?? "",
updatedAt: Date.now()
};
const saved = await setPageState(tabId, state);
if (saved.pendingFill) {
schedulePendingPoll(tabId, resolvedURL);
} else {
clearPendingPoll(tabId);
}
return saved;
})().finally(() => {
if (refreshJobs.get(tabId) === job) {
refreshJobs.delete(tabId);
}
});
refreshJobs.set(tabId, job);
return job;
}
async function statusForPage(options = {}) {
let page = await activePageContext();
if (Number.isInteger(options.tabId)) {
const tab = await tabsGet(options.tabId).catch(() => null);
page = {
tabId: options.tabId,
url: typeof tab?.url === "string" ? tab.url : ""
};
}
if (page.tabId == null) {
return defaultPageState(null, page.url);
}
if (!options.force) {
const cached = await getPageState(page.tabId, page.url);
if (cached.pageUrl === page.url && cached.updatedAt && !cached.pendingFill) {
return cached;
}
}
return refreshPageState(page.tabId, page.url, options);
}
async function fillLogin(tabId, entryId) {
if (!Number.isInteger(tabId)) {
throw new Error("No active tab is available.");
}
const tab = await tabsGet(tabId);
const pageUrl = typeof tab?.url === "string" ? tab.url : "";
if (!supportsPageStateURL(pageUrl)) {
throw new Error("This page cannot be filled.");
}
let state = await getPageState(tabId, pageUrl);
state = await setPageState(tabId, {
...state,
pageUrl,
pendingFill: true,
pendingEntryId: String(entryId || "").trim(),
pendingTarget: cloneTarget(state.focusTarget),
pendingMessage: "Approve or deny the browser fill request in KeePassGO.",
error: "",
updatedAt: Date.now()
});
schedulePendingPoll(tabId, pageUrl);
try {
const settings = await loadSettings();
if (!settings.bearerToken) {
throw new Error("API token is not configured.");
}
const response = await connectNative({
action: "get-login",
bearerToken: settings.bearerToken,
entryId,
url: pageUrl
});
if (!response?.success || !response.credential) {
throw new Error(response?.error || "KeePassGO did not return a credential.");
}
const fillResponse = await tabsSendMessage(tabId, {
type: "keepassgo-fill-credential",
credential: response.credential,
target: state.pendingTarget
});
if (!fillResponse?.ok) {
throw new Error(fillResponse?.error || "The current page could not be filled.");
}
state = await setPageState(tabId, {
...state,
pendingFill: false,
pendingEntryId: "",
pendingTarget: null,
pendingMessage: "",
lastFilledEntryId: String(entryId || "").trim(),
error: "",
updatedAt: Date.now()
});
clearPendingPoll(tabId);
return {
credential: response.credential,
pageUrl,
state
};
} catch (error) {
state = await setPageState(tabId, {
...state,
pendingFill: false,
pendingEntryId: "",
pendingTarget: null,
pendingMessage: "",
error: describeError(error),
updatedAt: Date.now()
});
clearPendingPoll(tabId);
throw error;
}
}
async function refreshActivePage(options = {}) {
const page = await activePageContext();
if (page.tabId == null) {
return defaultPageState(null, page.url);
}
return refreshPageState(page.tabId, page.url, options);
}
const backgroundTestExports = {
normalizePageState,
actionPresentationForState,
shouldReuseMatches,
tokenPendingApprovalCount,
defaultSettings
};
if (isNodeTestEnv) {
module.exports = backgroundTestExports;
} else {
ext.runtime.onMessage.addListener((message, sender, sendResponse) => {
(async () => {
switch (message?.type) {
case "keepassgo-popup-state":
sendResponse(await statusForPage({
force: Boolean(message.force),
tabId: Number.isInteger(message?.tabId) ? message.tabId : null
}));
return;
case "keepassgo-fill-entry": {
const targetTabID = Number.isInteger(message?.tabId)
? message.tabId
: (Number.isInteger(sender?.tab?.id) ? sender.tab.id : (await activePageContext()).tabId);
if (Number.isInteger(targetTabID) && message.target) {
const targetState = await getPageState(targetTabID, "");
await setPageState(targetTabID, {
...targetState,
focusTarget: cloneTarget(message.target)
});
}
sendResponse({ success: true, ...(await fillLogin(targetTabID, message.entryId)) });
return;
}
case "keepassgo-load-settings":
sendResponse({ success: true, settings: await loadSettings() });
return;
case "keepassgo-save-settings":
await storageSet({
bearerToken: String(message.settings?.bearerToken || "").trim()
});
await refreshActivePage({ force: true }).catch(() => null);
sendResponse({ success: true });
return;
case "keepassgo-page-ready":
if (Number.isInteger(sender?.tab?.id)) {
sendResponse(await refreshPageState(sender.tab.id, sender.tab.url, {
force: Boolean(message.force),
pageHasLoginForm: Boolean(message.pageHasLoginForm),
focusTarget: cloneTarget(message.focusTarget),
signature: typeof message.signature === "string" ? message.signature : ""
}));
return;
}
sendResponse(defaultPageState(null, ""));
return;
case "keepassgo-refresh-page-state":
if (Number.isInteger(sender?.tab?.id)) {
sendResponse(await refreshPageState(sender.tab.id, sender.tab.url, { force: true }));
return;
}
sendResponse(defaultPageState(null, ""));
return;
default:
sendResponse({ success: false, error: `Unsupported message ${message?.type || ""}`.trim() });
}
})().catch((error) => {
sendResponse({ success: false, error: describeError(error) });
});
return true;
});
ext.tabs?.onActivated?.addListener(({ tabId }) => {
void refreshPageState(tabId, "", { force: false }).catch(() => null);
});
ext.tabs?.onUpdated?.addListener((tabId, changeInfo, tab) => {
if (typeof changeInfo.url === "string") {
void clearPageState(tabId).catch(() => null);
}
if (changeInfo.status === "complete") {
void refreshPageState(tabId, tab?.url || changeInfo.url || "", { force: false }).catch(() => null);
}
});
ext.tabs?.onRemoved?.addListener((tabId) => {
void clearPageState(tabId).catch(() => null);
});
}
+54
View File
@@ -0,0 +1,54 @@
const test = require("node:test");
const assert = require("node:assert/strict");
const background = require("./background.js");
test("normalizePageState preserves focused and pending field targets", () => {
const state = background.normalizePageState({
tabId: 7,
pageUrl: "https://vault.example.invalid/login",
focusTarget: { role: "username", formIndex: 0, fieldIndex: 1 },
pendingTarget: { role: "password", formIndex: 0, fieldIndex: 2 }
});
assert.deepEqual(state.focusTarget, { role: "username", formIndex: 0, fieldIndex: 1 });
assert.deepEqual(state.pendingTarget, { role: "password", formIndex: 0, fieldIndex: 2 });
});
test("shouldReuseMatches only reuses recent non-pending page matches", () => {
const recentState = {
pageHasLoginForm: true,
matches: [{ id: "vault-console" }],
pendingFill: false,
updatedAt: Date.now()
};
assert.equal(background.shouldReuseMatches(recentState, false), true);
assert.equal(background.shouldReuseMatches({ ...recentState, pendingFill: true }, false), false);
assert.equal(background.shouldReuseMatches({ ...recentState, pageHasLoginForm: false }, false), false);
assert.equal(background.shouldReuseMatches(recentState, true), false);
});
test("actionPresentationForState prioritizes approval visibility", () => {
const presentation = background.actionPresentationForState({
pendingFill: true,
pendingMessage: "Approve the browser fill request in KeePassGO.",
configured: true,
success: true,
pageHasLoginForm: true,
matches: [{ id: "vault-console" }]
});
assert.equal(presentation.badgeText, "!");
assert.equal(presentation.color, "#9f5f0e");
assert.match(presentation.title, /approve/i);
});
test("tokenPendingApprovalCount reads token-scoped approval state", () => {
assert.equal(background.tokenPendingApprovalCount({ tokenPendingApprovalCount: 2 }), 2);
assert.equal(background.tokenPendingApprovalCount({}), 0);
});
test("default settings include a blank bearer token that can be overridden by harness patching", () => {
assert.equal(background.defaultSettings.bearerToken, "");
});
+676
View File
@@ -0,0 +1,676 @@
const ext = globalThis.browser ?? globalThis.chrome;
const isNodeTestEnv = typeof module !== "undefined" && module.exports;
const usePromiseAPI = typeof globalThis.browser !== "undefined";
function runtimeSend(message) {
if (usePromiseAPI) {
return ext.runtime.sendMessage(message);
}
return new Promise((resolve, reject) => {
ext.runtime.sendMessage(message, (response) => {
const error = ext.runtime.lastError;
if (error) {
reject(new Error(error.message));
return;
}
resolve(response);
});
});
}
function isVisibleInput(input) {
if (!(input instanceof HTMLInputElement)) {
return false;
}
if (input.disabled || input.readOnly) {
return false;
}
const style = window.getComputedStyle(input);
if (style.display === "none" || style.visibility === "hidden") {
return false;
}
return input.offsetParent !== null || style.position === "fixed";
}
function normalizeRole(rawRole) {
switch (String(rawRole || "").trim().toLowerCase()) {
case "password":
return "password";
default:
return "username";
}
}
function describeFieldRole(input) {
const type = String(input?.getAttribute?.("type") || "").toLowerCase();
if (type === "password") {
return "password";
}
const autocomplete = String(input?.autocomplete || "").toLowerCase();
if (autocomplete.includes("username") || autocomplete.includes("email")) {
return "username";
}
return "username";
}
function isUsernameCandidate(input) {
if (!isVisibleInput(input)) {
return false;
}
return describeFieldRole(input) === "username";
}
function isPasswordCandidate(input) {
return isVisibleInput(input) && describeFieldRole(input) === "password";
}
function dispatchFillEvents(input) {
if (typeof InputEvent === "function") {
input.dispatchEvent(new InputEvent("input", { bubbles: true, data: input.value, inputType: "insertText" }));
} else {
input.dispatchEvent(new Event("input", { bubbles: true }));
}
input.dispatchEvent(new Event("change", { bubbles: true }));
}
function setInputValue(input, value) {
const prototype = Object.getPrototypeOf(input);
const descriptor = prototype ? Object.getOwnPropertyDescriptor(prototype, "value") : null;
if (descriptor?.set) {
descriptor.set.call(input, value);
return;
}
input.value = value;
}
function visibleInputs(scope) {
return Array.from(scope.querySelectorAll("input")).filter(isVisibleInput);
}
function resolveFormInputs(anchorInput) {
if (anchorInput?.form instanceof HTMLFormElement) {
return visibleInputs(anchorInput.form);
}
return visibleInputs(document);
}
function firstVisiblePassword(scope) {
return visibleInputs(scope).find(isPasswordCandidate) || null;
}
function firstVisibleUsername(scope) {
return visibleInputs(scope).find(isUsernameCandidate) || null;
}
function associatedFieldsForAnchor(anchorInput) {
const scopeInputs = resolveFormInputs(anchorInput);
const passwordInput = scopeInputs.find(isPasswordCandidate) || firstVisiblePassword(document);
const usernameInScope = scopeInputs.filter(isUsernameCandidate);
let usernameInput = usernameInScope[0] || null;
if (passwordInput && usernameInScope.length !== 0) {
const priorSibling = usernameInScope.find((input) =>
typeof input.compareDocumentPosition === "function" &&
Boolean(input.compareDocumentPosition(passwordInput) & Node.DOCUMENT_POSITION_FOLLOWING)
);
usernameInput = priorSibling || usernameInScope[0] || null;
}
if (!usernameInput) {
usernameInput = firstVisibleUsername(document);
}
return { usernameInput, passwordInput };
}
function buildFieldDescriptor(input, role) {
if (!(input instanceof HTMLInputElement)) {
return null;
}
const normalizedRole = normalizeRole(role || describeFieldRole(input));
const form = input.form instanceof HTMLFormElement ? input.form : null;
const scope = form || document;
const inputs = visibleInputs(scope);
const fieldIndex = inputs.indexOf(input);
const forms = Array.from(document.forms || []);
return {
role: normalizedRole,
formIndex: form ? forms.indexOf(form) : -1,
fieldIndex,
id: String(input.id || ""),
name: String(input.name || ""),
autocomplete: String(input.autocomplete || "").toLowerCase()
};
}
function resolveFieldDescriptor(descriptor) {
if (!descriptor || typeof descriptor !== "object") {
return null;
}
const normalizedRole = normalizeRole(descriptor.role);
const forms = Array.from(document.forms || []);
const form = Number.isInteger(descriptor.formIndex) && descriptor.formIndex >= 0 ? forms[descriptor.formIndex] || null : null;
const scope = form || document;
const inputs = visibleInputs(scope);
if (Number.isInteger(descriptor.fieldIndex) && descriptor.fieldIndex >= 0 && descriptor.fieldIndex < inputs.length) {
const candidate = inputs[descriptor.fieldIndex];
if (describeFieldRole(candidate) === normalizedRole) {
return candidate;
}
}
if (descriptor.id) {
const byID = scope.querySelector(`#${CSS.escape(descriptor.id)}`);
if (byID instanceof HTMLInputElement && isVisibleInput(byID) && describeFieldRole(byID) === normalizedRole) {
return byID;
}
}
if (descriptor.name) {
const byName = visibleInputs(scope).find((input) => input.name === descriptor.name && describeFieldRole(input) === normalizedRole);
if (byName) {
return byName;
}
}
if (normalizedRole === "password") {
return firstVisiblePassword(scope) || firstVisiblePassword(document);
}
return firstVisibleUsername(scope) || firstVisibleUsername(document);
}
function chooseFillTargets(targetDescriptor) {
const anchorInput = resolveFieldDescriptor(targetDescriptor) || (document.activeElement instanceof HTMLInputElement ? document.activeElement : null);
const associated = associatedFieldsForAnchor(anchorInput);
if (normalizeRole(targetDescriptor?.role) === "password" && anchorInput instanceof HTMLInputElement) {
return {
usernameInput: associated.usernameInput,
passwordInput: isPasswordCandidate(anchorInput) ? anchorInput : associated.passwordInput,
anchorInput
};
}
if (normalizeRole(targetDescriptor?.role) === "username" && anchorInput instanceof HTMLInputElement) {
return {
usernameInput: isUsernameCandidate(anchorInput) ? anchorInput : associated.usernameInput,
passwordInput: associated.passwordInput,
anchorInput
};
}
return {
usernameInput: associated.usernameInput,
passwordInput: associated.passwordInput,
anchorInput: anchorInput || associated.passwordInput || associated.usernameInput || null
};
}
function scanLoginFields() {
const activeElement = document.activeElement instanceof HTMLInputElement ? document.activeElement : null;
const activeUsable = activeElement && isVisibleInput(activeElement) ? activeElement : null;
const targets = chooseFillTargets(buildFieldDescriptor(activeUsable, describeFieldRole(activeUsable)));
const anchorInput = activeUsable || targets.passwordInput || targets.usernameInput;
const focusTarget = buildFieldDescriptor(anchorInput, describeFieldRole(anchorInput));
const allVisible = visibleInputs(document);
const roles = allVisible
.filter((input) => isUsernameCandidate(input) || isPasswordCandidate(input))
.map((input) => {
const descriptor = buildFieldDescriptor(input, describeFieldRole(input));
return `${descriptor.formIndex}:${descriptor.fieldIndex}:${descriptor.role}`;
});
return {
pageHasLoginForm: Boolean(targets.usernameInput || targets.passwordInput),
usernameInput: targets.usernameInput,
passwordInput: targets.passwordInput,
anchorInput,
focusTarget,
signature: roles.join("|")
};
}
function fillCredential(credential, targetDescriptor) {
const { passwordInput, usernameInput } = chooseFillTargets(targetDescriptor);
if (usernameInput && credential.username) {
usernameInput.focus();
setInputValue(usernameInput, credential.username);
dispatchFillEvents(usernameInput);
}
if (passwordInput && credential.password) {
passwordInput.focus();
setInputValue(passwordInput, credential.password);
dispatchFillEvents(passwordInput);
}
if (!usernameInput && !passwordInput) {
return { ok: false, error: "No fillable username or password fields were found." };
}
return { ok: true };
}
function domainLabel(rawURL) {
try {
return new URL(rawURL).host || "";
} catch (_error) {
return "";
}
}
function inlineMatchSummary(match) {
const parts = [];
if (match.username) {
parts.push(match.username);
}
if (match.url) {
const host = domainLabel(match.url);
if (host) {
parts.push(host);
}
}
if (Array.isArray(match.path) && match.path.length !== 0) {
parts.push(match.path.join(" / "));
}
return parts.join(" · ") || "No username";
}
function shouldShowInlineOverlay(state, hasTarget, suppressed) {
if (suppressed || !hasTarget) {
return false;
}
return Boolean(
state?.pageHasLoginForm &&
(
state?.pendingFill ||
(state?.configured && state?.success && !state?.status?.locked && Array.isArray(state?.matches) && state.matches.length > 0)
)
);
}
const contentTestExports = {
normalizeRole,
describeFieldRole,
buildFieldDescriptor,
resolveFieldDescriptor,
chooseFillTargets,
inlineMatchSummary,
domainLabel,
shouldShowInlineOverlay
};
if (isNodeTestEnv) {
module.exports = contentTestExports;
} else {
let pageState = {
configured: true,
success: true,
matches: [],
pageHasLoginForm: false,
pendingFill: false,
error: "",
focusTarget: null
};
let chooserOpen = false;
let inlineSuppressed = false;
let refreshTimer = null;
let lastReportedSignature = "";
let lastReportedTarget = "";
const root = document.createElement("div");
root.id = "keepassgo-inline-root";
root.setAttribute("aria-live", "polite");
const shadow = root.attachShadow({ mode: "open" });
shadow.innerHTML = `
<style>
:host {
all: initial;
}
.dock {
position: fixed;
z-index: 2147483647;
display: none;
min-width: 220px;
max-width: min(340px, calc(100vw - 24px));
font: 13px/1.35 "Noto Sans", "Liberation Sans", sans-serif;
color: #214f44;
}
.dock[data-open="true"] .panel {
display: block;
}
.trigger {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
border: 1px solid #c6d8cf;
border-radius: 999px;
background: linear-gradient(180deg, #ffffff, #edf5f0);
color: #214f44;
box-shadow: 0 12px 26px rgba(33, 79, 68, 0.16);
cursor: pointer;
}
.trigger[data-tone="warning"] {
border-color: #e4d0ae;
background: linear-gradient(180deg, #fff8ed, #f9edd6);
color: #7f4b09;
}
.trigger[data-tone="error"] {
border-color: #e4bcbc;
background: linear-gradient(180deg, #fff5f5, #f9e7e7);
color: #8c2f2f;
}
.brand {
font-weight: 700;
letter-spacing: 0.02em;
}
.meta {
color: #4d6d66;
font-size: 12px;
}
.panel {
display: none;
margin-top: 8px;
border: 1px solid #d7e3dc;
border-radius: 16px;
background: #fffdfa;
box-shadow: 0 18px 42px rgba(33, 79, 68, 0.22);
overflow: hidden;
}
.panel-header {
padding: 12px 14px 10px;
border-bottom: 1px solid #e6efea;
background: linear-gradient(180deg, #f8fbf8, #f1f6f3);
}
.panel-title {
font-weight: 700;
}
.panel-copy {
margin-top: 4px;
color: #4d6d66;
font-size: 12px;
}
.match-list {
display: grid;
gap: 0;
max-height: 280px;
overflow: auto;
}
.match {
display: grid;
gap: 3px;
width: 100%;
padding: 12px 14px;
border: 0;
border-top: 1px solid #eef4f0;
background: #fffdfa;
color: #214f44;
text-align: left;
cursor: pointer;
}
.match:hover,
.match:focus-visible {
background: #edf5f0;
outline: none;
}
.match strong {
font-size: 13px;
}
.pill {
display: inline-flex;
width: fit-content;
padding: 2px 6px;
border-radius: 999px;
background: #e7f1eb;
color: #255f4a;
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.subtle {
color: #4d6d66;
font-size: 12px;
}
.empty {
padding: 12px 14px;
color: #4d6d66;
font-size: 12px;
}
</style>
<div class="dock" data-open="false">
<button type="button" class="trigger" data-tone="ready">
<span class="brand">KeePassGO</span>
<span class="meta">Checking this form</span>
</button>
<div class="panel">
<div class="panel-header">
<div class="panel-title">KeePassGO suggestions</div>
<div class="panel-copy">Select a matching login for this field.</div>
</div>
<div class="match-list"></div>
</div>
</div>
`;
const dock = shadow.querySelector(".dock");
const trigger = shadow.querySelector(".trigger");
const meta = shadow.querySelector(".meta");
const matchList = shadow.querySelector(".match-list");
const panelCopy = shadow.querySelector(".panel-copy");
function ensureRootMounted() {
if (!root.isConnected) {
document.documentElement.appendChild(root);
}
}
function currentTarget() {
return chooseFillTargets(pageState.focusTarget).anchorInput;
}
function hideDock() {
chooserOpen = false;
dock.style.display = "none";
dock.dataset.open = "false";
}
function positionDock() {
const anchor = currentTarget();
if (!anchor || dock.style.display === "none") {
return;
}
const rect = anchor.getBoundingClientRect();
const width = Math.min(340, Math.max(220, rect.width));
const left = Math.min(window.innerWidth - width - 12, Math.max(12, rect.left));
const top = Math.min(window.innerHeight - 16, rect.bottom + 8);
dock.style.left = `${left}px`;
dock.style.top = `${top}px`;
dock.style.width = `${width}px`;
}
function renderMatches() {
matchList.textContent = "";
if (pageState.pendingFill) {
const pending = document.createElement("div");
pending.className = "empty";
pending.textContent = pageState.pendingMessage || "Approve or deny the fill request in KeePassGO.";
matchList.appendChild(pending);
return;
}
if (!Array.isArray(pageState.matches) || pageState.matches.length === 0) {
const empty = document.createElement("div");
empty.className = "empty";
empty.textContent = pageState.error || "No matching entries were found for this page.";
matchList.appendChild(empty);
return;
}
for (const match of pageState.matches) {
const row = document.createElement("button");
row.type = "button";
row.className = "match";
const title = document.createElement("strong");
title.textContent = match.title;
const summary = document.createElement("span");
summary.className = "subtle";
summary.textContent = inlineMatchSummary(match);
const quality = document.createElement("span");
quality.className = "pill";
quality.textContent = match.quality || "Candidate";
row.appendChild(title);
row.appendChild(summary);
row.appendChild(quality);
row.addEventListener("click", async () => {
row.disabled = true;
inlineSuppressed = true;
hideDock();
try {
await runtimeSend({
type: "keepassgo-fill-entry",
entryId: match.id,
target: pageState.focusTarget
});
} catch (_error) {
pageState = {
...pageState,
pendingFill: false,
error: "KeePassGO could not fill this page."
};
renderInlineState();
} finally {
row.disabled = false;
}
});
matchList.appendChild(row);
}
}
function renderInlineState() {
const target = currentTarget();
const shouldShow = shouldShowInlineOverlay(pageState, Boolean(target), inlineSuppressed);
if (!shouldShow) {
hideDock();
return;
}
ensureRootMounted();
dock.style.display = "block";
trigger.dataset.tone = pageState.pendingFill ? "warning" : (pageState.error ? "error" : "ready");
if (pageState.pendingFill) {
meta.textContent = "Approval needed in KeePassGO";
panelCopy.textContent = pageState.pendingMessage || "Approve or deny the fill request in KeePassGO.";
} else {
const count = Array.isArray(pageState.matches) ? pageState.matches.length : 0;
meta.textContent = count === 1 ? "1 login ready" : `${count} logins ready`;
panelCopy.textContent = "Select a matching login for this field.";
}
dock.dataset.open = chooserOpen ? "true" : "false";
renderMatches();
positionDock();
}
function reportFieldState(force) {
const scan = scanLoginFields();
pageState = {
...pageState,
pageHasLoginForm: scan.pageHasLoginForm,
focusTarget: scan.focusTarget
};
renderInlineState();
const nextTarget = JSON.stringify(scan.focusTarget || null);
if (!force && scan.signature === lastReportedSignature && nextTarget === lastReportedTarget) {
return;
}
lastReportedSignature = scan.signature;
lastReportedTarget = nextTarget;
void runtimeSend({
type: "keepassgo-page-ready",
force: Boolean(force),
pageHasLoginForm: scan.pageHasLoginForm,
focusTarget: scan.focusTarget,
signature: scan.signature
}).then((response) => {
if (response && typeof response === "object" && !("success" in response && response.success === false)) {
pageState = {
...pageState,
...response,
pageHasLoginForm: Boolean(response.pageHasLoginForm),
focusTarget: response.focusTarget || pageState.focusTarget
};
renderInlineState();
}
}).catch(() => null);
}
function scheduleRefresh(force) {
if (refreshTimer !== null) {
clearTimeout(refreshTimer);
}
refreshTimer = window.setTimeout(() => {
refreshTimer = null;
reportFieldState(force);
}, force ? 0 : 120);
}
trigger.addEventListener("click", () => {
chooserOpen = !chooserOpen;
renderInlineState();
});
document.addEventListener("focusin", () => {
scheduleRefresh(false);
});
document.addEventListener("input", () => {
scheduleRefresh(false);
}, true);
document.addEventListener("click", (event) => {
if (!root.contains(event.target)) {
chooserOpen = false;
renderInlineState();
}
});
window.addEventListener("scroll", () => {
positionDock();
}, true);
window.addEventListener("resize", () => {
positionDock();
});
const observer = new MutationObserver((records) => {
if (records.some((record) => record.type === "childList")) {
scheduleRefresh(false);
}
});
observer.observe(document.documentElement, {
childList: true,
subtree: true
});
ext.runtime.onMessage.addListener((message, _sender, sendResponse) => {
if (message?.type === "keepassgo-fill-credential") {
try {
sendResponse(fillCredential(message.credential || {}, message.target || pageState.focusTarget));
} catch (error) {
sendResponse({ ok: false, error: error instanceof Error ? error.message : String(error) });
}
return false;
}
if (message?.type === "keepassgo-page-state") {
pageState = {
...pageState,
...(message.state || {})
};
renderInlineState();
sendResponse({ ok: true });
return false;
}
if (message?.type === "keepassgo-page-scan") {
const scan = scanLoginFields();
sendResponse({
pageHasLoginForm: scan.pageHasLoginForm,
focusTarget: scan.focusTarget,
signature: scan.signature
});
return false;
}
return false;
});
reportFieldState(true);
}
+33
View File
@@ -0,0 +1,33 @@
const test = require("node:test");
const assert = require("node:assert/strict");
const content = require("./content.js");
test("inlineMatchSummary includes username, host, and path context", () => {
const summary = content.inlineMatchSummary({
username: "dannyocean",
url: "https://vault.example.invalid/login",
path: ["Root", "Crew"]
});
assert.equal(summary, "dannyocean · vault.example.invalid · Root / Crew");
});
test("domainLabel tolerates invalid URLs", () => {
assert.equal(content.domainLabel("https://vault.example.invalid"), "vault.example.invalid");
assert.equal(content.domainLabel("not-a-url"), "");
});
test("shouldShowInlineOverlay hides the page overlay after it is suppressed", () => {
const state = {
pageHasLoginForm: true,
configured: true,
success: true,
status: { locked: false },
matches: [{ id: "vault-console" }],
pendingFill: false
};
assert.equal(content.shouldShowInlineOverlay(state, true, false), true);
assert.equal(content.shouldShowInlineOverlay(state, true, true), false);
});
+26
View File
@@ -0,0 +1,26 @@
{
"manifest_version": 3,
"name": "KeePassGO Browser",
"version": "0.1.0",
"description": "Fill credentials from KeePassGO on sign-in pages.",
"permissions": ["activeTab", "nativeMessaging", "storage", "tabs"],
"host_permissions": ["http://*/*", "https://*/*"],
"background": {
"service_worker": "background.js"
},
"action": {
"default_title": "KeePassGO Browser",
"default_popup": "popup.html"
},
"options_ui": {
"page": "options.html",
"open_in_tab": true
},
"content_scripts": [
{
"matches": ["http://*/*", "https://*/*"],
"js": ["content.js"],
"run_at": "document_idle"
}
]
}
+37
View File
@@ -0,0 +1,37 @@
{
"manifest_version": 2,
"name": "KeePassGO Browser",
"version": "0.1.0",
"description": "Fill credentials from KeePassGO on sign-in pages.",
"permissions": [
"activeTab",
"nativeMessaging",
"storage",
"tabs",
"http://*/*",
"https://*/*"
],
"background": {
"scripts": ["background.js"]
},
"browser_action": {
"default_title": "KeePassGO Browser",
"default_popup": "popup.html"
},
"options_ui": {
"page": "options.html",
"open_in_tab": true
},
"content_scripts": [
{
"matches": ["http://*/*", "https://*/*"],
"js": ["content.js"],
"run_at": "document_idle"
}
],
"browser_specific_settings": {
"gecko": {
"id": "browser@keepassgo.com"
}
}
}
+30
View File
@@ -0,0 +1,30 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>KeePassGO Browser Settings</title>
<link rel="stylesheet" href="style.css">
</head>
<body>
<main class="surface settings">
<header class="topbar">
<div>
<h1>Browser Settings</h1>
<p class="subtle">Connect the extension to KeePassGO.</p>
</div>
</header>
<form id="settings-form" class="settings-form">
<label>
<span>API token</span>
<textarea id="bearer-token" name="bearer-token" rows="6" spellcheck="false"></textarea>
</label>
<div class="actions">
<button type="submit">Save</button>
</div>
<p id="settings-status" class="subtle"></p>
</form>
</main>
<script src="options.js"></script>
</body>
</html>
+49
View File
@@ -0,0 +1,49 @@
const extOptions = globalThis.browser ?? globalThis.chrome;
const usePromiseAPI = typeof globalThis.browser !== "undefined";
function runtimeSend(message) {
if (usePromiseAPI) {
return extOptions.runtime.sendMessage(message);
}
return new Promise((resolve, reject) => {
extOptions.runtime.sendMessage(message, (response) => {
const error = extOptions.runtime.lastError;
if (error) {
reject(new Error(error.message));
return;
}
resolve(response);
});
});
}
async function loadSettings() {
const response = await runtimeSend({ type: "keepassgo-load-settings" });
if (!response?.success) {
throw new Error(response?.error || "Could not load settings.");
}
document.getElementById("bearer-token").value = response.settings.bearerToken || "";
}
async function saveSettings(event) {
event.preventDefault();
const status = document.getElementById("settings-status");
status.textContent = "Saving…";
try {
const response = await runtimeSend({
type: "keepassgo-save-settings",
settings: {
bearerToken: document.getElementById("bearer-token").value
}
});
if (!response?.success) {
throw new Error(response?.error || "Could not save settings.");
}
status.textContent = "Saved.";
} catch (error) {
status.textContent = error instanceof Error ? error.message : String(error);
}
}
document.getElementById("settings-form").addEventListener("submit", saveSettings);
void loadSettings();
+30
View File
@@ -0,0 +1,30 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>KeePassGO Browser</title>
<link rel="stylesheet" href="style.css">
</head>
<body class="popup">
<main class="surface">
<header class="topbar">
<div>
<h1>KeePassGO</h1>
<p id="page-host" class="subtle">Checking current page</p>
</div>
<a href="options.html" target="_blank" rel="noreferrer" class="link-button">Settings</a>
</header>
<section id="status-card" class="status-card">
<strong id="status-title">Loading</strong>
<p id="status-message" class="subtle">Checking KeePassGO.</p>
</section>
<p id="page-hint" class="inline-hint subtle">Loading page state.</p>
<section>
<h2>Matches</h2>
<div id="matches" class="match-list"></div>
</section>
</main>
<script src="popup.js"></script>
</body>
</html>
+173
View File
@@ -0,0 +1,173 @@
const extPopup = globalThis.browser ?? globalThis.chrome;
const usePromiseAPI = typeof globalThis.browser !== "undefined";
function runtimeSend(message) {
if (usePromiseAPI) {
return extPopup.runtime.sendMessage(message);
}
return new Promise((resolve, reject) => {
extPopup.runtime.sendMessage(message, (response) => {
const error = extPopup.runtime.lastError;
if (error) {
reject(new Error(error.message));
return;
}
resolve(response);
});
});
}
function hostFromURL(rawURL) {
try {
return new URL(rawURL).host || rawURL;
} catch (_error) {
return rawURL || "Current page";
}
}
function setStatus(title, message, tone) {
const card = document.getElementById("status-card");
card.dataset.tone = tone || "neutral";
document.getElementById("status-title").textContent = title;
document.getElementById("status-message").textContent = message;
}
function matchSubtitle(match) {
const parts = [];
if (match.username) {
parts.push(match.username);
}
if (Array.isArray(match.path) && match.path.length !== 0) {
parts.push(match.path.join(" / "));
}
return parts.join(" · ") || "No username";
}
function renderMatches(state) {
const root = document.getElementById("matches");
const targetTabID = popupTabID();
root.textContent = "";
if (!Array.isArray(state.matches) || state.matches.length === 0) {
const empty = document.createElement("p");
empty.className = "subtle";
empty.textContent = state.pageHasLoginForm
? "No matching entries for this page."
: "No login fields detected on this page.";
root.appendChild(empty);
return;
}
for (const match of state.matches) {
const row = document.createElement("button");
row.type = "button";
row.className = "match-row";
const main = document.createElement("span");
main.className = "match-main";
const title = document.createElement("strong");
title.textContent = match.title;
const subtitle = document.createElement("span");
subtitle.className = "subtle";
subtitle.textContent = matchSubtitle(match);
const quality = document.createElement("span");
quality.className = "quality";
quality.textContent = match.quality || "";
main.appendChild(title);
main.appendChild(subtitle);
row.appendChild(main);
row.appendChild(quality);
row.addEventListener("click", async () => {
row.disabled = true;
setStatus("Approval may be required", "KeePassGO will prompt if this token needs approval before fill.", "warning");
try {
const result = await runtimeSend({
type: "keepassgo-fill-entry",
entryId: match.id,
tabId: targetTabID
});
if (!result?.success) {
throw new Error(result?.error || "Fill failed.");
}
setStatus("Filled", `${match.title} was sent to the current page.`, "ready");
} catch (error) {
setStatus("Fill failed", error instanceof Error ? error.message : String(error), "error");
} finally {
row.disabled = false;
}
});
root.appendChild(row);
}
}
function renderPageHint(state) {
const hint = document.getElementById("page-hint");
if (state.pendingFill) {
hint.textContent = "Approval is pending in KeePassGO.";
return;
}
if (state.pageHasLoginForm && Array.isArray(state.matches) && state.matches.length > 0) {
hint.textContent = "Inline KeePassGO suggestions are available on the page.";
return;
}
if (state.pageHasLoginForm) {
hint.textContent = "KeePassGO checked this login form already.";
return;
}
hint.textContent = "Open a sign-in page to see KeePassGO suggestions here.";
}
function popupTabID() {
const rawValue = new URLSearchParams(window.location.search).get("tabId");
if (rawValue === null) {
return null;
}
const parsed = Number.parseInt(rawValue, 10);
return Number.isInteger(parsed) ? parsed : null;
}
async function main() {
try {
const state = await runtimeSend({
type: "keepassgo-popup-state",
force: true,
tabId: popupTabID()
});
document.getElementById("page-host").textContent = hostFromURL(state.pageUrl || "");
renderPageHint(state);
if (!state.configured) {
setStatus("Configure access", state.error || "Set the API token in extension settings.", "warning");
renderMatches({ matches: [] });
return;
}
if (state.pendingFill) {
setStatus("Approval needed", state.pendingMessage || "Approve or deny the fill request in KeePassGO.", "warning");
renderMatches(state);
return;
}
if (!state.success) {
setStatus("KeePassGO unavailable", state.error || "The native host could not reach KeePassGO.", "error");
renderMatches(state);
return;
}
if (state.status?.locked) {
setStatus("Vault locked", "Unlock KeePassGO, then try the page again.", "warning");
renderMatches(state);
return;
}
const count = Array.isArray(state.matches) ? state.matches.length : 0;
if (!state.pageHasLoginForm) {
setStatus("Ready", "KeePassGO is connected. Open a login form to check for matches.", "ready");
} else if (count === 0) {
setStatus("Checked this page", "KeePassGO did not find a matching login for this form.", "ready");
} else {
setStatus("Page suggestions ready", count === 1 ? "1 matching entry is ready on this page." : `${count} matching entries are ready on this page.`, "ready");
}
renderMatches(state);
} catch (error) {
setStatus("Error", error instanceof Error ? error.message : String(error), "error");
renderMatches({ matches: [] });
}
}
void main();
+178
View File
@@ -0,0 +1,178 @@
:root {
color-scheme: light;
--ink: #214f44;
--ink-soft: #4d6d66;
--surface: #fffdfa;
--surface-2: #f2f7f3;
--line: #d7e3dc;
--accent: #255f4a;
--accent-soft: #dfeee6;
--warn: #9f5f0e;
--error: #9f2f2f;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
font: 14px/1.4 "Noto Sans", "Liberation Sans", sans-serif;
color: var(--ink);
background:
radial-gradient(circle at top right, #ecf5ef, transparent 38%),
linear-gradient(180deg, #f8fbf8, #eef4f0);
}
body.popup {
min-width: 360px;
}
.surface {
padding: 16px;
}
.topbar {
display: flex;
align-items: start;
justify-content: space-between;
gap: 12px;
margin-bottom: 16px;
}
h1,
h2,
p {
margin: 0;
}
h1 {
font-size: 22px;
line-height: 1.1;
}
h2 {
font-size: 14px;
text-transform: uppercase;
letter-spacing: 0.08em;
margin-bottom: 8px;
color: var(--ink-soft);
}
.subtle {
color: var(--ink-soft);
}
.status-card {
padding: 12px 14px;
border-radius: 12px;
border: 1px solid var(--line);
background: var(--surface);
margin-bottom: 16px;
}
.status-card[data-tone="ready"] {
border-color: #c5dccf;
background: var(--accent-soft);
}
.status-card[data-tone="warning"] {
border-color: #e4d0ae;
background: #fbf4e7;
}
.status-card[data-tone="error"] {
border-color: #e4bcbc;
background: #fcf1f1;
}
.inline-hint {
margin: -6px 0 16px;
}
.match-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.match-row,
button,
.link-button {
appearance: none;
border: 0;
border-radius: 12px;
background: var(--surface);
color: var(--ink);
text-decoration: none;
}
.match-row {
display: flex;
width: 100%;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 12px 14px;
border: 1px solid var(--line);
cursor: pointer;
}
.match-row:hover,
button:hover,
.link-button:hover {
background: var(--surface-2);
}
.match-main {
display: flex;
flex-direction: column;
align-items: start;
text-align: left;
}
.quality {
font-size: 12px;
color: var(--ink-soft);
text-transform: uppercase;
}
.settings {
max-width: 720px;
margin: 0 auto;
min-height: 100vh;
}
.settings-form {
display: grid;
gap: 16px;
}
label {
display: grid;
gap: 8px;
}
input,
textarea {
width: 100%;
padding: 10px 12px;
border: 1px solid var(--line);
border-radius: 10px;
background: #fff;
color: var(--ink);
font: inherit;
}
button,
.link-button {
padding: 10px 14px;
background: var(--accent);
color: #fff;
cursor: pointer;
}
.actions {
display: flex;
justify-content: end;
}
+166
View File
@@ -0,0 +1,166 @@
package main
import (
"context"
"encoding/json"
"flag"
"fmt"
"os"
"runtime"
"strings"
"git.julianfamily.org/keepassgo/internal/browserbridge"
"git.julianfamily.org/keepassgo/internal/grpcaddr"
"google.golang.org/grpc"
)
type bridgeConfig struct {
grpcAddr string
}
func main() {
cfg := bridgeConfig{
grpcAddr: resolveGlobalGRPCAddr(os.Args[1:]),
}
if len(os.Args) > 1 {
switch strings.TrimSpace(os.Args[1]) {
case "install-native-host":
if err := runInstallNativeHost(os.Args[2:]); err != nil {
fail(err)
}
return
case "status":
if err := runStatus(cfg, stripGlobalGRPCAddrFlags(os.Args[2:])); err != nil {
fail(err)
}
return
}
}
if err := runNativeMessage(cfg); err != nil {
_ = browserbridge.WriteResponse(os.Stdout, browserbridge.Response{Success: false, Error: err.Error()})
os.Exit(1)
}
}
func runInstallNativeHost(args []string) error {
fs := flag.NewFlagSet("install-native-host", flag.ContinueOnError)
browserName := fs.String("browser", string(browserbridge.BrowserFirefox), "target browser: firefox, chrome, chromium")
binaryPath := fs.String("binary", "", "path to keepassgo-browser-bridge binary")
extensionID := fs.String("extension-id", "", "browser extension id (required for chrome/chromium)")
extensionKey := fs.String("extension-key", "", "Chromium manifest public key used to derive a fixed extension id")
extensionKeyFile := fs.String("extension-key-file", "", "path to a Chromium manifest public key file")
outputPath := fs.String("output", "", "native host manifest output path")
if err := fs.Parse(args); err != nil {
return err
}
path := strings.TrimSpace(*binaryPath)
if path == "" {
resolved, err := defaultBinaryPath()
if err != nil {
return err
}
path = resolved
}
resolvedExtensionID := strings.TrimSpace(*extensionID)
if resolvedExtensionID == "" {
keyValue := strings.TrimSpace(*extensionKey)
if keyValue == "" && strings.TrimSpace(*extensionKeyFile) != "" {
data, err := os.ReadFile(strings.TrimSpace(*extensionKeyFile))
if err != nil {
return err
}
keyValue = string(data)
}
if keyValue != "" {
derivedID, err := browserbridge.ChromiumExtensionIDFromManifestKey(keyValue)
if err != nil {
return err
}
resolvedExtensionID = derivedID
}
}
installed, err := browserbridge.InstallManifest(browserbridge.Browser(strings.TrimSpace(*browserName)), path, resolvedExtensionID, strings.TrimSpace(*outputPath))
if err != nil {
return err
}
fmt.Fprintln(os.Stdout, installed)
return nil
}
func runStatus(cfg bridgeConfig, args []string) error {
fs := flag.NewFlagSet("status", flag.ContinueOnError)
token := fs.String("token", "", "KeePassGO API bearer token")
if err := fs.Parse(args); err != nil {
return err
}
req := browserbridge.Request{
Action: "status",
BearerToken: strings.TrimSpace(*token),
}
conn, client, ctx, err := dialBridge(context.Background(), cfg, req)
if err != nil {
return err
}
defer func() { _ = conn.Close() }()
resp := browserbridge.HandleRequest(ctx, req, cfg.grpcAddr, client)
enc := json.NewEncoder(os.Stdout)
enc.SetIndent("", " ")
return enc.Encode(resp)
}
func runNativeMessage(cfg bridgeConfig) error {
req, err := browserbridge.ReadRequest(os.Stdin)
if err != nil {
return err
}
conn, client, ctx, err := dialBridge(context.Background(), cfg, req)
if err != nil {
return browserbridge.WriteResponse(os.Stdout, browserbridge.Response{Success: false, Error: err.Error()})
}
defer func() { _ = conn.Close() }()
return browserbridge.WriteResponse(os.Stdout, browserbridge.HandleRequest(ctx, req, cfg.grpcAddr, client))
}
func dialBridge(ctx context.Context, cfg bridgeConfig, req browserbridge.Request) (*grpc.ClientConn, *browserbridge.GRPCClient, context.Context, error) {
return browserbridge.DialRequest(ctx, req, cfg.grpcAddr)
}
func defaultBinaryPath() (string, error) {
return browserbridge.ResolveBridgeBinaryPath("")
}
func resolveGlobalGRPCAddr(args []string) string {
addr := grpcaddr.Default(runtime.GOOS)
for i := 0; i < len(args); i++ {
arg := strings.TrimSpace(args[i])
switch {
case arg == "--grpc-addr" && i+1 < len(args):
return strings.TrimSpace(args[i+1])
case strings.HasPrefix(arg, "--grpc-addr="):
return strings.TrimSpace(strings.TrimPrefix(arg, "--grpc-addr="))
}
}
return addr
}
func stripGlobalGRPCAddrFlags(args []string) []string {
out := make([]string, 0, len(args))
for i := 0; i < len(args); i++ {
arg := strings.TrimSpace(args[i])
switch {
case arg == "--grpc-addr" && i+1 < len(args):
i++
continue
case strings.HasPrefix(arg, "--grpc-addr="):
continue
default:
out = append(out, args[i])
}
}
return out
}
func fail(err error) {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
+125
View File
@@ -0,0 +1,125 @@
# Browser Extension
KeePassGO browser integration uses:
- the existing local gRPC API in KeePassGO
- API tokens for authorization
- a tiny native messaging host for browser-to-gRPC transport adaptation
The browser extension does **not** talk to vault files directly.
## Security Model
- KeePassGO remains the source of truth for authentication, authorization, approvals, and audit events.
- The browser extension stores the API token in browser extension storage.
- The native messaging host receives the token on each request from the extension.
- The native messaging host uses the token only to attach `authorization: Bearer ...` metadata to the local gRPC request.
- The native messaging host does not persist the token to disk.
The native messaging host is therefore part of the trusted client for that browser profile. Scope the API token accordingly.
## RPCs Used
The browser integration uses:
- `GetSessionStatus`
- `FindBrowserLogins`
- `GetBrowserCredential`
The browser feature intentionally stays on the same secure gRPC surface used by other trusted automation.
## Default Listener
On desktop KeePassGO listens on a Unix socket by default:
- primary location: under the user runtime directory
- fallback: `/run/user/<uid>` if present
- final fallback: a private directory under the system temp directory
Override the listener with `-grpc-addr` or `KEEPASSGO_GRPC_ADDR`, for example:
```bash
KEEPASSGO_GRPC_ADDR=tcp://127.0.0.1:47777 ./keepassgo
```
## Native Host
Build the bridge:
```bash
go build ./cmd/keepassgo-browser-bridge
```
On Linux desktop builds, KeePassGO now refreshes the user-scoped native messaging manifests on launch. That automatic update always installs the Firefox manifest and also installs Chrome or Chromium manifests when it finds an installed `KeePassGO Browser` extension in that browser profile. The Arch package also ships the extension assets under `/usr/share/keepassgo/browser-extension/`.
Install a Firefox native messaging manifest:
```bash
./keepassgo-browser-bridge install-native-host --browser firefox --binary /absolute/path/to/keepassgo-browser-bridge
```
Install a Chromium native messaging manifest:
```bash
./keepassgo-browser-bridge install-native-host --browser chromium --binary /absolute/path/to/keepassgo-browser-bridge --extension-key-file /path/to/chromium-extension-public-key.txt
```
Chrome and Chromium require the actual extension id in the native host manifest. KeePassGO can derive that id from the Chromium manifest public key so you do not have to type it separately.
For a fixed Chromium ID:
1. Keep a stable Chromium extension signing key outside the repo.
2. Add the corresponding public key to the Chromium manifest as `"key": "<base64-public-key>"`.
3. Use the same public key with `install-native-host --extension-key-file ...` so the native host manifest is locked to that stable extension ID.
## Extension Setup
Firefox:
1. Load `browser/extension/manifest.firefox.json` as a temporary add-on or package it as an extension.
2. Open the extension settings page.
3. Paste an API token scoped for browser login lookup and credential copy.
Chromium / Chrome:
1. Load a Chromium manifest based on `browser/extension/manifest.chromium.json`, or install the published extension when that distribution exists.
2. Start KeePassGO once so it can refresh the native host manifest for the discovered extension id.
3. Configure the API token in the extension settings page.
## Current Browser Flow
- The extension checks sign-in pages in the background and caches per-tab match state instead of waiting for the popup to be opened first.
- The toolbar badge shows when KeePassGO found matches for the current page.
- Username and password fields get an inline KeePassGO affordance that opens a candidate chooser anchored to the focused field and keeps fills scoped to that field's form when possible.
- If a fill request needs user approval, the extension keeps the pending state visible in both the page affordance and the popup until KeePassGO resolves it, using the token-scoped pending-approval count from the local gRPC API.
For extension-side regression checks, run:
```bash
node --test browser/extension/background.test.cjs browser/extension/content.test.cjs
```
For a reproducible real-browser Chromium validation harness, run:
```bash
make browser-extension-validate
```
That target:
- validates the Firefox flow by default with a temporary addon install
- can also validate Chromium with `make browser-extension-validate BROWSER=chromium`
- builds the native messaging bridge
- starts a stub KeePassGO gRPC server and a local login page
- drives the browser through inline match discovery, approval visibility, and fill completion
If validation fails, the script preserves its temporary workspace path so the captured HTML, screenshots, logs, and native-host files can be inspected.
## Required Token Scope
At minimum, the browser token should have policy rules allowing:
- `list_entries` for the groups you want the browser to search
- `copy_username` for entries the browser may fill
- `copy_password` for entries the browser may fill
- `copy_url` for entries the browser may confirm against page URL
+54 -4
View File
@@ -4,10 +4,13 @@ import (
"errors"
"fmt"
"net"
"os"
"path/filepath"
"strings"
"sync"
"git.julianfamily.org/keepassgo/internal/clipboard"
"git.julianfamily.org/keepassgo/internal/grpcaddr"
"git.julianfamily.org/keepassgo/internal/passwords"
"git.julianfamily.org/keepassgo/internal/session"
"git.julianfamily.org/keepassgo/internal/vault"
@@ -27,6 +30,7 @@ type Host struct {
lastModel vault.Model
started bool
listenAddr string
socketPath string
}
func StartHost(addr string, lifecycle lifecycleBackend, profiles map[string]passwords.Profile, clipboardWriter clipboard.Writer, dirty DirtyProvider) (*Host, error) {
@@ -35,13 +39,17 @@ func StartHost(addr string, lifecycle lifecycleBackend, profiles map[string]pass
return nil, nil
}
listener, err := net.Listen("tcp", addr)
network, endpoint, err := grpcaddr.Parse(addr)
if err != nil {
return nil, err
}
listener, socketPath, err := listen(network, endpoint)
if err != nil {
return nil, fmt.Errorf("listen gRPC host %s: %w", addr, err)
}
service := NewServerWithLifecycle(vault.Model{}, profiles, clipboardWriter, lifecycle)
server := grpc.NewServer(grpc.UnaryInterceptor(AuthInterceptor(service)))
server := grpc.NewServer()
keepassgov1.RegisterVaultServiceServer(server, service)
host := &Host{
@@ -50,7 +58,8 @@ func StartHost(addr string, lifecycle lifecycleBackend, profiles map[string]pass
listener: listener,
lifecycle: lifecycle,
dirty: dirty,
listenAddr: listener.Addr().String(),
listenAddr: formatListenAddress(network, listener.Addr().String(), socketPath),
socketPath: socketPath,
started: true,
}
if err := host.SyncFromLifecycle(); err != nil && !errors.Is(err, session.ErrLocked) {
@@ -91,7 +100,16 @@ func (h *Host) Stop() error {
}
h.started = false
h.grpcServer.Stop()
return h.listener.Close()
err := h.listener.Close()
if errors.Is(err, net.ErrClosed) {
err = nil
}
if h.socketPath != "" {
if removeErr := os.Remove(h.socketPath); removeErr != nil && !errors.Is(removeErr, os.ErrNotExist) && err == nil {
err = removeErr
}
}
return err
}
func (h *Host) SyncFromLifecycle() error {
@@ -120,3 +138,35 @@ func (h *Host) SyncFromLifecycle() error {
h.server.SetSessionState(h.lastModel, locked, dirty)
return nil
}
func listen(network, endpoint string) (net.Listener, string, error) {
if network == "unix" {
if err := os.MkdirAll(filepath.Dir(endpoint), 0o700); err != nil {
return nil, "", err
}
if err := os.Remove(endpoint); err != nil && !errors.Is(err, os.ErrNotExist) {
return nil, "", err
}
listener, err := net.Listen("unix", endpoint)
if err != nil {
return nil, "", err
}
if err := os.Chmod(endpoint, 0o600); err != nil {
_ = listener.Close()
return nil, "", err
}
return listener, endpoint, nil
}
listener, err := net.Listen(network, endpoint)
if err != nil {
return nil, "", err
}
return listener, "", nil
}
func formatListenAddress(network, listenerAddr, socketPath string) string {
if network == "unix" {
return "unix://" + socketPath
}
return listenerAddr
}
+53 -2
View File
@@ -2,9 +2,13 @@ package api
import (
"context"
"errors"
"net"
"os"
"testing"
"git.julianfamily.org/keepassgo/internal/apitokens"
"git.julianfamily.org/keepassgo/internal/grpcaddr"
"git.julianfamily.org/keepassgo/internal/passwords"
"git.julianfamily.org/keepassgo/internal/session"
"git.julianfamily.org/keepassgo/internal/vault"
@@ -19,7 +23,16 @@ func TestStartHostServesVaultLifecycleAndSyncsSessionState(t *testing.T) {
lifecycle := &session.Manager{}
if err := lifecycle.Create(vault.Model{
Entries: []vault.Entry{
testAPITokenEntry(t),
testAPITokenEntry(t,
apitokens.PolicyRule{
Effect: apitokens.EffectAllow,
Operation: apitokens.OperationManageVault,
Resource: apitokens.Resource{
Kind: apitokens.ResourceGroup,
Path: []string{"Root"},
},
},
),
{ID: "entry-1", Title: "Vault Console", Path: []string{"Root", "Internet"}},
},
}, vault.MasterKey{Password: "correct horse battery staple"}); err != nil {
@@ -32,10 +45,14 @@ func TestStartHostServesVaultLifecycleAndSyncsSessionState(t *testing.T) {
}
defer func() { _ = host.Stop() }()
network, endpoint, err := grpcaddr.Parse(host.Address())
if err != nil {
t.Fatalf("Parse(host.Address()) error = %v", err)
}
conn, err := grpc.NewClient("passthrough:///"+host.Address(),
grpc.WithTransportCredentials(insecure.NewCredentials()),
grpc.WithContextDialer(func(context.Context, string) (net.Conn, error) {
return net.Dial("tcp", host.Address())
return net.Dial(network, endpoint)
}),
)
if err != nil {
@@ -70,3 +87,37 @@ func TestStartHostServesVaultLifecycleAndSyncsSessionState(t *testing.T) {
t.Fatal("GetSessionStatus().Locked = false, want true after lifecycle lock")
}
}
func TestStartHostServesOverUnixSocket(t *testing.T) {
t.Parallel()
socketDir := t.TempDir()
socketPath := socketDir + "/keepassgo.sock"
lifecycle := &session.Manager{}
if err := lifecycle.Create(vault.Model{
Entries: []vault.Entry{
testAPITokenEntry(t,
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationManageVault, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root"}}},
),
},
}, vault.MasterKey{Password: "correct horse battery staple"}); err != nil {
t.Fatalf("Create() error = %v", err)
}
host, err := StartHost("unix://"+socketPath, lifecycle, passwords.DefaultProfiles(), nil, func() bool { return false })
if err != nil {
t.Fatalf("StartHost() error = %v", err)
}
if got := host.Address(); got != "unix://"+socketPath {
t.Fatalf("host.Address() = %q, want %q", got, "unix://"+socketPath)
}
if _, err := os.Stat(socketPath); err != nil {
t.Fatalf("Stat(socketPath) error = %v", err)
}
if err := host.Stop(); err != nil {
t.Fatalf("Stop() error = %v", err)
}
if _, err := os.Stat(socketPath); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("socket exists after Stop(), err = %v, want not-exist", err)
}
}
+487 -117
View File
@@ -3,7 +3,9 @@ package api
import (
"context"
"errors"
"fmt"
"maps"
"net/url"
"os"
"slices"
"strings"
@@ -17,9 +19,9 @@ import (
"git.julianfamily.org/keepassgo/internal/passwords"
"git.julianfamily.org/keepassgo/internal/session"
"git.julianfamily.org/keepassgo/internal/vault"
"git.julianfamily.org/keepassgo/internal/vaultview"
"git.julianfamily.org/keepassgo/internal/webdav"
keepassgov1 "git.julianfamily.org/keepassgo/proto/keepassgo/v1"
"google.golang.org/grpc"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/metadata"
"google.golang.org/grpc/status"
@@ -37,6 +39,7 @@ type Server struct {
clipboard clipboard.Writer
approvals *apiapproval.Broker
audit *apiaudit.Log
notify func()
}
type lifecycleBackend interface {
@@ -53,6 +56,13 @@ type modelReplaceableLifecycle interface {
Replace(vault.Model)
}
type rankedBrowserMatch struct {
match *keepassgov1.BrowserLoginMatch
score int
resource apitokens.Resource
decision apitokens.Decision
}
func NewServer(model vault.Model, profiles map[string]passwords.Profile, clipboardWriter clipboard.Writer) *Server {
return &Server{
model: model,
@@ -77,6 +87,15 @@ func (s *Server) AuditLog() *apiaudit.Log {
return s.audit
}
func (s *Server) SetChangeNotifier(notify func()) {
if s == nil {
return
}
s.mu.Lock()
defer s.mu.Unlock()
s.notify = notify
}
func (s *Server) ResolveApproval(id string, outcome apiapproval.Outcome) (apiapproval.Request, *apitokens.PolicyRule, error) {
return s.approvals.Resolve(id, outcome)
}
@@ -89,18 +108,37 @@ func (s *Server) SetSessionState(model vault.Model, locked, dirty bool) {
s.dirty = dirty
}
func (s *Server) GetSessionStatus(_ context.Context, _ *keepassgov1.GetSessionStatusRequest) (*keepassgov1.GetSessionStatusResponse, error) {
func (s *Server) GetSessionStatus(ctx context.Context, _ *keepassgov1.GetSessionStatusRequest) (*keepassgov1.GetSessionStatusResponse, error) {
token, err := s.authenticateRequest(ctx)
if err != nil {
return nil, err
}
pendingApprovals := s.approvals.Pending()
var tokenPending uint32
for _, pending := range pendingApprovals {
if pending.TokenID == token.ID {
tokenPending++
}
}
s.mu.RLock()
defer s.mu.RUnlock()
locked := s.locked
dirty := s.dirty
entryCount := uint32(len(s.model.Entries))
s.mu.RUnlock()
return &keepassgov1.GetSessionStatusResponse{
Locked: s.locked,
Dirty: s.dirty,
EntryCount: uint32(len(s.model.Entries)),
Locked: locked,
Dirty: dirty,
EntryCount: entryCount,
PendingApprovalCount: uint32(len(pendingApprovals)),
TokenPendingApprovalCount: tokenPending,
}, nil
}
func (s *Server) OpenVault(_ context.Context, req *keepassgov1.OpenVaultRequest) (*keepassgov1.OpenVaultResponse, error) {
func (s *Server) OpenVault(ctx context.Context, req *keepassgov1.OpenVaultRequest) (*keepassgov1.OpenVaultResponse, error) {
if _, err := s.authorizeVaultRequest(ctx, apitokens.OperationManageVault); err != nil {
return nil, err
}
if s.lifecycle == nil {
return nil, status.Error(codes.FailedPrecondition, "vault lifecycle backend is not configured")
}
@@ -124,7 +162,10 @@ func (s *Server) OpenVault(_ context.Context, req *keepassgov1.OpenVaultRequest)
return &keepassgov1.OpenVaultResponse{}, nil
}
func (s *Server) OpenRemoteVault(_ context.Context, req *keepassgov1.OpenRemoteVaultRequest) (*keepassgov1.OpenRemoteVaultResponse, error) {
func (s *Server) OpenRemoteVault(ctx context.Context, req *keepassgov1.OpenRemoteVaultRequest) (*keepassgov1.OpenRemoteVaultResponse, error) {
if _, err := s.authorizeVaultRequest(ctx, apitokens.OperationManageVault); err != nil {
return nil, err
}
if s.lifecycle == nil {
return nil, status.Error(codes.FailedPrecondition, "vault lifecycle backend is not configured")
}
@@ -153,7 +194,10 @@ func (s *Server) OpenRemoteVault(_ context.Context, req *keepassgov1.OpenRemoteV
return &keepassgov1.OpenRemoteVaultResponse{}, nil
}
func (s *Server) SaveVault(_ context.Context, _ *keepassgov1.SaveVaultRequest) (*keepassgov1.SaveVaultResponse, error) {
func (s *Server) SaveVault(ctx context.Context, _ *keepassgov1.SaveVaultRequest) (*keepassgov1.SaveVaultResponse, error) {
if _, err := s.authorizeVaultRequest(ctx, apitokens.OperationManageVault); err != nil {
return nil, err
}
if s.lifecycle == nil {
return nil, status.Error(codes.FailedPrecondition, "vault lifecycle backend is not configured")
}
@@ -169,7 +213,10 @@ func (s *Server) SaveVault(_ context.Context, _ *keepassgov1.SaveVaultRequest) (
return &keepassgov1.SaveVaultResponse{}, nil
}
func (s *Server) LockVault(_ context.Context, _ *keepassgov1.LockVaultRequest) (*keepassgov1.LockVaultResponse, error) {
func (s *Server) LockVault(ctx context.Context, _ *keepassgov1.LockVaultRequest) (*keepassgov1.LockVaultResponse, error) {
if _, err := s.authorizeVaultRequest(ctx, apitokens.OperationManageVault); err != nil {
return nil, err
}
if s.lifecycle == nil {
return nil, status.Error(codes.FailedPrecondition, "vault lifecycle backend is not configured")
}
@@ -185,7 +232,10 @@ func (s *Server) LockVault(_ context.Context, _ *keepassgov1.LockVaultRequest) (
return &keepassgov1.LockVaultResponse{}, nil
}
func (s *Server) UnlockVault(_ context.Context, req *keepassgov1.UnlockVaultRequest) (*keepassgov1.UnlockVaultResponse, error) {
func (s *Server) UnlockVault(ctx context.Context, req *keepassgov1.UnlockVaultRequest) (*keepassgov1.UnlockVaultResponse, error) {
if _, err := s.authorizeVaultRequest(ctx, apitokens.OperationManageVault); err != nil {
return nil, err
}
if s.lifecycle == nil {
return nil, status.Error(codes.FailedPrecondition, "vault lifecycle backend is not configured")
}
@@ -208,6 +258,172 @@ func (s *Server) UnlockVault(_ context.Context, req *keepassgov1.UnlockVaultRequ
return &keepassgov1.UnlockVaultResponse{}, nil
}
func (s *Server) FindBrowserLogins(ctx context.Context, req *keepassgov1.FindBrowserLoginsRequest) (*keepassgov1.FindBrowserLoginsResponse, error) {
model, locked := s.snapshotModel()
if locked {
return nil, status.Error(codes.FailedPrecondition, "vault is locked")
}
displayModel := visibleModel(model)
token, err := s.authenticateRequest(ctx)
if err != nil {
return nil, err
}
pageHost, err := normalizedBrowserHost(req.GetPageUrl())
if err != nil {
return nil, status.Error(codes.InvalidArgument, err.Error())
}
var matches []rankedBrowserMatch
for _, entry := range displayModel.Entries {
quality, score := classifyBrowserEntryMatch(pageHost, entry.URL)
if score == 0 {
continue
}
resource := apitokens.Resource{Kind: apitokens.ResourceGroup, Path: entry.Path}
matches = append(matches, rankedBrowserMatch{
match: &keepassgov1.BrowserLoginMatch{
Id: entry.ID,
Title: entry.Title,
Username: entry.Username,
Url: entry.URL,
Path: append([]string(nil), entry.Path...),
Quality: quality,
},
score: score,
resource: resource,
decision: apitokens.Evaluate(token, apitokens.OperationListEntries, resource),
})
}
slices.SortFunc(matches, func(a, b rankedBrowserMatch) int {
switch {
case a.score != b.score:
return b.score - a.score
case a.match.GetTitle() != b.match.GetTitle():
return strings.Compare(a.match.GetTitle(), b.match.GetTitle())
case a.match.GetUsername() != b.match.GetUsername():
return strings.Compare(a.match.GetUsername(), b.match.GetUsername())
default:
return strings.Compare(a.match.GetId(), b.match.GetId())
}
})
out, err := s.authorizedBrowserMatches(ctx, token, matches)
if err != nil {
return nil, err
}
switch len(out) {
case 1:
s.audit.Record(apiaudit.Event{
Type: apiaudit.EventAutofillFound,
TokenID: token.ID,
TokenName: token.Name,
ClientName: token.ClientName,
Operation: apitokens.OperationListEntries,
Message: "browser login match found for " + pageHost,
})
case 2, 3, 4, 5:
s.audit.Record(apiaudit.Event{
Type: apiaudit.EventAutofillAmbiguous,
TokenID: token.ID,
TokenName: token.Name,
ClientName: token.ClientName,
Operation: apitokens.OperationListEntries,
Message: "browser login search returned multiple matches for " + pageHost,
})
}
return &keepassgov1.FindBrowserLoginsResponse{Matches: out}, nil
}
func (s *Server) authorizedBrowserMatches(ctx context.Context, token apitokens.Token, matches []rankedBrowserMatch) ([]*keepassgov1.BrowserLoginMatch, error) {
out := make([]*keepassgov1.BrowserLoginMatch, 0, len(matches))
for _, match := range matches {
if match.decision == apitokens.DecisionAllow {
out = append(out, match.match)
}
}
if len(out) != 0 {
return out, nil
}
for _, match := range matches {
if match.decision != apitokens.DecisionPrompt {
continue
}
if _, err := s.authorizeResourceRequest(ctx, token, apitokens.OperationListEntries, match.resource); err != nil {
return nil, err
}
return s.authorizedBrowserMatchesWithinPath(ctx, token, matches, match.resource.Path)
}
return out, nil
}
func (s *Server) authorizedBrowserMatchesWithinPath(_ context.Context, _ apitokens.Token, matches []rankedBrowserMatch, path []string) ([]*keepassgov1.BrowserLoginMatch, error) {
out := make([]*keepassgov1.BrowserLoginMatch, 0, len(matches))
for _, match := range matches {
if len(path) > len(match.resource.Path) {
continue
}
if !slices.Equal(path, match.resource.Path[:len(path)]) {
continue
}
if match.decision == apitokens.DecisionDeny {
continue
}
out = append(out, match.match)
}
return out, nil
}
func (s *Server) GetBrowserCredential(ctx context.Context, req *keepassgov1.GetBrowserCredentialRequest) (*keepassgov1.GetBrowserCredentialResponse, error) {
model, locked := s.snapshotModel()
if locked {
return nil, status.Error(codes.FailedPrecondition, "vault is locked")
}
token, err := s.authenticateRequest(ctx)
if err != nil {
return nil, err
}
entry, err := findEntryByID(model, req.GetId())
if err != nil {
return nil, status.Error(codes.NotFound, err.Error())
}
if pageURL := strings.TrimSpace(req.GetPageUrl()); pageURL != "" {
pageHost, err := normalizedBrowserHost(pageURL)
if err != nil {
return nil, status.Error(codes.InvalidArgument, err.Error())
}
if _, score := classifyBrowserEntryMatch(pageHost, entry.URL); score == 0 {
return nil, status.Error(codes.InvalidArgument, "entry url does not match requested page")
}
}
if strings.TrimSpace(entry.Username) != "" {
if _, err := s.authorizeResourceRequest(ctx, token, apitokens.OperationCopyUsername, apitokens.Resource{Kind: apitokens.ResourceEntry, EntryID: entry.ID, Path: entry.Path}); err != nil {
return nil, err
}
}
if _, err := s.authorizeResourceRequest(ctx, token, apitokens.OperationCopyPassword, apitokens.Resource{Kind: apitokens.ResourceEntry, EntryID: entry.ID, Path: entry.Path}); err != nil {
return nil, err
}
if strings.TrimSpace(entry.URL) != "" {
if _, err := s.authorizeResourceRequest(ctx, token, apitokens.OperationCopyURL, apitokens.Resource{Kind: apitokens.ResourceEntry, EntryID: entry.ID, Path: entry.Path}); err != nil {
return nil, err
}
}
s.audit.Record(apiaudit.Event{
Type: apiaudit.EventAutofillFound,
TokenID: token.ID,
TokenName: token.Name,
ClientName: token.ClientName,
Operation: apitokens.OperationCopyPassword,
Resource: apitokens.Resource{Kind: apitokens.ResourceEntry, EntryID: entry.ID, Path: entry.Path},
Message: "browser credential returned for " + entry.ID,
})
return &keepassgov1.GetBrowserCredentialResponse{
Id: entry.ID,
Username: entry.Username,
Password: entry.Password,
Url: entry.URL,
}, nil
}
func mapLifecycleError(operation string, err error) error {
switch {
case errors.Is(err, os.ErrNotExist):
@@ -228,11 +444,13 @@ func (s *Server) ListEntries(ctx context.Context, req *keepassgov1.ListEntriesRe
if locked {
return nil, status.Error(codes.FailedPrecondition, "vault is locked")
}
if _, err := s.authorizePathRequest(ctx, apitokens.OperationListEntries, req.GetPath()); err != nil {
displayModel := visibleModel(model)
internalPath := expandClientPath(displayModel, req.GetPath())
if _, err := s.authorizePathRequest(ctx, apitokens.OperationListEntries, internalPath); err != nil {
return nil, err
}
model = visibleModel(model)
model = displayModel
var entries []vault.Entry
if strings.TrimSpace(req.GetQuery()) != "" {
results := model.Search(req.GetQuery())
@@ -241,14 +459,14 @@ func (s *Server) ListEntries(ctx context.Context, req *keepassgov1.ListEntriesRe
entries = append(entries, result.Entry)
}
} else {
entries = model.EntriesInPath(req.GetPath())
entries = model.EntriesInPath(internalPath)
}
resp := &keepassgov1.ListEntriesResponse{
Entries: make([]*keepassgov1.Entry, 0, len(entries)),
}
for _, entry := range entries {
resp.Entries = append(resp.Entries, entryToProto(entry))
resp.Entries = append(resp.Entries, entryToProtoWithModel(model, entry))
}
return resp, nil
@@ -259,76 +477,86 @@ func (s *Server) ListGroups(ctx context.Context, req *keepassgov1.ListGroupsRequ
if locked {
return nil, status.Error(codes.FailedPrecondition, "vault is locked")
}
if _, err := s.authorizePathRequest(ctx, apitokens.OperationListGroups, req.GetPath()); err != nil {
displayModel := visibleModel(model)
internalPath := expandClientPath(displayModel, req.GetPath())
if _, err := s.authorizePathRequest(ctx, apitokens.OperationListGroups, internalPath); err != nil {
return nil, err
}
return &keepassgov1.ListGroupsResponse{
Names: visibleModel(model).ChildGroups(req.GetPath()),
Names: displayModel.ChildGroups(internalPath),
}, nil
}
func (s *Server) CreateGroup(ctx context.Context, req *keepassgov1.CreateGroupRequest) (*keepassgov1.CreateGroupResponse, error) {
if _, err := s.authorizePathRequest(ctx, apitokens.OperationMutateGroup, req.GetParentPath()); err != nil {
return nil, err
func (s *Server) mutateAuthorizedVisiblePath(ctx context.Context, clientPath []string, op apitokens.Operation, mutate func(*vault.Model, []string) error) error {
model, locked := s.snapshotModel()
if locked {
return status.Error(codes.FailedPrecondition, "vault is locked")
}
internalPath := expandClientPath(visibleModel(model), clientPath)
if _, err := s.authorizePathRequest(ctx, op, internalPath); err != nil {
return err
}
return s.mutateAuthorizedModel(func() error { return nil }, func(model *vault.Model) error {
return mutate(model, internalPath)
})
}
func (s *Server) mutateAuthorizedModel(authorize func() error, mutate func(*vault.Model) error) error {
if err := authorize(); err != nil {
return err
}
s.mu.Lock()
defer s.mu.Unlock()
if s.locked {
return nil, status.Error(codes.FailedPrecondition, "vault is locked")
if err := mutate(&s.model); err != nil {
return err
}
s.model.CreateGroup(req.GetParentPath(), req.GetName())
s.dirty = true
s.syncMutationLocked()
return nil
}
func (s *Server) CreateGroup(ctx context.Context, req *keepassgov1.CreateGroupRequest) (*keepassgov1.CreateGroupResponse, error) {
if err := s.mutateAuthorizedVisiblePath(ctx, req.GetParentPath(), apitokens.OperationMutateGroup, func(model *vault.Model, parentPath []string) error {
model.CreateGroup(parentPath, req.GetName())
return nil
}); err != nil {
return nil, err
}
return &keepassgov1.CreateGroupResponse{}, nil
}
func (s *Server) RenameGroup(ctx context.Context, req *keepassgov1.RenameGroupRequest) (*keepassgov1.RenameGroupResponse, error) {
if _, err := s.authorizePathRequest(ctx, apitokens.OperationMutateGroup, req.GetPath()); err != nil {
if err := s.mutateAuthorizedVisiblePath(ctx, req.GetPath(), apitokens.OperationMutateGroup, func(model *vault.Model, groupPath []string) error {
if err := model.RenameGroup(groupPath, req.GetNewName()); err != nil {
if errors.Is(err, vault.ErrEntryNotFound) {
return status.Error(codes.NotFound, err.Error())
}
return status.Errorf(codes.Internal, "rename group: %v", err)
}
return nil
}); err != nil {
return nil, err
}
s.mu.Lock()
defer s.mu.Unlock()
if s.locked {
return nil, status.Error(codes.FailedPrecondition, "vault is locked")
}
if err := s.model.RenameGroup(req.GetPath(), req.GetNewName()); err != nil {
if errors.Is(err, vault.ErrEntryNotFound) {
return nil, status.Error(codes.NotFound, err.Error())
}
return nil, status.Errorf(codes.Internal, "rename group: %v", err)
}
s.dirty = true
return &keepassgov1.RenameGroupResponse{}, nil
}
func (s *Server) DeleteGroup(ctx context.Context, req *keepassgov1.DeleteGroupRequest) (*keepassgov1.DeleteGroupResponse, error) {
if _, err := s.authorizePathRequest(ctx, apitokens.OperationMutateGroup, req.GetPath()); err != nil {
if err := s.mutateAuthorizedVisiblePath(ctx, req.GetPath(), apitokens.OperationMutateGroup, func(model *vault.Model, groupPath []string) error {
if err := model.DeleteGroup(groupPath); err != nil {
switch {
case errors.Is(err, vault.ErrEntryNotFound):
return status.Error(codes.NotFound, err.Error())
case errors.Is(err, vault.ErrGroupNotEmpty):
return status.Error(codes.FailedPrecondition, err.Error())
default:
return status.Errorf(codes.Internal, "delete group: %v", err)
}
}
return nil
}); err != nil {
return nil, err
}
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
}
@@ -337,21 +565,22 @@ func (s *Server) UpsertEntry(ctx context.Context, req *keepassgov1.UpsertEntryRe
return nil, status.Error(codes.InvalidArgument, "missing entry")
}
entry := entryFromProto(req.GetEntry())
if _, err := s.authorizeEntryRequest(ctx, apitokens.OperationMutateEntry, entry); err != nil {
model, locked := s.snapshotModel()
if locked {
return nil, status.Error(codes.FailedPrecondition, "vault is locked")
}
entry := entryFromProtoWithModel(visibleModel(model), req.GetEntry())
if _, err := s.authorizeUpsertEntryRequest(ctx, entry); err != nil {
return nil, err
}
if err := s.mutateAuthorizedModel(func() error { return nil }, func(model *vault.Model) error {
model.UpsertEntry(entry)
return nil
}); err != nil {
return nil, err
}
s.mu.Lock()
if s.locked {
s.mu.Unlock()
return nil, status.Error(codes.FailedPrecondition, "vault is locked")
}
s.model.UpsertEntry(entry)
s.dirty = true
s.mu.Unlock()
return &keepassgov1.UpsertEntryResponse{Entry: entryToProto(entry)}, nil
return &keepassgov1.UpsertEntryResponse{Entry: entryToProtoWithModel(visibleModel(model), entry)}, nil
}
func (s *Server) DeleteEntry(ctx context.Context, req *keepassgov1.DeleteEntryRequest) (*keepassgov1.DeleteEntryResponse, error) {
@@ -376,6 +605,7 @@ func (s *Server) DeleteEntry(ctx context.Context, req *keepassgov1.DeleteEntryRe
}
s.dirty = true
s.syncMutationLocked()
return &keepassgov1.DeleteEntryResponse{}, nil
}
@@ -409,7 +639,8 @@ func (s *Server) RestoreEntry(ctx context.Context, req *keepassgov1.RestoreEntry
}
s.dirty = true
return &keepassgov1.RestoreEntryResponse{Entry: entryToProto(restored)}, nil
s.syncMutationLocked()
return &keepassgov1.RestoreEntryResponse{Entry: entryToProtoWithModel(visibleModel(model), restored)}, nil
}
func (s *Server) ListEntryHistory(ctx context.Context, req *keepassgov1.ListEntryHistoryRequest) (*keepassgov1.ListEntryHistoryResponse, error) {
@@ -430,7 +661,7 @@ func (s *Server) ListEntryHistory(ctx context.Context, req *keepassgov1.ListEntr
Entries: make([]*keepassgov1.Entry, 0, len(entry.History)),
}
for _, historical := range entry.History {
resp.Entries = append(resp.Entries, entryToProto(historical))
resp.Entries = append(resp.Entries, entryToProtoWithModel(visibleModel(model), historical))
}
return resp, nil
}
@@ -462,10 +693,14 @@ func (s *Server) RestoreEntryHistory(ctx context.Context, req *keepassgov1.Resto
return nil, status.Error(codes.NotFound, err.Error())
}
s.dirty = true
return &keepassgov1.RestoreEntryHistoryResponse{Entry: entryToProto(entry)}, nil
s.syncMutationLocked()
return &keepassgov1.RestoreEntryHistoryResponse{Entry: entryToProtoWithModel(visibleModel(s.model), entry)}, nil
}
func (s *Server) ListTemplates(_ context.Context, _ *keepassgov1.ListTemplatesRequest) (*keepassgov1.ListTemplatesResponse, error) {
func (s *Server) ListTemplates(ctx context.Context, _ *keepassgov1.ListTemplatesRequest) (*keepassgov1.ListTemplatesResponse, error) {
if _, err := s.authorizeTemplateCollectionRequest(ctx, apitokens.OperationListTemplates); err != nil {
return nil, err
}
s.mu.RLock()
defer s.mu.RUnlock()
@@ -477,16 +712,19 @@ func (s *Server) ListTemplates(_ context.Context, _ *keepassgov1.ListTemplatesRe
Templates: make([]*keepassgov1.Entry, 0, len(s.model.Templates)),
}
for _, template := range s.model.Templates {
resp.Templates = append(resp.Templates, entryToProto(template))
resp.Templates = append(resp.Templates, entryToProtoWithModel(visibleModel(s.model), template))
}
return resp, nil
}
func (s *Server) UpsertTemplate(_ context.Context, req *keepassgov1.UpsertTemplateRequest) (*keepassgov1.UpsertTemplateResponse, error) {
func (s *Server) UpsertTemplate(ctx context.Context, req *keepassgov1.UpsertTemplateRequest) (*keepassgov1.UpsertTemplateResponse, error) {
if req.GetTemplate() == nil {
return nil, status.Error(codes.InvalidArgument, "missing template")
}
if _, err := s.authorizeTemplateRequest(ctx, apitokens.OperationMutateTemplate, req.GetTemplate().GetId()); err != nil {
return nil, err
}
s.mu.Lock()
defer s.mu.Unlock()
@@ -495,14 +733,18 @@ func (s *Server) UpsertTemplate(_ context.Context, req *keepassgov1.UpsertTempla
return nil, status.Error(codes.FailedPrecondition, "vault is locked")
}
entry := entryFromProto(req.GetTemplate())
entry := entryFromProtoWithModel(visibleModel(s.model), req.GetTemplate())
s.model.UpsertTemplate(entry)
s.dirty = true
s.syncMutationLocked()
return &keepassgov1.UpsertTemplateResponse{Template: entryToProto(entry)}, nil
return &keepassgov1.UpsertTemplateResponse{Template: entryToProtoWithModel(visibleModel(s.model), entry)}, nil
}
func (s *Server) DeleteTemplate(_ context.Context, req *keepassgov1.DeleteTemplateRequest) (*keepassgov1.DeleteTemplateResponse, error) {
func (s *Server) DeleteTemplate(ctx context.Context, req *keepassgov1.DeleteTemplateRequest) (*keepassgov1.DeleteTemplateResponse, error) {
if _, err := s.authorizeTemplateRequest(ctx, apitokens.OperationMutateTemplate, req.GetId()); err != nil {
return nil, err
}
s.mu.Lock()
defer s.mu.Unlock()
@@ -517,14 +759,22 @@ func (s *Server) DeleteTemplate(_ context.Context, req *keepassgov1.DeleteTempla
return nil, status.Errorf(codes.Internal, "delete template: %v", err)
}
s.dirty = true
s.syncMutationLocked()
return &keepassgov1.DeleteTemplateResponse{}, nil
}
func (s *Server) InstantiateTemplate(_ context.Context, req *keepassgov1.InstantiateTemplateRequest) (*keepassgov1.InstantiateTemplateResponse, error) {
func (s *Server) InstantiateTemplate(ctx context.Context, req *keepassgov1.InstantiateTemplateRequest) (*keepassgov1.InstantiateTemplateResponse, error) {
if req.GetOverrides() == nil {
return nil, status.Error(codes.InvalidArgument, "missing overrides")
}
if _, err := s.authorizeTemplateRequest(ctx, apitokens.OperationListTemplates, req.GetTemplateId()); err != nil {
return nil, err
}
overridePath := expandClientPath(visibleModel(s.model), req.GetOverrides().GetPath())
if _, err := s.authorizePathRequest(ctx, apitokens.OperationMutateEntry, overridePath); err != nil {
return nil, err
}
s.mu.Lock()
defer s.mu.Unlock()
@@ -533,7 +783,8 @@ func (s *Server) InstantiateTemplate(_ context.Context, req *keepassgov1.Instant
return nil, status.Error(codes.FailedPrecondition, "vault is locked")
}
entry, err := s.model.InstantiateTemplate(req.GetTemplateId(), entryFromProto(req.GetOverrides()))
overrides := entryFromProtoWithModel(visibleModel(s.model), req.GetOverrides())
entry, err := s.model.InstantiateTemplate(req.GetTemplateId(), overrides)
if err != nil {
if errors.Is(err, vault.ErrEntryNotFound) {
return nil, status.Error(codes.NotFound, err.Error())
@@ -542,7 +793,8 @@ func (s *Server) InstantiateTemplate(_ context.Context, req *keepassgov1.Instant
}
s.dirty = true
return &keepassgov1.InstantiateTemplateResponse{Entry: entryToProto(entry)}, nil
s.syncMutationLocked()
return &keepassgov1.InstantiateTemplateResponse{Entry: entryToProtoWithModel(visibleModel(s.model), entry)}, nil
}
func (s *Server) ListAttachments(ctx context.Context, req *keepassgov1.ListAttachmentsRequest) (*keepassgov1.ListAttachmentsResponse, error) {
@@ -594,6 +846,7 @@ func (s *Server) UploadAttachment(ctx context.Context, req *keepassgov1.UploadAt
entry.Attachments[req.GetName()] = append([]byte(nil), req.GetContent()...)
s.model.Entries[index] = entry
s.dirty = true
s.syncMutationLocked()
return &keepassgov1.UploadAttachmentResponse{}, nil
}
@@ -652,6 +905,7 @@ func (s *Server) DeleteAttachment(ctx context.Context, req *keepassgov1.DeleteAt
}
s.model.Entries[index] = entry
s.dirty = true
s.syncMutationLocked()
return &keepassgov1.DeleteAttachmentResponse{}, nil
}
@@ -685,12 +939,11 @@ func (s *Server) CopyEntryField(ctx context.Context, req *keepassgov1.CopyEntryF
}
func (s *Server) GeneratePassword(ctx context.Context, req *keepassgov1.GeneratePasswordRequest) (*keepassgov1.GeneratePasswordResponse, error) {
s.mu.RLock()
defer s.mu.RUnlock()
if _, err := s.authenticateRequest(ctx); err != nil {
if _, err := s.authorizeVaultRequest(ctx, apitokens.OperationGeneratePassword); err != nil {
return nil, err
}
s.mu.RLock()
defer s.mu.RUnlock()
if s.locked {
return nil, status.Error(codes.FailedPrecondition, "vault is locked")
@@ -709,7 +962,7 @@ func (s *Server) GeneratePassword(ctx context.Context, req *keepassgov1.Generate
return &keepassgov1.GeneratePasswordResponse{Password: password}, nil
}
func entryToProto(entry vault.Entry) *keepassgov1.Entry {
func entryToProtoWithModel(model vault.Model, entry vault.Entry) *keepassgov1.Entry {
return &keepassgov1.Entry{
Id: entry.ID,
Title: entry.Title,
@@ -718,12 +971,12 @@ func entryToProto(entry vault.Entry) *keepassgov1.Entry {
Url: entry.URL,
Notes: entry.Notes,
Tags: append([]string(nil), entry.Tags...),
Path: append([]string(nil), entry.Path...),
Path: collapseInternalPath(model, entry.Path),
Fields: maps.Clone(entry.Fields),
}
}
func entryFromProto(entry *keepassgov1.Entry) vault.Entry {
func entryFromProtoWithModel(model vault.Model, entry *keepassgov1.Entry) vault.Entry {
return vault.Entry{
ID: entry.GetId(),
Title: entry.GetTitle(),
@@ -732,11 +985,33 @@ func entryFromProto(entry *keepassgov1.Entry) vault.Entry {
URL: entry.GetUrl(),
Notes: entry.GetNotes(),
Tags: append([]string(nil), entry.GetTags()...),
Path: append([]string(nil), entry.GetPath()...),
Path: expandClientPath(model, entry.GetPath()),
Fields: maps.Clone(entry.GetFields()),
}
}
func expandClientPath(model vault.Model, path []string) []string {
root := vaultview.HiddenRoot(model)
if root == "" {
return append([]string(nil), path...)
}
if len(path) == 0 {
return []string{root}
}
if path[0] == root {
return append([]string(nil), path...)
}
return append([]string{root}, path...)
}
func collapseInternalPath(model vault.Model, path []string) []string {
root := vaultview.HiddenRoot(model)
if root == "" || len(path) == 0 || path[0] != root {
return append([]string(nil), path...)
}
return append([]string(nil), path[1:]...)
}
func findEntryByID(model vault.Model, id string) (vault.Entry, error) {
for _, entry := range model.Entries {
if entry.ID == id {
@@ -756,6 +1031,53 @@ func findMutableEntryByID(model *vault.Model, id string) (vault.Entry, int, erro
return vault.Entry{}, -1, vault.ErrEntryNotFound
}
func normalizedBrowserHost(raw string) (string, error) {
parsed, err := url.Parse(strings.TrimSpace(raw))
if err != nil {
return "", fmt.Errorf("parse page url: %w", err)
}
host := strings.ToLower(parsed.Hostname())
if host == "" {
return "", fmt.Errorf("page url must include a hostname")
}
return host, nil
}
func normalizedBrowserEntryHost(raw string) string {
raw = strings.TrimSpace(raw)
if raw == "" {
return ""
}
parsed, err := url.Parse(raw)
if err == nil {
if host := strings.ToLower(parsed.Hostname()); host != "" {
return host
}
}
if !strings.Contains(raw, "://") {
parsed, err = url.Parse("https://" + raw)
if err == nil {
return strings.ToLower(parsed.Hostname())
}
}
return ""
}
func classifyBrowserEntryMatch(pageHost, rawEntryURL string) (string, int) {
entryHost := normalizedBrowserEntryHost(rawEntryURL)
if entryHost == "" {
return "", 0
}
switch {
case pageHost == entryHost:
return "exact-host", 3
case strings.HasSuffix(pageHost, "."+entryHost):
return "subdomain", 2
default:
return "", 0
}
}
func visibleModel(model vault.Model) vault.Model {
out := model
out.Entries = nil
@@ -784,6 +1106,11 @@ func (s *Server) snapshotModel() (vault.Model, bool) {
var timeNow = func() time.Time { return time.Now().UTC() }
var (
vaultPolicyPath = []string{"Root"}
templatePolicyPath = []string{"Root", "Templates"}
)
func (s *Server) authenticateRequest(ctx context.Context) (apitokens.Token, error) {
md, ok := metadata.FromIncomingContext(ctx)
if !ok {
@@ -826,6 +1153,14 @@ func (s *Server) authorizePathRequest(ctx context.Context, op apitokens.Operatio
return s.authorizeResourceRequest(ctx, token, op, apitokens.Resource{Kind: apitokens.ResourceGroup, Path: path})
}
func (s *Server) authorizeVaultRequest(ctx context.Context, op apitokens.Operation) (apitokens.Token, error) {
return s.authorizePathRequest(ctx, op, vaultPolicyPath)
}
func (s *Server) authorizeTemplateCollectionRequest(ctx context.Context, op apitokens.Operation) (apitokens.Token, error) {
return s.authorizePathRequest(ctx, op, templatePolicyPath)
}
func (s *Server) authorizeEntryRequest(ctx context.Context, op apitokens.Operation, entry vault.Entry) (apitokens.Token, error) {
token, err := s.authenticateRequest(ctx)
if err != nil {
@@ -834,6 +1169,56 @@ func (s *Server) authorizeEntryRequest(ctx context.Context, op apitokens.Operati
return s.authorizeResourceRequest(ctx, token, op, apitokens.Resource{Kind: apitokens.ResourceEntry, EntryID: entry.ID, Path: entry.Path})
}
func (s *Server) authorizeUpsertEntryRequest(ctx context.Context, entry vault.Entry) (apitokens.Token, error) {
token, err := s.authenticateRequest(ctx)
if err != nil {
return apitokens.Token{}, err
}
model, locked := s.snapshotModel()
if locked {
return apitokens.Token{}, status.Error(codes.FailedPrecondition, "vault is locked")
}
existing, err := findEntryByID(model, entry.ID)
switch {
case err == nil:
if _, err := s.authorizeResourceRequest(ctx, token, apitokens.OperationMutateEntry, apitokens.Resource{
Kind: apitokens.ResourceEntry,
EntryID: existing.ID,
Path: existing.Path,
}); err != nil {
return apitokens.Token{}, err
}
if !slices.Equal(existing.Path, entry.Path) {
if _, err := s.authorizeResourceRequest(ctx, token, apitokens.OperationMutateEntry, apitokens.Resource{
Kind: apitokens.ResourceGroup,
Path: entry.Path,
}); err != nil {
return apitokens.Token{}, err
}
}
return token, nil
case errors.Is(err, vault.ErrEntryNotFound):
return s.authorizeResourceRequest(ctx, token, apitokens.OperationMutateEntry, apitokens.Resource{
Kind: apitokens.ResourceGroup,
Path: entry.Path,
})
default:
return apitokens.Token{}, status.Errorf(codes.Internal, "lookup existing entry: %v", err)
}
}
func (s *Server) authorizeTemplateRequest(ctx context.Context, op apitokens.Operation, templateID string) (apitokens.Token, error) {
token, err := s.authenticateRequest(ctx)
if err != nil {
return apitokens.Token{}, err
}
return s.authorizeResourceRequest(ctx, token, op, apitokens.Resource{
Kind: apitokens.ResourceEntry,
Path: templatePolicyPath,
EntryID: templateID,
})
}
func (s *Server) authorizeResourceRequest(ctx context.Context, token apitokens.Token, op apitokens.Operation, resource apitokens.Resource) (apitokens.Token, error) {
switch apitokens.Evaluate(token, op, resource) {
case apitokens.DecisionAllow:
@@ -922,14 +1307,21 @@ func (s *Server) persistApprovalRule(tokenID string, rule apitokens.PolicyRule)
}
s.model.Entries[i] = token.Entry(entry.Path)
s.dirty = true
if lifecycle, ok := s.lifecycle.(modelReplaceableLifecycle); ok {
lifecycle.Replace(s.model)
}
s.syncMutationLocked()
return nil
}
return status.Error(codes.NotFound, "api token entry not found")
}
func (s *Server) syncMutationLocked() {
if lifecycle, ok := s.lifecycle.(modelReplaceableLifecycle); ok {
lifecycle.Replace(s.model)
}
if s.notify != nil {
s.notify()
}
}
func hasPolicyRule(rules []apitokens.PolicyRule, target apitokens.PolicyRule) bool {
for _, rule := range rules {
if rule.Effect != target.Effect || rule.Operation != target.Operation {
@@ -955,25 +1347,3 @@ func copyOperation(target string) apitokens.Operation {
return apitokens.OperationCopyPassword
}
}
func AuthInterceptor(server *Server) grpc.UnaryServerInterceptor {
return func(
ctx context.Context,
req any,
info *grpc.UnaryServerInfo,
handler grpc.UnaryHandler,
) (any, error) {
switch info.FullMethod {
case "/keepassgo.v1.VaultService/GetSessionStatus",
"/keepassgo.v1.VaultService/OpenVault",
"/keepassgo.v1.VaultService/OpenRemoteVault",
"/keepassgo.v1.VaultService/SaveVault",
"/keepassgo.v1.VaultService/LockVault",
"/keepassgo.v1.VaultService/UnlockVault":
if _, err := server.authenticateRequest(ctx); err != nil {
return nil, err
}
}
return handler(ctx, req)
}
}
+627 -2
View File
@@ -5,8 +5,10 @@ import (
"context"
"crypto/sha256"
"encoding/hex"
"errors"
"net"
"os"
"slices"
"testing"
"time"
@@ -99,6 +101,529 @@ func TestVaultServiceRejectsUnauthorizedEntryAccess(t *testing.T) {
}
}
func TestVaultServiceAllowsSessionStatusWithoutManageVault(t *testing.T) {
t.Parallel()
client, _, cleanup := newTestClientForModel(t, vault.Model{
Entries: []vault.Entry{
testAPITokenEntry(t,
apitokens.PolicyRule{Effect: apitokens.EffectDeny, Operation: apitokens.OperationManageVault, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root"}}},
),
},
})
defer cleanup()
resp, err := client.GetSessionStatus(tokenContext(defaultTestTokenSecret), &keepassgov1.GetSessionStatusRequest{})
if err != nil {
t.Fatalf("GetSessionStatus() error = %v", err)
}
if resp.GetLocked() {
t.Fatal("GetSessionStatus().Locked = true, want false")
}
}
func TestVaultServiceSessionStatusIncludesPendingApprovalsForCurrentToken(t *testing.T) {
t.Parallel()
token, secret, err := apitokens.Issue("Browser Token", "browser-extension", nil, time.Date(2026, 4, 11, 12, 0, 0, 0, time.UTC))
if err != nil {
t.Fatalf("Issue() error = %v", err)
}
token.SecretHash = hashSecretForTest(secret)
otherToken, otherSecret, err := apitokens.Issue("Other Token", "automation-client", nil, time.Date(2026, 4, 11, 12, 1, 0, 0, time.UTC))
if err != nil {
t.Fatalf("Issue() other error = %v", err)
}
otherToken.SecretHash = hashSecretForTest(otherSecret)
client, _, service, cleanup := newTestHarnessForModel(t, vault.Model{
Entries: []vault.Entry{
token.Entry([]string{"Root", "API Tokens"}),
otherToken.Entry([]string{"Root", "API Tokens"}),
},
})
defer cleanup()
service.approvals = apiapproval.NewBroker(time.Minute)
ctx, cancel := context.WithCancel(tokenContext(secret))
defer cancel()
waiting := make(chan error, 1)
go func() {
_, err := service.approvals.Request(ctx, token, apitokens.OperationCopyPassword, apitokens.Resource{
Kind: apitokens.ResourceEntry,
EntryID: "vault-console",
Path: []string{"Root", "Internet"},
})
waiting <- err
}()
otherCtx, otherCancel := context.WithCancel(tokenContext(otherSecret))
defer otherCancel()
otherWaiting := make(chan error, 1)
go func() {
_, err := service.approvals.Request(otherCtx, otherToken, apitokens.OperationListEntries, apitokens.Resource{
Kind: apitokens.ResourceGroup,
Path: []string{"Root", "Shared"},
})
otherWaiting <- err
}()
waitForServerPendingApproval(t, service, 2)
resp, err := client.GetSessionStatus(tokenContext(secret), &keepassgov1.GetSessionStatusRequest{})
if err != nil {
t.Fatalf("GetSessionStatus() error = %v", err)
}
if got := resp.GetPendingApprovalCount(); got != 2 {
t.Fatalf("GetSessionStatus().PendingApprovalCount = %d, want 2", got)
}
if got := resp.GetTokenPendingApprovalCount(); got != 1 {
t.Fatalf("GetSessionStatus().TokenPendingApprovalCount = %d, want 1", got)
}
for _, pending := range waitForServerPendingApproval(t, service, 2) {
if _, _, err := service.ResolveApproval(pending.ID, apiapproval.OutcomeCancel); err != nil {
t.Fatalf("ResolveApproval(%q) error = %v", pending.ID, err)
}
}
if err := <-waiting; !errors.Is(err, apiapproval.ErrRequestCanceled) {
t.Fatalf("Request(token) error = %v, want %v", err, apiapproval.ErrRequestCanceled)
}
if err := <-otherWaiting; !errors.Is(err, apiapproval.ErrRequestCanceled) {
t.Fatalf("Request(otherToken) error = %v, want %v", err, apiapproval.ErrRequestCanceled)
}
}
func TestVaultServiceRejectsUnauthorizedTemplateMutation(t *testing.T) {
t.Parallel()
client, _, cleanup := newTestClientForModel(t, vault.Model{
Entries: []vault.Entry{
testAPITokenEntry(t,
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationListTemplates, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root", "Templates"}}},
apitokens.PolicyRule{Effect: apitokens.EffectDeny, Operation: apitokens.OperationMutateTemplate, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root", "Templates"}}},
),
},
Templates: []vault.Entry{
{ID: "website-login", Title: "Website Login", Path: []string{"Templates"}},
},
})
defer cleanup()
_, err := client.UpsertTemplate(tokenContext(defaultTestTokenSecret), &keepassgov1.UpsertTemplateRequest{
Template: &keepassgov1.Entry{Id: "website-login", Title: "Updated"},
})
if status.Code(err) != codes.PermissionDenied {
t.Fatalf("UpsertTemplate() code = %v, want %v", status.Code(err), codes.PermissionDenied)
}
}
func TestVaultServiceRejectsUnauthorizedPasswordGeneration(t *testing.T) {
t.Parallel()
client, _, cleanup := newTestClientForModel(t, vault.Model{
Entries: []vault.Entry{
testAPITokenEntry(t,
apitokens.PolicyRule{Effect: apitokens.EffectDeny, Operation: apitokens.OperationGeneratePassword, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root"}}},
),
},
})
defer cleanup()
_, err := client.GeneratePassword(tokenContext(defaultTestTokenSecret), &keepassgov1.GeneratePasswordRequest{Profile: "strong"})
if status.Code(err) != codes.PermissionDenied {
t.Fatalf("GeneratePassword() code = %v, want %v", status.Code(err), codes.PermissionDenied)
}
}
func TestVaultServiceFindsBrowserLoginsForAuthorizedClients(t *testing.T) {
t.Parallel()
client, _, cleanup := newTestClient(t)
defer cleanup()
ctx := tokenContext(defaultTestTokenSecret)
resp, err := client.FindBrowserLogins(ctx, &keepassgov1.FindBrowserLoginsRequest{
PageUrl: "https://vault.crew.example.invalid/login",
})
if err != nil {
t.Fatalf("FindBrowserLogins() error = %v", err)
}
if len(resp.Matches) != 1 {
t.Fatalf("len(FindBrowserLogins().Matches) = %d, want 1", len(resp.Matches))
}
if resp.Matches[0].Id != "vault-console" {
t.Fatalf("FindBrowserLogins().Matches[0].Id = %q, want vault-console", resp.Matches[0].Id)
}
if resp.Matches[0].Quality != "exact-host" {
t.Fatalf("FindBrowserLogins().Matches[0].Quality = %q, want exact-host", resp.Matches[0].Quality)
}
}
func TestVaultServiceFindsBrowserLoginsForSchemeLessEntryURLs(t *testing.T) {
t.Parallel()
client, _, cleanup := newTestClientForModel(t, vault.Model{
Entries: []vault.Entry{
{
ID: "gitlab",
Title: "GitLab",
Username: "jjulian",
Password: "secret",
URL: "gitlab.com",
Path: []string{"Root", "Internet"},
},
testAPITokenEntry(t,
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationListEntries, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root"}}},
),
},
})
defer cleanup()
resp, err := client.FindBrowserLogins(tokenContext(defaultTestTokenSecret), &keepassgov1.FindBrowserLoginsRequest{
PageUrl: "https://gitlab.com/users/sign_in",
})
if err != nil {
t.Fatalf("FindBrowserLogins() error = %v", err)
}
if len(resp.Matches) != 1 {
t.Fatalf("len(FindBrowserLogins().Matches) = %d, want 1", len(resp.Matches))
}
if resp.Matches[0].Id != "gitlab" {
t.Fatalf("FindBrowserLogins().Matches[0].Id = %q, want gitlab", resp.Matches[0].Id)
}
}
func TestVaultServiceFindsBrowserLoginsWithinAuthorizedGroupScope(t *testing.T) {
t.Parallel()
client, _, cleanup := newTestClientForModel(t, vault.Model{
Entries: []vault.Entry{
{
ID: "codex-nextcloud",
Title: "Nextcloud (codex)",
Username: "jjulian",
Password: "secret-1",
URL: "https://nextcloud.example.invalid",
Path: []string{"keepass", "Joe", "codex"},
},
{
ID: "joe-nextcloud",
Title: "Nextcloud",
Username: "jjulian",
Password: "secret-2",
URL: "https://nextcloud.example.invalid",
Path: []string{"keepass", "Joe", "Internet"},
},
testAPITokenEntry(t,
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationListEntries, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"keepass", "Joe", "codex"}}},
),
},
})
defer cleanup()
resp, err := client.FindBrowserLogins(tokenContext(defaultTestTokenSecret), &keepassgov1.FindBrowserLoginsRequest{
PageUrl: "https://nextcloud.example.invalid/login",
})
if err != nil {
t.Fatalf("FindBrowserLogins() error = %v", err)
}
if len(resp.Matches) != 1 {
t.Fatalf("len(FindBrowserLogins().Matches) = %d, want 1", len(resp.Matches))
}
if resp.Matches[0].Id != "codex-nextcloud" {
t.Fatalf("FindBrowserLogins().Matches[0].Id = %q, want codex-nextcloud", resp.Matches[0].Id)
}
}
func TestVaultServiceFindsBrowserLoginsRechecksChildPoliciesAfterPrompt(t *testing.T) {
t.Parallel()
model := vault.Model{
Entries: []vault.Entry{
{
ID: "rusty-casino",
Title: "Rusty Casino",
Username: "rustyryan",
Password: "bellagio-1",
URL: "https://vault.heist.example.invalid",
Path: []string{"Crews", "Bellagio"},
},
{
ID: "benedict-vault",
Title: "Benedict Vault",
Username: "terrybenedict",
Password: "bellagio-2",
URL: "https://vault.heist.example.invalid",
Path: []string{"Crews", "Bellagio", "Denied"},
},
testAPITokenEntry(t,
apitokens.PolicyRule{Effect: apitokens.EffectDeny, Operation: apitokens.OperationListEntries, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Crews", "Bellagio", "Denied"}}},
),
},
}
client, _, service, cleanup := newTestHarnessForModel(t, model)
defer cleanup()
service.approvals = apiapproval.NewBroker(time.Minute)
respCh := make(chan *keepassgov1.FindBrowserLoginsResponse, 1)
errCh := make(chan error, 1)
go func() {
resp, err := client.FindBrowserLogins(tokenContext(defaultTestTokenSecret), &keepassgov1.FindBrowserLoginsRequest{
PageUrl: "https://vault.heist.example.invalid/login",
})
respCh <- resp
errCh <- err
}()
pending := waitForServerPendingApproval(t, service, 1)[0]
if got := pending.Resource.Path; !slices.Equal(got, []string{"Crews", "Bellagio"}) {
t.Fatalf("pending.Resource.Path = %v, want [Crews Bellagio]", got)
}
if _, _, err := service.ResolveApproval(pending.ID, apiapproval.OutcomeAllowOnce); err != nil {
t.Fatalf("ResolveApproval(allow once) error = %v", err)
}
resp := <-respCh
if err := <-errCh; err != nil {
t.Fatalf("FindBrowserLogins() error = %v", err)
}
if len(resp.Matches) != 1 {
t.Fatalf("len(FindBrowserLogins().Matches) = %d, want 1", len(resp.Matches))
}
if got := resp.Matches[0].Id; got != "rusty-casino" {
t.Fatalf("FindBrowserLogins().Matches[0].Id = %q, want rusty-casino", got)
}
}
func TestVaultServiceDoesNotMatchSpecificBrowserEntryToParentDomain(t *testing.T) {
t.Parallel()
client, _, cleanup := newTestClientForModel(t, vault.Model{
Entries: []vault.Entry{
{
ID: "inside-man-accounts",
Title: "Inside Man Accounts",
Username: "daltonrussell",
Password: "diamond-1",
URL: "https://accounts.heist.example.invalid",
Path: []string{"Crews", "Bank"},
},
testAPITokenEntry(t,
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationListEntries, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Crews"}}},
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationCopyUsername, Resource: apitokens.Resource{Kind: apitokens.ResourceEntry, EntryID: "inside-man-accounts", Path: []string{"Crews", "Bank"}}},
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationCopyPassword, Resource: apitokens.Resource{Kind: apitokens.ResourceEntry, EntryID: "inside-man-accounts", Path: []string{"Crews", "Bank"}}},
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationCopyURL, Resource: apitokens.Resource{Kind: apitokens.ResourceEntry, EntryID: "inside-man-accounts", Path: []string{"Crews", "Bank"}}},
),
},
})
defer cleanup()
resp, err := client.FindBrowserLogins(tokenContext(defaultTestTokenSecret), &keepassgov1.FindBrowserLoginsRequest{
PageUrl: "https://heist.example.invalid/login",
})
if err != nil {
t.Fatalf("FindBrowserLogins() error = %v", err)
}
if len(resp.Matches) != 0 {
t.Fatalf("len(FindBrowserLogins().Matches) = %d, want 0", len(resp.Matches))
}
_, err = client.GetBrowserCredential(tokenContext(defaultTestTokenSecret), &keepassgov1.GetBrowserCredentialRequest{
Id: "inside-man-accounts",
PageUrl: "https://heist.example.invalid/login",
})
if status.Code(err) != codes.InvalidArgument {
t.Fatalf("GetBrowserCredential() code = %v, want %v", status.Code(err), codes.InvalidArgument)
}
}
func TestVaultServiceListEntriesHidesSingleInternalVaultRoot(t *testing.T) {
t.Parallel()
client, _, cleanup := newTestClientForModel(t, vault.Model{
Entries: []vault.Entry{
{
ID: "codex-nextcloud",
Title: "Nextcloud (codex)",
Username: "jjulian",
Password: "secret-1",
URL: "https://nextcloud.example.invalid",
Path: []string{"keepass", "Joe", "codex"},
},
testAPITokenEntry(t,
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationListEntries, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"keepass", "Joe", "codex"}}},
),
},
Groups: [][]string{
{"keepass"},
{"keepass", "Joe"},
{"keepass", "Joe", "codex"},
},
})
defer cleanup()
resp, err := client.ListEntries(tokenContext(defaultTestTokenSecret), &keepassgov1.ListEntriesRequest{
Path: []string{"Joe", "codex"},
})
if err != nil {
t.Fatalf("ListEntries() error = %v", err)
}
if len(resp.Entries) != 1 {
t.Fatalf("len(ListEntries().Entries) = %d, want 1", len(resp.Entries))
}
if got := resp.Entries[0].Path; !slices.Equal(got, []string{"Joe", "codex"}) {
t.Fatalf("ListEntries().Entries[0].Path = %v, want [Joe codex]", got)
}
}
func TestVaultServiceListEntriesHidesSingleInternalVaultRootWhenRecycleBinExists(t *testing.T) {
t.Parallel()
client, _, cleanup := newTestClientForModel(t, vault.Model{
Entries: []vault.Entry{
{
ID: "codex-nextcloud",
Title: "Nextcloud (codex)",
Username: "jjulian",
Password: "secret-1",
URL: "https://nextcloud.example.invalid",
Path: []string{"keepass", "Joe", "codex"},
},
testAPITokenEntry(t,
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationListEntries, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"keepass", "Joe", "codex"}}},
),
},
Groups: [][]string{
{"keepass"},
{"keepass", "Joe"},
{"keepass", "Joe", "codex"},
{"Recycle Bin"},
},
})
defer cleanup()
resp, err := client.ListEntries(tokenContext(defaultTestTokenSecret), &keepassgov1.ListEntriesRequest{
Path: []string{"Joe", "codex"},
})
if err != nil {
t.Fatalf("ListEntries() error = %v", err)
}
if len(resp.Entries) != 1 {
t.Fatalf("len(ListEntries().Entries) = %d, want 1", len(resp.Entries))
}
if got := resp.Entries[0].Path; !slices.Equal(got, []string{"Joe", "codex"}) {
t.Fatalf("ListEntries().Entries[0].Path = %v, want [Joe codex]", got)
}
}
func TestVaultServiceListGroupsHidesSingleInternalVaultRoot(t *testing.T) {
t.Parallel()
client, _, cleanup := newTestClientForModel(t, vault.Model{
Entries: []vault.Entry{
testAPITokenEntry(t,
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationListGroups, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"keepass"}}},
),
},
Groups: [][]string{
{"keepass"},
{"keepass", "Joe"},
{"keepass", "Shared"},
},
})
defer cleanup()
resp, err := client.ListGroups(tokenContext(defaultTestTokenSecret), &keepassgov1.ListGroupsRequest{})
if err != nil {
t.Fatalf("ListGroups() error = %v", err)
}
if !slices.Equal(resp.Names, []string{"Joe", "Shared"}) {
t.Fatalf("ListGroups().Names = %v, want [Joe Shared]", resp.Names)
}
}
func TestVaultServiceListGroupsHidesSingleInternalVaultRootWhenRecycleBinExists(t *testing.T) {
t.Parallel()
client, _, cleanup := newTestClientForModel(t, vault.Model{
Entries: []vault.Entry{
testAPITokenEntry(t,
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationListGroups, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"keepass"}}},
),
},
Groups: [][]string{
{"keepass"},
{"keepass", "Joe"},
{"keepass", "Shared"},
{"Recycle Bin"},
},
})
defer cleanup()
resp, err := client.ListGroups(tokenContext(defaultTestTokenSecret), &keepassgov1.ListGroupsRequest{})
if err != nil {
t.Fatalf("ListGroups() error = %v", err)
}
if !slices.Equal(resp.Names, []string{"Joe", "Shared"}) {
t.Fatalf("ListGroups().Names = %v, want [Joe Shared]", resp.Names)
}
}
func TestVaultServiceGetsBrowserCredentialForAuthorizedClients(t *testing.T) {
t.Parallel()
client, _, cleanup := newTestClient(t)
defer cleanup()
ctx := tokenContext(defaultTestTokenSecret)
resp, err := client.GetBrowserCredential(ctx, &keepassgov1.GetBrowserCredentialRequest{
Id: "vault-console",
PageUrl: "https://vault.crew.example.invalid/login",
})
if err != nil {
t.Fatalf("GetBrowserCredential() error = %v", err)
}
if resp.Id != "vault-console" {
t.Fatalf("GetBrowserCredential().Id = %q, want vault-console", resp.Id)
}
if resp.Password != "token-1" {
t.Fatalf("GetBrowserCredential().Password = %q, want token-1", resp.Password)
}
}
func TestVaultServiceRejectsUnauthorizedBrowserCredentialAccess(t *testing.T) {
t.Parallel()
client, _, cleanup := newTestClientForModel(t, vault.Model{
Entries: []vault.Entry{
{
ID: "vault-console",
Title: "Vault Console",
Username: "dannyocean",
Password: "token-1",
URL: "https://vault.crew.example.invalid",
Path: []string{"Root", "Internet"},
},
testAPITokenEntry(t,
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationListEntries, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root"}}},
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationCopyUsername, Resource: apitokens.Resource{Kind: apitokens.ResourceEntry, EntryID: "vault-console", Path: []string{"Root", "Internet"}}},
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationCopyURL, Resource: apitokens.Resource{Kind: apitokens.ResourceEntry, EntryID: "vault-console", Path: []string{"Root", "Internet"}}},
apitokens.PolicyRule{Effect: apitokens.EffectDeny, Operation: apitokens.OperationCopyPassword, Resource: apitokens.Resource{Kind: apitokens.ResourceEntry, EntryID: "vault-console", Path: []string{"Root", "Internet"}}},
),
},
})
defer cleanup()
_, err := client.GetBrowserCredential(tokenContext(defaultTestTokenSecret), &keepassgov1.GetBrowserCredentialRequest{
Id: "vault-console",
PageUrl: "https://vault.crew.example.invalid/login",
})
if status.Code(err) != codes.PermissionDenied {
t.Fatalf("GetBrowserCredential() code = %v, want %v", status.Code(err), codes.PermissionDenied)
}
}
func TestVaultServicePromptsAndResumesWhenApproved(t *testing.T) {
t.Parallel()
@@ -802,6 +1327,103 @@ func TestVaultServiceUpsertsEntriesForAuthorizedClients(t *testing.T) {
}
}
func TestVaultServiceUpsertEntryUpdatesLifecycleModel(t *testing.T) {
t.Parallel()
model := vault.Model{
Entries: []vault.Entry{
testAPITokenEntry(t,
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationMutateEntry, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root"}}},
),
},
}
lifecycle := &stubLifecycle{model: model}
listener := bufconn.Listen(1024 * 1024)
clipboardWriter := &memoryClipboardWriter{}
service := NewServerWithLifecycle(model, passwords.DefaultProfiles(), clipboardWriter, lifecycle)
server := grpc.NewServer()
keepassgov1.RegisterVaultServiceServer(server, service)
go func() { _ = server.Serve(listener) }()
t.Cleanup(func() {
server.Stop()
_ = listener.Close()
})
conn, err := grpc.NewClient("passthrough:///bufnet",
grpc.WithContextDialer(func(ctx context.Context, _ string) (net.Conn, error) {
return listener.DialContext(ctx)
}),
grpc.WithTransportCredentials(insecure.NewCredentials()),
)
if err != nil {
t.Fatalf("NewClient() error = %v", err)
}
t.Cleanup(func() { _ = conn.Close() })
client := keepassgov1.NewVaultServiceClient(conn)
_, err = client.UpsertEntry(tokenContext(defaultTestTokenSecret), &keepassgov1.UpsertEntryRequest{
Entry: &keepassgov1.Entry{
Id: "lifecycle-visible",
Title: "Lifecycle Visible",
Path: []string{"Root", "Internet"},
},
})
if err != nil {
t.Fatalf("UpsertEntry() error = %v", err)
}
current, err := lifecycle.Current()
if err != nil {
t.Fatalf("Current() error = %v", err)
}
if _, err := current.EntryByID("lifecycle-visible"); err != nil {
t.Fatalf("Current().EntryByID() error = %v, want persisted lifecycle-visible entry", err)
}
}
func TestVaultServiceUpsertsNewEntryWithinAuthorizedGroupScope(t *testing.T) {
t.Parallel()
client, _, cleanup := newTestClientForModel(t, vault.Model{
Entries: []vault.Entry{
testAPITokenEntry(t,
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationMutateEntry, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"keepass", "Joe", "codex"}}},
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationListEntries, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"keepass", "Joe", "codex"}}},
),
},
Groups: [][]string{
{"keepass"},
{"keepass", "Joe"},
{"keepass", "Joe", "codex"},
},
})
defer cleanup()
upserted, err := client.UpsertEntry(tokenContext(defaultTestTokenSecret), &keepassgov1.UpsertEntryRequest{
Entry: &keepassgov1.Entry{
Id: "codex-created",
Title: "Codex Created",
Path: []string{"Joe", "codex"},
},
})
if err != nil {
t.Fatalf("UpsertEntry() error = %v", err)
}
if got := upserted.Entry.Path; !slices.Equal(got, []string{"Joe", "codex"}) {
t.Fatalf("UpsertEntry().Entry.Path = %v, want [Joe codex]", got)
}
listed, err := client.ListEntries(tokenContext(defaultTestTokenSecret), &keepassgov1.ListEntriesRequest{
Path: []string{"Joe", "codex"},
})
if err != nil {
t.Fatalf("ListEntries() error = %v", err)
}
if len(listed.Entries) != 1 || listed.Entries[0].Id != "codex-created" {
t.Fatalf("ListEntries().Entries = %#v, want created codex entry", listed.Entries)
}
}
func TestVaultServiceDeletesAndRestoresEntriesForAuthorizedClients(t *testing.T) {
t.Parallel()
@@ -1087,9 +1709,12 @@ func newTestClient(t *testing.T) (keepassgov1.VaultServiceClient, *memoryClipboa
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationManageVault, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root"}}},
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationListEntries, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root"}}},
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationListGroups, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root"}}},
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationListTemplates, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root", "Templates"}}},
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationMutateGroup, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root"}}},
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationMutateEntry, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root"}}},
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationMutateTemplate, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root", "Templates"}}},
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationReadEntry, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root"}}},
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationGeneratePassword, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root"}}},
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationCopyPassword, Resource: apitokens.Resource{Kind: apitokens.ResourceEntry, EntryID: "vault-console", Path: []string{"Root", "Internet"}}},
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationCopyUsername, Resource: apitokens.Resource{Kind: apitokens.ResourceEntry, EntryID: "vault-console", Path: []string{"Root", "Internet"}}},
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationCopyURL, Resource: apitokens.Resource{Kind: apitokens.ResourceEntry, EntryID: "vault-console", Path: []string{"Root", "Internet"}}},
@@ -1125,7 +1750,7 @@ func newTestHarnessForModel(t *testing.T, model vault.Model) (keepassgov1.VaultS
listener := bufconn.Listen(1024 * 1024)
clipboardWriter := &memoryClipboardWriter{}
service := NewServer(model, passwords.DefaultProfiles(), clipboardWriter)
server := grpc.NewServer(grpc.UnaryInterceptor(AuthInterceptor(service)))
server := grpc.NewServer()
keepassgov1.RegisterVaultServiceServer(server, service)
go func() {
@@ -1168,7 +1793,7 @@ func newTestHarnessWithLifecycle(t *testing.T, lifecycle *stubLifecycle) (keepas
))
lifecycle.model = model
service := NewServerWithLifecycle(model, passwords.DefaultProfiles(), clipboardWriter, lifecycle)
server := grpc.NewServer(grpc.UnaryInterceptor(AuthInterceptor(service)))
server := grpc.NewServer()
keepassgov1.RegisterVaultServiceServer(server, service)
go func() {
+24 -5
View File
@@ -13,10 +13,11 @@ import (
)
var (
ErrRequestDenied = errors.New("authorization request denied")
ErrRequestCanceled = errors.New("authorization request canceled")
ErrRequestTimedOut = errors.New("authorization request timed out")
ErrRequestNotFound = errors.New("authorization request not found")
ErrRequestDenied = errors.New("authorization request denied")
ErrRequestCanceled = errors.New("authorization request canceled")
ErrRequestTimedOut = errors.New("authorization request timed out")
ErrRequestNotFound = errors.New("authorization request not found")
ErrBrokerNotConfigured = errors.New("authorization broker is not configured")
)
type Outcome string
@@ -50,6 +51,7 @@ type Broker struct {
timeout time.Duration
now func() time.Time
nextID func() string
notify func()
}
type pendingRequest struct {
@@ -108,9 +110,18 @@ func (b *Broker) Pending() []Request {
return requests
}
func (b *Broker) SetChangeNotifier(notify func()) {
if b == nil {
return
}
b.mu.Lock()
defer b.mu.Unlock()
b.notify = notify
}
func (b *Broker) Request(ctx context.Context, token apitokens.Token, op apitokens.Operation, resource apitokens.Resource) (Result, error) {
if b == nil {
return Result{}, ErrRequestTimedOut
return Result{}, ErrBrokerNotConfigured
}
pending := &pendingRequest{
@@ -128,12 +139,20 @@ func (b *Broker) Request(ctx context.Context, token apitokens.Token, op apitoken
b.mu.Lock()
b.pending[pending.request.ID] = pending
notify := b.notify
b.mu.Unlock()
if notify != nil {
notify()
}
defer func() {
b.mu.Lock()
delete(b.pending, pending.request.ID)
notify := b.notify
b.mu.Unlock()
if notify != nil {
notify()
}
}()
timer := time.NewTimer(b.timeout)
+41
View File
@@ -3,6 +3,7 @@ package apiapproval
import (
"context"
"errors"
"slices"
"testing"
"time"
@@ -120,6 +121,46 @@ func TestBrokerTimesOutPendingRequests(t *testing.T) {
}
}
func TestNilBrokerReturnsConfigurationError(t *testing.T) {
t.Parallel()
var broker *Broker
_, err := broker.Request(context.Background(), apitokens.Token{ID: "token-1", Name: "CLI"}, apitokens.OperationListGroups, apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root"}})
if !errors.Is(err, ErrBrokerNotConfigured) {
t.Fatalf("Request(nil broker) error = %v, want %v", err, ErrBrokerNotConfigured)
}
}
func TestBrokerNotifiesWhenPendingRequestsChange(t *testing.T) {
t.Parallel()
broker := NewBroker(time.Minute)
changes := make(chan int, 4)
broker.SetChangeNotifier(func() {
changes <- len(broker.Pending())
})
errCh := make(chan error, 1)
go func() {
_, err := broker.Request(context.Background(), apitokens.Token{ID: "token-1", Name: "CLI"}, apitokens.OperationListGroups, apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root"}})
errCh <- err
}()
waitForPending(t, broker, 1)
if _, _, err := broker.Resolve(broker.Pending()[0].ID, OutcomeAllowOnce); err != nil {
t.Fatalf("Resolve(allow once) error = %v", err)
}
if err := <-errCh; err != nil {
t.Fatalf("Request() error = %v, want nil", err)
}
got := []int{<-changes, <-changes}
slices.Sort(got)
if !slices.Equal(got, []int{0, 1}) {
t.Fatalf("change notifications = %v, want [0 1]", got)
}
}
func waitForPending(t *testing.T, broker *Broker, want int) {
t.Helper()
+6
View File
@@ -16,6 +16,12 @@ const (
EventApprovalDenied EventType = "approval_denied"
EventApprovalCanceled EventType = "approval_canceled"
EventApprovalTimedOut EventType = "approval_timed_out"
EventTokenIssued EventType = "token_issued"
EventTokenUpdated EventType = "token_updated"
EventTokenRotated EventType = "token_rotated"
EventTokenDisabled EventType = "token_disabled"
EventTokenRevoked EventType = "token_revoked"
EventTokenDeleted EventType = "token_deleted"
EventAutofillFound EventType = "autofill_found"
EventAutofillAmbiguous EventType = "autofill_ambiguous"
EventAutofillBlocked EventType = "autofill_blocked"
+12 -9
View File
@@ -55,15 +55,18 @@ const (
DecisionDeny Decision = "deny"
DecisionPrompt Decision = "prompt"
OperationListEntries Operation = "list_entries"
OperationListGroups Operation = "list_groups"
OperationReadEntry Operation = "read_entry"
OperationCopyPassword Operation = "copy_password"
OperationCopyUsername Operation = "copy_username"
OperationCopyURL Operation = "copy_url"
OperationMutateEntry Operation = "mutate_entry"
OperationMutateGroup Operation = "mutate_group"
OperationManageVault Operation = "manage_vault"
OperationListEntries Operation = "list_entries"
OperationListGroups Operation = "list_groups"
OperationListTemplates Operation = "list_templates"
OperationReadEntry Operation = "read_entry"
OperationCopyPassword Operation = "copy_password"
OperationCopyUsername Operation = "copy_username"
OperationCopyURL Operation = "copy_url"
OperationMutateEntry Operation = "mutate_entry"
OperationMutateGroup Operation = "mutate_group"
OperationMutateTemplate Operation = "mutate_template"
OperationGeneratePassword Operation = "generate_password"
OperationManageVault Operation = "manage_vault"
)
type Resource struct {
+151 -117
View File
@@ -8,6 +8,7 @@ import (
"time"
"git.julianfamily.org/keepassgo/internal/apiapproval"
"git.julianfamily.org/keepassgo/internal/apiaudit"
"git.julianfamily.org/keepassgo/internal/apitokens"
"git.julianfamily.org/keepassgo/internal/vault"
"git.julianfamily.org/keepassgo/internal/webdav"
@@ -54,6 +55,15 @@ type SaveableSession interface {
Save() error
}
type AutoSaveableSession interface {
SaveableSession
HasSaveTarget() bool
}
type RemoteAwareSession interface {
IsRemote() bool
}
type SynchronizableSession interface {
CurrentSession
Synchronize() error
@@ -101,6 +111,8 @@ type ApprovalManager interface {
type State struct {
Session CurrentSession
Approvals ApprovalManager
AuditLog *apiaudit.Log
AutoSaveRemote bool
Section Section
CurrentPath []string
SearchQuery string
@@ -180,115 +192,129 @@ func (s *State) RemoteCredentialEntries() ([]vault.Entry, error) {
}
func (s *State) IssueAPIToken(name, clientName string, expiresAt *time.Time, now time.Time) (apitokens.Token, string, error) {
session, ok := s.Session.(MutableSession)
if !ok {
return apitokens.Token{}, "", fmt.Errorf("session is not mutable")
}
model, err := session.Current()
result, err := s.mutateAPITokens(apiaudit.EventTokenIssued, "issued API token", func(model *vault.Model) (tokenMutationResult, error) {
token, secret, err := apitokens.Issue(name, clientName, expiresAt, now)
if err != nil {
return tokenMutationResult{}, err
}
apitokens.Upsert(model, token)
return tokenMutationResult{token: token, secret: secret}, nil
})
if err != nil {
return apitokens.Token{}, "", err
}
token, secret, err := apitokens.Issue(name, clientName, expiresAt, now)
if err != nil {
return apitokens.Token{}, "", err
}
apitokens.Upsert(&model, token)
session.Replace(model)
s.Dirty = true
return token, secret, nil
return result.token, result.secret, nil
}
func (s *State) RotateAPIToken(id string, now time.Time) (apitokens.Token, string, error) {
session, ok := s.Session.(MutableSession)
if !ok {
return apitokens.Token{}, "", fmt.Errorf("session is not mutable")
}
model, err := session.Current()
result, err := s.mutateAPITokens(apiaudit.EventTokenRotated, "rotated API token", func(model *vault.Model) (tokenMutationResult, error) {
token, err := apitokens.Find(*model, id)
if err != nil {
return tokenMutationResult{}, err
}
token, secret, err := apitokens.Rotate(token, now)
if err != nil {
return tokenMutationResult{}, err
}
apitokens.Upsert(model, token)
return tokenMutationResult{token: token, secret: secret}, nil
})
if err != nil {
return apitokens.Token{}, "", err
}
token, err := apitokens.Find(model, id)
if err != nil {
return apitokens.Token{}, "", err
}
token, secret, err := apitokens.Rotate(token, now)
if err != nil {
return apitokens.Token{}, "", err
}
apitokens.Upsert(&model, token)
session.Replace(model)
s.Dirty = true
return token, secret, nil
return result.token, result.secret, nil
}
func (s *State) UpsertAPIToken(token apitokens.Token) error {
session, ok := s.Session.(MutableSession)
if !ok {
return fmt.Errorf("session is not mutable")
}
model, err := session.Current()
if err != nil {
return err
}
apitokens.Upsert(&model, token)
session.Replace(model)
s.Dirty = true
return nil
_, err := s.mutateAPITokens(apiaudit.EventTokenUpdated, "updated API token", func(model *vault.Model) (tokenMutationResult, error) {
apitokens.Upsert(model, token)
return tokenMutationResult{token: token}, nil
})
return err
}
func (s *State) DisableAPIToken(id string) error {
session, ok := s.Session.(MutableSession)
if !ok {
return fmt.Errorf("session is not mutable")
}
model, err := session.Current()
if err != nil {
return err
}
token, err := apitokens.Find(model, id)
if err != nil {
return err
}
apitokens.Upsert(&model, apitokens.Disable(token))
session.Replace(model)
s.Dirty = true
return nil
_, err := s.mutateAPITokens(apiaudit.EventTokenDisabled, "disabled API token", func(model *vault.Model) (tokenMutationResult, error) {
token, err := apitokens.Find(*model, id)
if err != nil {
return tokenMutationResult{}, err
}
token = apitokens.Disable(token)
apitokens.Upsert(model, token)
return tokenMutationResult{token: token}, nil
})
return err
}
func (s *State) RevokeAPIToken(id string, when time.Time) error {
session, ok := s.Session.(MutableSession)
if !ok {
return fmt.Errorf("session is not mutable")
}
model, err := session.Current()
if err != nil {
return err
}
token, err := apitokens.Find(model, id)
if err != nil {
return err
}
apitokens.Upsert(&model, apitokens.Revoke(token, when))
session.Replace(model)
s.Dirty = true
return nil
_, err := s.mutateAPITokens(apiaudit.EventTokenRevoked, "revoked API token", func(model *vault.Model) (tokenMutationResult, error) {
token, err := apitokens.Find(*model, id)
if err != nil {
return tokenMutationResult{}, err
}
token = apitokens.Revoke(token, when)
apitokens.Upsert(model, token)
return tokenMutationResult{token: token}, nil
})
return err
}
func (s *State) DeleteAPIToken(id string) error {
_, err := s.mutateAPITokens(apiaudit.EventTokenDeleted, "deleted API token", func(model *vault.Model) (tokenMutationResult, error) {
token, err := apitokens.Find(*model, id)
if err != nil {
return tokenMutationResult{}, err
}
if err := apitokens.Delete(model, id); err != nil {
return tokenMutationResult{}, err
}
return tokenMutationResult{token: token}, nil
})
return err
}
type tokenMutationResult struct {
token apitokens.Token
secret string
}
func (s *State) mutateAPITokens(eventType apiaudit.EventType, message string, mutate func(*vault.Model) (tokenMutationResult, error)) (tokenMutationResult, error) {
session, ok := s.Session.(MutableSession)
if !ok {
return fmt.Errorf("session is not mutable")
return tokenMutationResult{}, fmt.Errorf("session is not mutable")
}
model, err := session.Current()
if err != nil {
return err
return tokenMutationResult{}, err
}
if err := apitokens.Delete(&model, id); err != nil {
return err
result, err := mutate(&model)
if err != nil {
return tokenMutationResult{}, err
}
session.Replace(model)
s.Dirty = true
return nil
if err := s.markDirtyAndAutoSave(); err != nil {
return tokenMutationResult{}, err
}
s.recordTokenAudit(eventType, result.token, message)
return result, nil
}
func (s *State) recordTokenAudit(eventType apiaudit.EventType, token apitokens.Token, message string) {
if s.AuditLog == nil {
return
}
s.AuditLog.Record(apiaudit.Event{
Type: eventType,
TokenID: token.ID,
TokenName: token.Name,
ClientName: token.ClientName,
Resource: apitokens.Resource{
Kind: apitokens.ResourceEntry,
Path: apitokens.EntryPath,
EntryID: token.ID,
},
Message: message,
})
}
func (s *State) SecuritySettings() (vault.SecuritySettings, error) {
@@ -307,8 +333,7 @@ func (s *State) ConfigureSecurity(settings vault.SecuritySettings) error {
if err := security.ConfigureSecurity(settings); err != nil {
return err
}
s.Dirty = true
return nil
return s.markDirtyAndAutoSave()
}
func (s *State) ShowSection(section Section) {
@@ -536,8 +561,7 @@ func (s *State) DeleteSelectedEntry() error {
session.Replace(model)
s.SelectedEntryID = ""
s.Dirty = true
return nil
return s.markDirtyAndAutoSave()
}
func (s *State) RestoreEntry(id string) error {
@@ -556,8 +580,7 @@ func (s *State) RestoreEntry(id string) error {
}
session.Replace(model)
s.Dirty = true
return nil
return s.markDirtyAndAutoSave()
}
func (s *State) UpsertEntry(entry vault.Entry) error {
@@ -574,8 +597,7 @@ func (s *State) UpsertEntry(entry vault.Entry) error {
model.UpsertEntry(entry)
session.Replace(model)
s.SelectedEntryID = entry.ID
s.Dirty = true
return nil
return s.markDirtyAndAutoSave()
}
func (s *State) UpsertTemplate(entry vault.Entry) error {
@@ -592,8 +614,7 @@ func (s *State) UpsertTemplate(entry vault.Entry) error {
model.UpsertTemplate(entry)
session.Replace(model)
s.SelectedEntryID = entry.ID
s.Dirty = true
return nil
return s.markDirtyAndAutoSave()
}
func (s *State) InstantiateTemplate(templateID string, overrides vault.Entry) (vault.Entry, error) {
@@ -614,7 +635,9 @@ func (s *State) InstantiateTemplate(templateID string, overrides vault.Entry) (v
session.Replace(model)
s.SelectedEntryID = entry.ID
s.Dirty = true
if err := s.markDirtyAndAutoSave(); err != nil {
return vault.Entry{}, err
}
return entry, nil
}
@@ -637,8 +660,7 @@ func (s *State) DeleteTemplate(id string) error {
if s.SelectedEntryID == id {
s.SelectedEntryID = ""
}
s.Dirty = true
return nil
return s.markDirtyAndAutoSave()
}
func (s *State) DuplicateSelectedEntry(duplicateID string) (vault.Entry, error) {
@@ -659,7 +681,9 @@ func (s *State) DuplicateSelectedEntry(duplicateID string) (vault.Entry, error)
session.Replace(model)
s.SelectedEntryID = duplicate.ID
s.Dirty = true
if err := s.markDirtyAndAutoSave(); err != nil {
return vault.Entry{}, err
}
return duplicate, nil
}
@@ -679,8 +703,7 @@ func (s *State) RestoreSelectedEntryVersion(historyIndex int) error {
}
session.Replace(model)
s.Dirty = true
return nil
return s.markDirtyAndAutoSave()
}
func (s *State) Lock() error {
@@ -716,8 +739,7 @@ func (s *State) ChangeMasterKey(key vault.MasterKey) error {
return err
}
s.Dirty = true
return nil
return s.markDirtyAndAutoSave()
}
func (s *State) EnterGroup(name string) {
@@ -744,6 +766,25 @@ func (s *State) Save() error {
return nil
}
func (s *State) markDirtyAndAutoSave() error {
s.Dirty = true
session, ok := s.Session.(SaveableSession)
if !ok {
return nil
}
if autosave, ok := s.Session.(AutoSaveableSession); ok && !autosave.HasSaveTarget() {
return nil
}
if remote, ok := s.Session.(RemoteAwareSession); ok && remote.IsRemote() && !s.AutoSaveRemote {
return nil
}
if err := session.Save(); err != nil {
return err
}
s.Dirty = false
return nil
}
func (s *State) Synchronize() error {
session, ok := s.Session.(SynchronizableSession)
if !ok {
@@ -916,7 +957,9 @@ func (s *State) ConfigureRemoteBinding(input RemoteBindingInput) (RemoteBinding,
}
session.Replace(model)
s.Dirty = true
if err := s.markDirtyAndAutoSave(); err != nil {
return RemoteBinding{}, err
}
return binding, nil
}
@@ -936,8 +979,7 @@ func (s *State) RemoveRemoteBinding(binding RemoteBinding) error {
}
session.Replace(model)
s.Dirty = true
return nil
return s.markDirtyAndAutoSave()
}
func (s *State) CreateGroup(name string) error {
@@ -953,8 +995,7 @@ func (s *State) CreateGroup(name string) error {
model.CreateGroup(s.CurrentPath, name)
session.Replace(model)
s.Dirty = true
return nil
return s.markDirtyAndAutoSave()
}
func (s *State) MoveCurrentGroup(parent []string) error {
@@ -974,8 +1015,7 @@ func (s *State) MoveCurrentGroup(parent []string) error {
if len(current) > 0 {
s.CurrentPath = append(append([]string(nil), parent...), current[len(current)-1])
}
s.Dirty = true
return nil
return s.markDirtyAndAutoSave()
}
func (s *State) RenameCurrentGroup(newName string) error {
@@ -997,8 +1037,7 @@ func (s *State) RenameCurrentGroup(newName string) error {
if len(s.CurrentPath) > 0 {
s.CurrentPath = append(append([]string(nil), s.CurrentPath[:len(s.CurrentPath)-1]...), newName)
}
s.Dirty = true
return nil
return s.markDirtyAndAutoSave()
}
func (s *State) MoveSelectedEntry(path []string) error {
@@ -1017,8 +1056,7 @@ func (s *State) MoveSelectedEntry(path []string) error {
}
session.Replace(model)
s.Dirty = true
return nil
return s.markDirtyAndAutoSave()
}
func (s *State) DeleteCurrentGroup() error {
@@ -1041,8 +1079,7 @@ func (s *State) DeleteCurrentGroup() error {
s.CurrentPath = append([]string(nil), s.CurrentPath[:len(s.CurrentPath)-1]...)
}
s.SelectedEntryID = ""
s.Dirty = true
return nil
return s.markDirtyAndAutoSave()
}
func (s *State) AddAttachmentToSelectedEntry(name string, content []byte) error {
@@ -1068,8 +1105,7 @@ func (s *State) AddAttachmentToSelectedEntry(name string, content []byte) error
}
model.Entries[i].Attachments[name] = append([]byte(nil), content...)
session.Replace(model)
s.Dirty = true
return nil
return s.markDirtyAndAutoSave()
}
return vault.ErrEntryNotFound
@@ -1095,8 +1131,7 @@ func (s *State) ReplaceAttachmentOnSelectedEntry(name string, content []byte) er
}
model.Entries[i].Attachments[name] = append([]byte(nil), content...)
session.Replace(model)
s.Dirty = true
return nil
return s.markDirtyAndAutoSave()
}
return vault.ErrEntryNotFound
@@ -1125,8 +1160,7 @@ func (s *State) DeleteAttachmentFromSelectedEntry(name string) error {
model.Entries[i].Attachments = nil
}
session.Replace(model)
s.Dirty = true
return nil
return s.markDirtyAndAutoSave()
}
return vault.ErrEntryNotFound
+195 -1
View File
@@ -7,6 +7,7 @@ import (
"time"
"git.julianfamily.org/keepassgo/internal/apiapproval"
"git.julianfamily.org/keepassgo/internal/apiaudit"
"git.julianfamily.org/keepassgo/internal/apitokens"
"git.julianfamily.org/keepassgo/internal/session"
"git.julianfamily.org/keepassgo/internal/vault"
@@ -109,7 +110,8 @@ func TestIssueRotateDisableRevokeAndDeleteAPIToken(t *testing.T) {
t.Parallel()
session := &mutableStubSession{model: vault.Model{}}
state := State{Session: session}
auditLog := apiaudit.New(10)
state := State{Session: session, AuditLog: auditLog}
now := time.Date(2026, 3, 29, 12, 0, 0, 0, time.UTC)
expiresAt := now.Add(24 * time.Hour)
@@ -162,6 +164,111 @@ func TestIssueRotateDisableRevokeAndDeleteAPIToken(t *testing.T) {
if len(tokens) != 0 {
t.Fatalf("APITokens() after delete = %#v, want empty", tokens)
}
events := auditLog.Events()
if len(events) != 5 {
t.Fatalf("len(AuditLog.Events()) = %d, want 5", len(events))
}
if events[0].Type != apiaudit.EventTokenDeleted ||
events[1].Type != apiaudit.EventTokenRevoked ||
events[2].Type != apiaudit.EventTokenDisabled ||
events[3].Type != apiaudit.EventTokenRotated ||
events[4].Type != apiaudit.EventTokenIssued {
t.Fatalf("AuditLog.Events() types = %#v, want deleted/revoked/disabled/rotated/issued", events)
}
if events[0].TokenID != issued.ID || events[0].Resource.EntryID != issued.ID {
t.Fatalf("delete audit event = %#v, want token/resource id %q", events[0], issued.ID)
}
if events[4].TokenName != "CLI" || events[4].ClientName != "grpc-cli" {
t.Fatalf("issued audit event = %#v, want CLI/grpc-cli metadata", events[4])
}
}
func TestIssueAPITokenAutoSavesWhenSessionSupportsSaving(t *testing.T) {
t.Parallel()
session := &mutableSaveableStubSession{model: vault.Model{}, hasSaveTarget: true}
state := State{Session: session}
now := time.Date(2026, 3, 29, 12, 0, 0, 0, time.UTC)
if _, _, err := state.IssueAPIToken("CLI", "grpc-cli", nil, now); err != nil {
t.Fatalf("IssueAPIToken() error = %v", err)
}
if session.saveCalls != 1 {
t.Fatalf("saveCalls = %d, want 1", session.saveCalls)
}
if state.Dirty {
t.Fatal("Dirty = true, want false after autosave")
}
}
func TestIssueAPITokenDoesNotAutoSaveWithoutSaveTarget(t *testing.T) {
t.Parallel()
session := &mutableSaveableStubSession{model: vault.Model{}}
state := State{Session: session}
now := time.Date(2026, 3, 29, 12, 0, 0, 0, time.UTC)
if _, _, err := state.IssueAPIToken("CLI", "grpc-cli", nil, now); err != nil {
t.Fatalf("IssueAPIToken() error = %v", err)
}
if session.saveCalls != 0 {
t.Fatalf("saveCalls = %d, want 0", session.saveCalls)
}
if !state.Dirty {
t.Fatal("Dirty = false, want true when no save target exists")
}
}
func TestIssueAPITokenDoesNotAutoSaveForRemoteSession(t *testing.T) {
t.Parallel()
session := &mutableSaveableStubSession{
model: vault.Model{},
hasSaveTarget: true,
remote: true,
}
state := State{Session: session}
now := time.Date(2026, 3, 29, 12, 0, 0, 0, time.UTC)
if _, _, err := state.IssueAPIToken("CLI", "grpc-cli", nil, now); err != nil {
t.Fatalf("IssueAPIToken() error = %v", err)
}
if session.saveCalls != 0 {
t.Fatalf("saveCalls = %d, want 0", session.saveCalls)
}
if !state.Dirty {
t.Fatal("Dirty = false, want true for remote session")
}
}
func TestIssueAPITokenAutoSavesForRemoteSessionWhenEnabled(t *testing.T) {
t.Parallel()
session := &mutableSaveableStubSession{
model: vault.Model{},
hasSaveTarget: true,
remote: true,
}
state := State{
Session: session,
AutoSaveRemote: true,
}
now := time.Date(2026, 3, 29, 12, 0, 0, 0, time.UTC)
if _, _, err := state.IssueAPIToken("CLI", "grpc-cli", nil, now); err != nil {
t.Fatalf("IssueAPIToken() error = %v", err)
}
if session.saveCalls != 1 {
t.Fatalf("saveCalls = %d, want 1", session.saveCalls)
}
if state.Dirty {
t.Fatal("Dirty = true, want false after remote autosave")
}
}
func TestRemoteProfilesReturnsVaultProfiles(t *testing.T) {
@@ -1460,6 +1567,60 @@ func TestCreateGroupPersistsGroupAndMarksDirty(t *testing.T) {
}
}
func TestCreateGroupAutoSavesWhenSessionSupportsSaving(t *testing.T) {
t.Parallel()
sess := &mutableSaveableStubSession{model: testVaultModel(), hasSaveTarget: true}
state := State{
Session: sess,
CurrentPath: []string{"Root"},
}
if err := state.CreateGroup("Finance"); err != nil {
t.Fatalf("CreateGroup() error = %v", err)
}
if sess.saveCalls != 1 {
t.Fatalf("saveCalls = %d, want 1", sess.saveCalls)
}
if state.Dirty {
t.Fatal("Dirty = true, want false after autosave")
}
}
func TestCreateGroupSaveFailureLeavesStateDirty(t *testing.T) {
t.Parallel()
sess := &mutableSaveableStubSession{
model: testVaultModel(),
hasSaveTarget: true,
saveErr: errors.New("save failed"),
}
state := State{
Session: sess,
CurrentPath: []string{"Root"},
}
err := state.CreateGroup("Finance")
if err == nil || err.Error() != "save failed" {
t.Fatalf("CreateGroup() error = %v, want save failed", err)
}
if sess.saveCalls != 1 {
t.Fatalf("saveCalls = %d, want 1", sess.saveCalls)
}
if !state.Dirty {
t.Fatal("Dirty = false, want true after failed autosave")
}
got, childErr := state.ChildGroups()
if childErr != nil {
t.Fatalf("ChildGroups() error = %v", childErr)
}
if !slices.Equal(got, []string{"Finance", "Internet", "Security Office"}) {
t.Fatalf("ChildGroups() = %v, want Finance, Internet, Security Office", got)
}
}
func TestCreateGroupSupportsNestedGroupPath(t *testing.T) {
t.Parallel()
@@ -1796,6 +1957,39 @@ func (s *saveableStubSession) Save() error {
return nil
}
type mutableSaveableStubSession struct {
model vault.Model
err error
saveCalls int
saveErr error
hasSaveTarget bool
remote bool
}
func (s *mutableSaveableStubSession) Current() (vault.Model, error) {
if s.err != nil {
return vault.Model{}, s.err
}
return s.model, nil
}
func (s *mutableSaveableStubSession) Replace(model vault.Model) {
s.model = model
}
func (s *mutableSaveableStubSession) Save() error {
s.saveCalls++
return s.saveErr
}
func (s *mutableSaveableStubSession) HasSaveTarget() bool {
return s.hasSaveTarget
}
func (s *mutableSaveableStubSession) IsRemote() bool {
return s.remote
}
type lifecycleStubSession struct {
createCalls int
model vault.Model
-95
View File
@@ -1,95 +0,0 @@
package actions
import "git.julianfamily.org/keepassgo/internal/appstate"
type SyncMenuModel struct {
HasOpenVault bool
HasSelectedBinding bool
ShowSelectors bool
ShowShare bool
ShowSaveCurrentBinding bool
SavedBindingSummary SyncMenuBindingSummary
RemoteBaseURL string
RemotePath string
RemoteUsername string
RemotePassword string
SelectedVaultSyncMode appstate.SyncMode
}
type SyncMenuBindingSummary struct {
ProfileLabel string
CredentialLabel string
SyncLabel string
OK bool
}
func (m SyncMenuModel) SavedBindingHeading() string {
if !m.ShowSelectors {
return "Use this vault's saved remote sync target"
}
return "Use a saved remote profile from this vault"
}
func (m SyncMenuModel) OpenSelectedButtonLabel() string {
if !m.ShowSelectors {
return "Use Remote Sync"
}
return "Open Saved Remote"
}
func (m SyncMenuModel) ShowDirectRemoteSyncShortcut() bool {
return m.HasOpenVault && m.HasSelectedBinding
}
func (m SyncMenuModel) DirectRemoteSyncShortcutLabel() string {
return "Use Remote Sync"
}
func (m SyncMenuModel) ShowRemoteSyncSettingsShortcut() bool {
return m.HasOpenVault && m.HasSelectedBinding
}
func (m SyncMenuModel) RemoteSyncSettingsShortcutLabel() string {
return "Remote Sync Settings"
}
func (m SyncMenuModel) ShowRemoveRemoteSyncShortcut() bool {
return m.ShowRemoteSyncSettingsShortcut()
}
func (m SyncMenuModel) RemoveRemoteSyncShortcutLabel() string {
return "Stop Using Remote Sync"
}
func (m SyncMenuModel) ShowRemoteSyncSetupShortcut() bool {
return m.HasOpenVault && !m.HasSelectedBinding
}
func (m SyncMenuModel) RemoteSyncSetupShortcutLabel() string {
return "Set Up Remote Sync"
}
func (m SyncMenuModel) ActionLabels() []string {
labels := []string{"Open Advanced Sync"}
if m.ShowRemoteSyncSetupShortcut() {
labels = append(labels, m.RemoteSyncSetupShortcutLabel())
}
if m.ShowDirectRemoteSyncShortcut() {
labels = append(labels, m.DirectRemoteSyncShortcutLabel())
}
if m.ShowRemoteSyncSettingsShortcut() {
labels = append(labels, m.RemoteSyncSettingsShortcutLabel())
}
if m.ShowRemoveRemoteSyncShortcut() {
labels = append(labels, m.RemoveRemoteSyncShortcutLabel())
}
return labels
}
func (m SyncMenuModel) SaveCurrentRemoteBindingHeading() string {
return "Bind this local vault to the current remote target"
}
func (m SyncMenuModel) SaveCurrentRemoteBindingButtonLabel() string {
return "Save Remote In Vault"
}
+93
View File
@@ -0,0 +1,93 @@
package api
import (
"strings"
"git.julianfamily.org/keepassgo/internal/apiaudit"
"git.julianfamily.org/keepassgo/internal/apitokens"
)
type AuditQuickFilter struct {
Label string
Query string
}
func Operations() []apitokens.Operation {
return []apitokens.Operation{
apitokens.OperationListEntries,
apitokens.OperationListGroups,
apitokens.OperationListTemplates,
apitokens.OperationReadEntry,
apitokens.OperationCopyPassword,
apitokens.OperationCopyUsername,
apitokens.OperationCopyURL,
apitokens.OperationMutateEntry,
apitokens.OperationMutateGroup,
apitokens.OperationMutateTemplate,
apitokens.OperationGeneratePassword,
apitokens.OperationManageVault,
}
}
func AuditDecisionLabel(eventType apiaudit.EventType) string {
switch eventType {
case apiaudit.EventApprovalRequested:
return "Requested"
case apiaudit.EventApprovalAllowed:
return "Allowed"
case apiaudit.EventApprovalDenied:
return "Denied"
case apiaudit.EventApprovalCanceled:
return "Canceled"
case apiaudit.EventApprovalTimedOut:
return "Timed Out"
case apiaudit.EventAuthRejected:
return "Auth Rejected"
default:
return strings.ReplaceAll(string(eventType), "_", " ")
}
}
func AuditOperationLabel(operation apitokens.Operation) string {
if strings.TrimSpace(string(operation)) == "" {
return "Other"
}
return strings.ReplaceAll(string(operation), "_", " ")
}
func CompactAuditFilterLabel(label string) string {
label = strings.TrimSpace(label)
if len(label) <= 22 {
return label
}
return label[:19] + "..."
}
func AuditEventSearchTerms(event apiaudit.Event) string {
parts := []string{
string(event.Type),
AuditDecisionLabel(event.Type),
event.TokenName,
event.ClientName,
string(event.Operation),
AuditOperationLabel(event.Operation),
strings.Join(event.Resource.Path, " / "),
event.Resource.EntryID,
event.Message,
}
switch event.Type {
case apiaudit.EventApprovalAllowed:
parts = append(parts, "allow approved")
case apiaudit.EventApprovalDenied:
parts = append(parts, "deny denied")
case apiaudit.EventApprovalRequested:
parts = append(parts, "prompt requested")
case apiaudit.EventApprovalCanceled:
parts = append(parts, "cancel canceled")
case apiaudit.EventApprovalTimedOut:
parts = append(parts, "timeout timed out")
case apiaudit.EventAuthRejected:
parts = append(parts, "rejected unauthorized")
}
return strings.ToLower(strings.Join(parts, " "))
}
@@ -12,89 +12,10 @@ import (
"gioui.org/widget/material"
"git.julianfamily.org/keepassgo/internal/apiaudit"
"git.julianfamily.org/keepassgo/internal/apitokens"
apiui "git.julianfamily.org/keepassgo/internal/appui/api"
)
func apiOperations() []apitokens.Operation {
return []apitokens.Operation{
apitokens.OperationListEntries,
apitokens.OperationListGroups,
apitokens.OperationReadEntry,
apitokens.OperationCopyPassword,
apitokens.OperationCopyUsername,
apitokens.OperationCopyURL,
apitokens.OperationMutateEntry,
apitokens.OperationMutateGroup,
apitokens.OperationManageVault,
}
}
type apiAuditQuickFilter struct {
Label string
Query string
}
func apiAuditDecisionLabel(eventType apiaudit.EventType) string {
switch eventType {
case apiaudit.EventApprovalRequested:
return "Requested"
case apiaudit.EventApprovalAllowed:
return "Allowed"
case apiaudit.EventApprovalDenied:
return "Denied"
case apiaudit.EventApprovalCanceled:
return "Canceled"
case apiaudit.EventApprovalTimedOut:
return "Timed Out"
case apiaudit.EventAuthRejected:
return "Auth Rejected"
default:
return strings.ReplaceAll(string(eventType), "_", " ")
}
}
func apiAuditOperationLabel(operation apitokens.Operation) string {
if strings.TrimSpace(string(operation)) == "" {
return "Other"
}
return strings.ReplaceAll(string(operation), "_", " ")
}
func compactAuditFilterLabel(label string) string {
label = strings.TrimSpace(label)
if len(label) <= 22 {
return label
}
return label[:19] + "..."
}
func apiAuditEventSearchTerms(event apiaudit.Event) string {
parts := []string{
string(event.Type),
apiAuditDecisionLabel(event.Type),
event.TokenName,
event.ClientName,
string(event.Operation),
apiAuditOperationLabel(event.Operation),
strings.Join(event.Resource.Path, " / "),
event.Resource.EntryID,
event.Message,
}
switch event.Type {
case apiaudit.EventApprovalAllowed:
parts = append(parts, "allow approved")
case apiaudit.EventApprovalDenied:
parts = append(parts, "deny denied")
case apiaudit.EventApprovalRequested:
parts = append(parts, "prompt requested")
case apiaudit.EventApprovalCanceled:
parts = append(parts, "cancel canceled")
case apiaudit.EventApprovalTimedOut:
parts = append(parts, "timeout timed out")
case apiaudit.EventAuthRejected:
parts = append(parts, "rejected unauthorized")
}
return strings.ToLower(strings.Join(parts, " "))
}
type apiAuditQuickFilter = apiui.AuditQuickFilter
func apiAuditFilterButtons(clicks *[]widget.Clickable, filters []apiAuditQuickFilter) []widget.Clickable {
if len(filters) == 0 {
@@ -126,7 +47,7 @@ func (u *ui) apiAuditQuickFilters(events []apiaudit.Event) ([]apiAuditQuickFilte
}
if _, ok := decisionSeen[event.Type]; !ok {
decisionSeen[event.Type] = struct{}{}
label := apiAuditDecisionLabel(event.Type)
label := apiui.AuditDecisionLabel(event.Type)
decisions = append(decisions, apiAuditQuickFilter{Label: label, Query: label})
}
if strings.TrimSpace(string(event.Operation)) == "" {
@@ -136,7 +57,7 @@ func (u *ui) apiAuditQuickFilters(events []apiaudit.Event) ([]apiAuditQuickFilte
continue
}
operationSeen[event.Operation] = struct{}{}
label := apiAuditOperationLabel(event.Operation)
label := apiui.AuditOperationLabel(event.Operation)
operations = append(operations, apiAuditQuickFilter{Label: label, Query: label})
}
@@ -208,7 +129,22 @@ func (u *ui) ensureAPIPolicyRemoveClickables(count int) []widget.Clickable {
return clicks
}
func (u *ui) ensureAPIPolicyEditClickables(count int) []widget.Clickable {
if count <= 0 {
u.apiPolicyEdits = nil
return nil
}
if len(u.apiPolicyEdits) == count {
return u.apiPolicyEdits
}
clicks := make([]widget.Clickable, count)
copy(clicks, u.apiPolicyEdits)
u.apiPolicyEdits = clicks
return clicks
}
func (u *ui) loadSelectedAPITokenIntoEditor() {
u.selectedAPIPolicyIndex = -1
token, ok := u.selectedAPIToken()
if !ok {
u.apiTokenSecret = ""
@@ -222,6 +158,7 @@ func (u *ui) loadSelectedAPITokenIntoEditor() {
u.apiPolicyAllow.Value = true
u.apiPolicyGroupScope = true
u.apiPolicyGroupScopeW.Value = true
u.ensureAPIPolicyEditClickables(0)
u.ensureAPIPolicyRemoveClickables(0)
return
}
@@ -233,6 +170,7 @@ func (u *ui) loadSelectedAPITokenIntoEditor() {
u.apiTokenExpiresAt.SetText("")
}
u.apiTokenDisabled.Value = token.Disabled
u.ensureAPIPolicyEditClickables(len(token.Policies))
u.ensureAPIPolicyRemoveClickables(len(token.Policies))
}
@@ -321,7 +259,7 @@ func parseAPITokenExpiry(text string) (*time.Time, error) {
func parseAPIPolicyOperation(text string) (apitokens.Operation, error) {
value := apitokens.Operation(strings.TrimSpace(text))
for _, operation := range apiOperations() {
for _, operation := range apiui.Operations() {
if operation == value {
return value, nil
}
@@ -329,14 +267,10 @@ func parseAPIPolicyOperation(text string) (apitokens.Operation, error) {
return "", fmt.Errorf("unknown API operation %q", text)
}
func (u *ui) addAPIPolicyRuleAction() error {
token, ok := u.selectedAPIToken()
if !ok {
return fmt.Errorf("no API token selected")
}
func (u *ui) apiPolicyRuleFromEditor() (apitokens.PolicyRule, error) {
operation, err := parseAPIPolicyOperation(u.apiPolicyOperation.Text())
if err != nil {
return err
return apitokens.PolicyRule{}, err
}
rule := apitokens.PolicyRule{
Operation: operation,
@@ -349,16 +283,28 @@ func (u *ui) addAPIPolicyRuleAction() error {
if u.apiPolicyGroupScope {
path := parsePath(u.apiPolicyPath.Text())
if len(path) == 0 {
return fmt.Errorf("policy path is required for group scope")
return apitokens.PolicyRule{}, fmt.Errorf("policy path is required for group scope")
}
rule.Resource = apitokens.Resource{Kind: apitokens.ResourceGroup, Path: path}
} else {
entryID := strings.TrimSpace(u.apiPolicyEntryID.Text())
if entryID == "" {
return fmt.Errorf("entry id is required for entry scope")
return apitokens.PolicyRule{}, fmt.Errorf("entry id is required for entry scope")
}
rule.Resource = apitokens.Resource{Kind: apitokens.ResourceEntry, EntryID: entryID}
}
return rule, nil
}
func (u *ui) addAPIPolicyRuleAction() error {
token, ok := u.selectedAPIToken()
if !ok {
return fmt.Errorf("no API token selected")
}
rule, err := u.apiPolicyRuleFromEditor()
if err != nil {
return err
}
if !uiHasPolicyRule(token.Policies, rule) {
token.Policies = append(token.Policies, rule)
}
@@ -369,6 +315,63 @@ func (u *ui) addAPIPolicyRuleAction() error {
return nil
}
func (u *ui) editAPIPolicyRuleAction(index int) error {
token, ok := u.selectedAPIToken()
if !ok {
return fmt.Errorf("no API token selected")
}
if index < 0 || index >= len(token.Policies) {
return fmt.Errorf("policy index %d out of range", index)
}
rule := token.Policies[index]
u.selectedAPIPolicyIndex = index
u.apiPolicyOperation.SetText(string(rule.Operation))
u.apiPolicyAllow.Value = rule.Effect == apitokens.EffectAllow
if rule.Resource.Kind == apitokens.ResourceEntry {
u.apiPolicyGroupScope = false
u.apiPolicyGroupScopeW.Value = false
u.apiPolicyEntryID.SetText(strings.TrimSpace(rule.Resource.EntryID))
u.apiPolicyPath.SetText("")
return nil
}
u.apiPolicyGroupScope = true
u.apiPolicyGroupScopeW.Value = true
u.apiPolicyPath.SetText(strings.Join(rule.Resource.Path, " / "))
u.apiPolicyEntryID.SetText("")
return nil
}
func (u *ui) saveAPIPolicyRuleAction() error {
token, ok := u.selectedAPIToken()
if !ok {
return fmt.Errorf("no API token selected")
}
index := u.selectedAPIPolicyIndex
if index < 0 || index >= len(token.Policies) {
return fmt.Errorf("no API policy rule selected")
}
rule, err := u.apiPolicyRuleFromEditor()
if err != nil {
return err
}
for i, existing := range token.Policies {
if i != index && uiHasPolicyRule([]apitokens.PolicyRule{existing}, rule) {
token.Policies = append(token.Policies[:index], token.Policies[index+1:]...)
if err := u.state.UpsertAPIToken(token); err != nil {
return err
}
u.loadSelectedAPITokenIntoEditor()
return nil
}
}
token.Policies[index] = rule
if err := u.state.UpsertAPIToken(token); err != nil {
return err
}
u.loadSelectedAPITokenIntoEditor()
return nil
}
func (u *ui) apiPolicyGroupPathSummary() string {
path := parsePath(u.apiPolicyPath.Text())
if len(path) == 0 {
@@ -436,6 +439,11 @@ func (u *ui) removeAPIPolicyRuleAction(index int) error {
return nil
}
func (u *ui) cancelAPIPolicyEditAction() error {
u.loadSelectedAPITokenIntoEditor()
return nil
}
func (u *ui) apiAuditEvents() []apiaudit.Event {
if u.auditLog == nil {
return nil
@@ -450,7 +458,7 @@ func (u *ui) apiAuditEvents() []apiaudit.Event {
}
filtered := make([]apiaudit.Event, 0, len(events))
for _, event := range events {
haystack := apiAuditEventSearchTerms(event)
haystack := apiui.AuditEventSearchTerms(event)
if strings.Contains(haystack, query) {
filtered = append(filtered, event)
}
@@ -785,7 +793,7 @@ func (u *ui) apiAuditQuickFilterRow(gtx layout.Context, title string, filters []
click := &buttons[i]
selected := strings.EqualFold(strings.TrimSpace(u.search.Text()), strings.TrimSpace(filter.Query))
column = append(column, layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return u.auditQuickFilterButton(gtx, click, compactAuditFilterLabel(filter.Label), selected, filter.Query)
return u.auditQuickFilterButton(gtx, click, apiui.CompactAuditFilterLabel(filter.Label), selected, filter.Query)
}))
}
return layout.Flex{Axis: layout.Vertical}.Layout(gtx, column...)
@@ -799,7 +807,7 @@ func (u *ui) apiAuditQuickFilterRow(gtx layout.Context, title string, filters []
click := &buttons[i]
selected := strings.EqualFold(strings.TrimSpace(u.search.Text()), strings.TrimSpace(filter.Query))
flexChildren = append(flexChildren, layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return u.auditQuickFilterButton(gtx, click, compactAuditFilterLabel(filter.Label), selected, filter.Query)
return u.auditQuickFilterButton(gtx, click, apiui.CompactAuditFilterLabel(filter.Label), selected, filter.Query)
}))
}
return layout.Flex{Spacing: layout.SpaceStart}.Layout(gtx, flexChildren...)
@@ -828,8 +836,10 @@ func (u *ui) auditQuickFilterButton(gtx layout.Context, click *widget.Clickable,
func (u *ui) apiTokenDetailPanel(gtx layout.Context) layout.Dimensions {
token, ok := u.selectedAPIToken()
removeClicks := u.ensureAPIPolicyRemoveClickables(0)
var editClicks []widget.Clickable
var removeClicks []widget.Clickable
if ok {
editClicks = u.ensureAPIPolicyEditClickables(len(token.Policies))
removeClicks = u.ensureAPIPolicyRemoveClickables(len(token.Policies))
}
rows := []layout.Widget{
@@ -997,6 +1007,10 @@ func (u *ui) apiTokenDetailPanel(gtx layout.Context) layout.Dimensions {
return layout.Flex{Alignment: layout.Middle}.Layout(gtx,
layout.Flexed(1, detailLine(u.theme, "Effect", effect)),
layout.Rigid(layout.Spacer{Width: unit.Dp(12)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return tonedButton(gtx, u.theme, &editClicks[index], "Edit")
}),
layout.Rigid(layout.Spacer{Width: unit.Dp(6)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return tonedButton(gtx, u.theme, &removeClicks[index], "Remove")
}),
@@ -1030,15 +1044,23 @@ func (u *ui) apiTokenDetailPanel(gtx layout.Context) layout.Dimensions {
rows = append(rows,
func(gtx layout.Context) layout.Dimensions {
return card(gtx, func(gtx layout.Context) layout.Dimensions {
actionLabel := "Add Rule"
title := "Policy Composer"
description := "Rules are evaluated per operation. Explicit deny rules override allow rules."
if 0 <= u.selectedAPIPolicyIndex {
actionLabel = "Save Rule"
title = "Policy Editor"
description = "Editing an existing rule. Save the updated scope or cancel to return to a blank composer."
}
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
lbl := material.Label(u.theme, unit.Sp(14), "Policy Composer")
lbl := material.Label(u.theme, unit.Sp(14), title)
lbl.Color = accentColor
return lbl.Layout(gtx)
}),
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), "Rules are evaluated per operation. Explicit deny rules override allow rules.")
lbl := material.Label(u.theme, unit.Sp(12), description)
lbl.Color = mutedColor
return lbl.Layout(gtx)
}),
@@ -1051,7 +1073,7 @@ func (u *ui) apiTokenDetailPanel(gtx layout.Context) layout.Dimensions {
return material.CheckBox(u.theme, &u.apiPolicyGroupScopeW, "Group scope (unchecked means exact entry scope)").Layout(gtx)
}),
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
layout.Rigid(labeledEditorHelp(u.theme, "Operation", "Valid operations: "+strings.Join(stringOps(apiOperations()), ", "), &u.apiPolicyOperation, false)),
layout.Rigid(labeledEditorHelp(u.theme, "Operation", "Valid operations: "+strings.Join(stringOps(apiui.Operations()), ", "), &u.apiPolicyOperation, false)),
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
if u.apiPolicyGroupScopeW.Value {
@@ -1093,7 +1115,22 @@ func (u *ui) apiTokenDetailPanel(gtx layout.Context) layout.Dimensions {
}),
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return tonedButton(gtx, u.theme, &u.addAPIPolicyRule, "Add Rule")
return layout.Flex{Spacing: layout.SpaceStart}.Layout(gtx,
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
if 0 <= u.selectedAPIPolicyIndex {
return tonedButton(gtx, u.theme, &u.saveAPIPolicyRule, actionLabel)
}
return tonedButton(gtx, u.theme, &u.addAPIPolicyRule, actionLabel)
}),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
if u.selectedAPIPolicyIndex < 0 {
return layout.Dimensions{}
}
return layout.Inset{Left: unit.Dp(6)}.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
return tonedButton(gtx, u.theme, &u.cancelAPIPolicyEdit, "Cancel Edit")
})
}),
)
}),
)
})
+136 -4195
View File
File diff suppressed because it is too large Load Diff
+26
View File
@@ -0,0 +1,26 @@
package layout
type Mode string
const (
ModeLocked Mode = "locked"
ModeStatic Mode = "static"
ModeEmpty Mode = "empty"
ModeEditor Mode = "editor"
ModeView Mode = "view"
)
func Resolve(isLocked bool, hasStaticPanel bool, hasSelectedEntry bool, editing bool) Mode {
switch {
case isLocked:
return ModeLocked
case hasStaticPanel:
return ModeStatic
case !hasSelectedEntry && !editing:
return ModeEmpty
case editing:
return ModeEditor
default:
return ModeView
}
}
+17
View File
@@ -0,0 +1,17 @@
package detail
type EmptyState struct {
Title string
Body string
}
type VaultSummary struct {
Title string
Detail string
Context string
}
type AttachmentItem struct {
Name string
Size int
}
+64
View File
@@ -0,0 +1,64 @@
package editor
import "strings"
type Field string
const (
FieldID Field = "id"
FieldTitle Field = "title"
FieldUsername Field = "username"
FieldPassword Field = "password"
FieldURL Field = "url"
FieldPath Field = "path"
FieldTags Field = "tags"
FieldPasswordProfile Field = "password-profile"
FieldNotes Field = "notes"
FieldFields Field = "fields"
FieldHistoryIndex Field = "history-index"
)
func Label(field Field) string {
switch field {
case FieldID:
return "ID"
case FieldTitle:
return "Title"
case FieldUsername:
return "Username"
case FieldPassword:
return "Password"
case FieldURL:
return "URL"
case FieldPath:
return "Path"
case FieldTags:
return "Tags"
case FieldPasswordProfile:
return "Password Profile"
case FieldNotes:
return "Notes"
case FieldFields:
return "Custom Fields"
case FieldHistoryIndex:
return "History Index"
default:
return strings.ReplaceAll(string(field), "-", " ")
}
}
func FocusOrder() []Field {
return []Field{
FieldID,
FieldTitle,
FieldUsername,
FieldPassword,
FieldURL,
FieldPath,
FieldTags,
FieldPasswordProfile,
FieldNotes,
FieldFields,
FieldHistoryIndex,
}
}
@@ -275,8 +275,7 @@ func (u *ui) deleteCurrentGroupAction() error {
return err
}
u.clearDeleteGroupConfirmation()
u.currentPath = append([]string(nil), u.state.CurrentPath...)
u.syncedPath = append([]string(nil), u.state.CurrentPath...)
u.adoptStateCurrentPath()
u.filter()
return nil
}
File diff suppressed because it is too large Load Diff
+335
View File
@@ -0,0 +1,335 @@
package appui
import (
"fmt"
"image"
"gioui.org/layout"
"gioui.org/op"
"gioui.org/op/paint"
"gioui.org/unit"
"gioui.org/widget"
"gioui.org/widget/material"
headerview "git.julianfamily.org/keepassgo/internal/appui/header"
headerlayout "git.julianfamily.org/keepassgo/internal/appui/header/layout"
)
func (u *ui) header(gtx layout.Context) layout.Dimensions {
if u.usesCompactViewport() {
if u.shouldShowLifecycleSetup() || u.isVaultLocked() {
return layout.Dimensions{}
}
gtx.Constraints.Min.X = gtx.Constraints.Max.X
return u.headerActions(gtx)
}
if u.shouldShowDesktopWorkingHeader() {
return layout.Dimensions{}
}
return card(gtx, func(gtx layout.Context) layout.Dimensions {
return layout.Flex{Alignment: layout.Middle}.Layout(gtx,
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return u.brandMark(gtx, 196, 56)
}),
layout.Flexed(1, u.headerActions),
)
})
}
func (u *ui) headerActions(gtx layout.Context) layout.Dimensions {
if u.shouldShowLifecycleSetup() || u.isVaultLocked() {
return layout.Dimensions{}
}
cluster := u.newHeaderActionCluster(gtx)
rowDims := cluster.layout(gtx, u)
if u.usesCompactViewport() {
u.maybeLogHeaderBounds(newHeaderButtonBounds(image.Pt(u.frameInsetPx, u.frameInsetPx), cluster.Metrics.Bounds()))
}
if u.usesCompactViewport() {
cluster.prepareCompactMenus(gtx, u)
return layout.Dimensions{Size: image.Pt(gtx.Constraints.Max.X, rowDims.Size.Y)}
}
return rowDims
}
type headerActionCluster struct {
Metrics headerlayout.ActionMetrics
SyncMenu layout.Widget
MainMenu layout.Widget
}
func (c headerActionCluster) ShowSyncMenu() bool { return c.SyncMenu != nil }
func (c headerActionCluster) ShowMainMenu() bool { return c.MainMenu != nil }
func (u *ui) newHeaderActionCluster(gtx layout.Context) headerActionCluster {
cluster := headerActionCluster{
SyncMenu: u.syncMenuWidget(),
MainMenu: u.mainMenuWidget(),
}
spacing := gtx.Dp(unit.Dp(8))
cluster.Metrics = headerlayout.ActionMetrics{Spacing: spacing, SyncInnerSpacing: gtx.Dp(unit.Dp(3))}
if !u.usesCompactViewport() {
cluster.Metrics.SyncInnerSpacing = gtx.Dp(unit.Dp(4))
}
return cluster
}
func (c *headerActionCluster) layout(gtx layout.Context, u *ui) layout.Dimensions {
rowOps := op.Record(gtx.Ops)
c.Metrics.RowDims = c.layoutRow(gtx, u)
rowCall := rowOps.Stop()
c.Metrics.RowOriginX = max(0, gtx.Constraints.Max.X-c.Metrics.RowDims.Size.X)
return layout.E.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
rowCall.Add(gtx.Ops)
return c.Metrics.RowDims
})
}
func (c headerActionCluster) activeMenu() layout.Widget {
switch {
case c.ShowSyncMenu():
return c.SyncMenu
case c.ShowMainMenu():
return c.MainMenu
default:
return nil
}
}
func (c *headerActionCluster) layoutRow(gtx layout.Context, u *ui) layout.Dimensions {
return layout.Flex{Spacing: layout.SpaceStart}.Layout(gtx,
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
c.Metrics.SyncDims, c.Metrics.SyncPrimaryDims, c.Metrics.SyncToggleDims = u.syncButtonGroupWithMetrics(gtx)
return c.Metrics.SyncDims
}),
layout.Rigid(layout.Spacer{Width: unit.Dp(8)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
btn := material.Button(u.theme, &u.lockVault, "Lock")
c.Metrics.LockDims = btn.Layout(gtx)
return c.Metrics.LockDims
}),
layout.Rigid(layout.Spacer{Width: unit.Dp(8)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
c.Metrics.MainDims = u.mainMenuButtonGroup(gtx)
return c.Metrics.MainDims
}),
)
}
func (c *headerActionCluster) prepareCompactMenus(gtx layout.Context, u *ui) {
compactSurface := headerlayout.DropdownSurface{
ContainerWidth: gtx.Constraints.Max.X,
LeftInset: u.frameInsetPx,
TopInset: u.frameInsetPx,
}
if c.ShowSyncMenu() {
u.phoneSyncMenuVisible = true
u.maybeLogHeaderMenuToggle("sync-visible", true)
placement, menuCall := compactSurface.Place(gtx, c.Metrics.SyncAnchor(), c.SyncMenu)
u.phoneSyncMenuOrigin = placement.Origin
u.phoneSyncMenuSize = placement.Size
u.phoneSyncMenuCall = menuCall
u.maybeLogHeaderMenuPlacement("sync-phone", compactSurface, placement)
}
if c.ShowMainMenu() {
u.phoneMainMenuVisible = true
u.maybeLogHeaderMenuToggle("main-visible", true)
placement, menuCall := compactSurface.Place(gtx, c.Metrics.MainAnchor(), c.MainMenu)
u.phoneMainMenuOrigin = placement.Origin
u.phoneMainMenuSize = placement.Size
u.phoneMainMenuCall = menuCall
u.maybeLogHeaderMenuPlacement("main-phone", compactSurface, placement)
}
}
func (c headerActionCluster) layoutMenuRow(gtx layout.Context) layout.Dimensions {
menu := c.activeMenu()
if menu == nil {
return layout.Dimensions{}
}
fullWidthGTX := gtx
fullWidthGTX.Constraints.Min = image.Point{}
fullWidthGTX.Constraints.Min.X = fullWidthGTX.Constraints.Max.X
dims := layout.E.Layout(fullWidthGTX, menu)
return layout.Dimensions{Size: image.Pt(fullWidthGTX.Constraints.Max.X, dims.Size.Y)}
}
type headerButtonBounds struct {
SyncPrimary image.Rectangle
SyncToggle image.Rectangle
Lock image.Rectangle
MainMenu image.Rectangle
}
func newHeaderButtonBounds(origin image.Point, bounds headerlayout.ActionBounds) headerButtonBounds {
return headerButtonBounds{
SyncPrimary: bounds.SyncPrimary.Add(origin),
SyncToggle: bounds.SyncToggle.Add(origin),
Lock: bounds.Lock.Add(origin),
MainMenu: bounds.MainMenu.Add(origin),
}
}
func (b headerButtonBounds) logLine(mode string) string {
return fmt.Sprintf(
"keepassgo header-bounds mode=%s sync=%s sync_toggle=%s lock=%s menu=%s",
mode,
formatHeaderRect(b.SyncPrimary),
formatHeaderRect(b.SyncToggle),
formatHeaderRect(b.Lock),
formatHeaderRect(b.MainMenu),
)
}
func formatHeaderRect(rect image.Rectangle) string {
return fmt.Sprintf("%d,%d-%d,%d", rect.Min.X, rect.Min.Y, rect.Max.X, rect.Max.Y)
}
func (u *ui) topRightActionOrder() []string {
if u.isVaultLocked() {
return nil
}
return []string{"Sync", "Lock", "Menu"}
}
func (u *ui) phoneHeaderMenus(gtx layout.Context) layout.Dimensions {
if !u.usesCompactViewport() || (!u.syncMenuVisibleOnPhone() && !u.mainMenuVisibleOnPhone()) {
return layout.Dimensions{}
}
cluster := u.newHeaderActionCluster(gtx)
if u.syncMenuVisibleOnPhone() {
return layout.UniformInset(unit.Dp(16)).Layout(gtx, func(gtx layout.Context) layout.Dimensions {
stack := op.Offset(image.Pt(0, max(0, u.phoneSyncMenuOrigin.Y-u.frameInsetPx))).Push(gtx.Ops)
defer stack.Pop()
dims := cluster.layoutMenuRow(gtx)
return layout.Dimensions{Size: image.Pt(gtx.Constraints.Max.X, max(dims.Size.Y, u.phoneSyncMenuOrigin.Y))}
})
}
if u.mainMenuVisibleOnPhone() {
return layout.UniformInset(unit.Dp(16)).Layout(gtx, func(gtx layout.Context) layout.Dimensions {
stack := op.Offset(image.Pt(0, max(0, u.phoneMainMenuOrigin.Y-u.frameInsetPx))).Push(gtx.Ops)
defer stack.Pop()
dims := cluster.layoutMenuRow(gtx)
return layout.Dimensions{Size: image.Pt(gtx.Constraints.Max.X, max(dims.Size.Y, u.phoneMainMenuOrigin.Y))}
})
}
return layout.Dimensions{}
}
func (u *ui) desktopHeaderMenus(gtx layout.Context) layout.Dimensions {
if u.usesCompactViewport() || (!u.syncMenuOpen && !u.mainMenuOpen) {
return layout.Dimensions{}
}
cluster := u.newHeaderActionCluster(gtx)
dims := cluster.layoutMenuRow(gtx)
if dims.Size.Y == 0 {
return dims
}
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions { return dims }),
)
}
func (u *ui) syncMenuVisibleOnPhone() bool {
return u.usesCompactViewport() && u.phoneSyncMenuVisible && u.syncMenuOpen
}
func (u *ui) mainMenuVisibleOnPhone() bool {
return u.usesCompactViewport() && u.phoneMainMenuVisible && u.mainMenuOpen
}
func (u *ui) syncMenuDropsBelowTrigger() bool { return true }
func (u *ui) syncMenuRightAlignsToTrigger() bool { return true }
func (u *ui) headerMenusUseOverlayModel() bool { return true }
func (u *ui) mainMenuDropsBelowTrigger() bool { return true }
func (u *ui) mainMenuRightAlignsToTrigger() bool { return true }
func (u *ui) lifecycleBranding(gtx layout.Context) layout.Dimensions {
if !u.usesCompactViewport() {
return layout.Dimensions{}
}
return layout.Dimensions{}
}
func (u *ui) brandMark(gtx layout.Context, widthDP, heightDP float32) layout.Dimensions {
if u.usesCompactViewport() {
return u.brandImage(gtx, u.splashSquare, widthDP, heightDP)
}
return u.brandImage(gtx, u.logoHorizontal, widthDP, heightDP)
}
func (u *ui) brandImage(gtx layout.Context, src paint.ImageOp, widthDP, heightDP float32) layout.Dimensions {
width := gtx.Dp(unit.Dp(widthDP))
height := gtx.Dp(unit.Dp(heightDP))
if width > gtx.Constraints.Max.X {
width = gtx.Constraints.Max.X
}
if height > gtx.Constraints.Max.Y && gtx.Constraints.Max.Y > 0 {
height = gtx.Constraints.Max.Y
}
img := widget.Image{
Src: src,
Fit: widget.Contain,
Position: layout.W,
Scale: 1.0 / gtx.Metric.PxPerDp,
}
gtx.Constraints.Min = image.Point{}
gtx.Constraints.Max = image.Pt(width, height)
return img.Layout(gtx)
}
func (u *ui) mainMenuWidget() layout.Widget {
if !u.mainMenuOpen {
return nil
}
return u.mainMenu
}
func (u *ui) mainMenu(gtx layout.Context) layout.Dimensions {
rows := []layout.Widget{
func(gtx layout.Context) layout.Dimensions {
return tonedButton(gtx, u.theme, &u.showEntries, "Entries")
},
func(gtx layout.Context) layout.Dimensions {
return tonedButton(gtx, u.theme, &u.showRecycle, "Recycle Bin")
},
func(gtx layout.Context) layout.Dimensions {
return tonedButton(gtx, u.theme, &u.showAPITokens, "API Tokens")
},
func(gtx layout.Context) layout.Dimensions {
return tonedButton(gtx, u.theme, &u.showAPIAudit, "API Audit")
},
func(gtx layout.Context) layout.Dimensions { return tonedButton(gtx, u.theme, &u.showAbout, "About") },
func(gtx layout.Context) layout.Dimensions {
return tonedButton(gtx, u.theme, &u.openSecuritySettings, "Settings")
},
}
return headerview.MainMenu(gtx, u.theme, rows, compactCard, nil)
}
func (u *ui) mainMenuButtonGroup(gtx layout.Context) layout.Dimensions {
icon := u.menuIcon
if icon == nil {
icon = u.settingsIcon
}
return headerview.MainMenuButtonGroup(gtx, u.theme, &u.toggleMainMenu, icon, u.mainMenuOpen, selectedColor, accentColor)
}
func intrinsicCompactCard(gtx layout.Context, w layout.Widget) layout.Dimensions {
return headerlayout.IntrinsicCompactCard(gtx, w, compactCard, nil)
}
func menuActionWidth(gtx layout.Context, rows []layout.Widget) int {
return headerlayout.MenuActionWidth(gtx, rows)
}
func rightAlignedMenuAction(gtx layout.Context, width int, child layout.Widget) layout.Dimensions {
return headerlayout.RightAlignedAction(gtx, width, child)
}
@@ -38,6 +38,12 @@ type DropdownSurface struct {
TopInset int
}
type DropdownPlacement struct {
Anchor DropdownAnchor
Origin image.Point
Size image.Point
}
func (s DropdownSurface) MenuConstraints(gtx layout.Context) layout.Context {
menuGTX := gtx
menuGTX.Constraints.Min = image.Point{}
@@ -51,38 +57,75 @@ func (s DropdownSurface) Origin(anchor DropdownAnchor, menuWidth int) image.Poin
return image.Pt(x, y)
}
func (s DropdownSurface) Draw(gtx layout.Context, anchor DropdownAnchor, menu layout.Widget) layout.Dimensions {
func (s DropdownSurface) Place(gtx layout.Context, anchor DropdownAnchor, menu layout.Widget) (DropdownPlacement, op.CallOp) {
menuGTX := s.MenuConstraints(gtx)
menuOps := op.Record(gtx.Ops)
menuDims := layout.Inset{Top: unit.Dp(6)}.Layout(menuGTX, menu)
menuCall := menuOps.Stop()
menuOrigin := s.Origin(anchor, menuDims.Size.X)
stack := op.Offset(menuOrigin).Push(gtx.Ops)
return DropdownPlacement{
Anchor: anchor,
Origin: menuOrigin,
Size: menuDims.Size,
}, menuCall
}
func (s DropdownSurface) Draw(gtx layout.Context, anchor DropdownAnchor, menu layout.Widget) layout.Dimensions {
placement, menuCall := s.Place(gtx, anchor, menu)
stack := op.Offset(placement.Origin).Push(gtx.Ops)
menuCall.Add(gtx.Ops)
stack.Pop()
return layout.Dimensions{Size: gtx.Constraints.Max}
}
type HeaderActionMetrics struct {
RowOriginX int
Spacing int
RowDims layout.Dimensions
SyncDims layout.Dimensions
LockDims layout.Dimensions
MainDims layout.Dimensions
type ActionMetrics struct {
RowOriginX int
Spacing int
SyncInnerSpacing int
RowDims layout.Dimensions
SyncDims layout.Dimensions
SyncPrimaryDims layout.Dimensions
SyncToggleDims layout.Dimensions
LockDims layout.Dimensions
MainDims layout.Dimensions
}
func (m HeaderActionMetrics) SyncAnchor() DropdownAnchor {
func (m ActionMetrics) SyncAnchor() DropdownAnchor {
return DropdownAnchor{
TriggerRightX: m.RowOriginX + m.SyncDims.Size.X,
TriggerBottomY: m.RowDims.Size.Y,
}
}
func (m HeaderActionMetrics) MainAnchor() DropdownAnchor {
func (m ActionMetrics) MainAnchor() DropdownAnchor {
triggerRightX := m.SyncDims.Size.X + m.Spacing + m.LockDims.Size.X + m.Spacing + m.MainDims.Size.X
return DropdownAnchor{
TriggerRightX: m.RowOriginX + triggerRightX,
TriggerBottomY: m.RowDims.Size.Y,
}
}
type ActionBounds struct {
SyncPrimary image.Rectangle
SyncToggle image.Rectangle
Lock image.Rectangle
MainMenu image.Rectangle
}
func (m ActionMetrics) Bounds() ActionBounds {
top := 0
syncLeft := m.RowOriginX
syncPrimary := image.Rect(syncLeft, top, syncLeft+m.SyncPrimaryDims.Size.X, top+m.SyncPrimaryDims.Size.Y)
syncToggleLeft := syncPrimary.Max.X + m.SyncInnerSpacing
syncToggle := image.Rect(syncToggleLeft, top, syncToggleLeft+m.SyncToggleDims.Size.X, top+m.SyncToggleDims.Size.Y)
lockLeft := syncLeft + m.SyncDims.Size.X + m.Spacing
lock := image.Rect(lockLeft, top, lockLeft+m.LockDims.Size.X, top+m.LockDims.Size.Y)
mainLeft := lock.Max.X + m.Spacing
mainMenu := image.Rect(mainLeft, top, mainLeft+m.MainDims.Size.X, top+m.MainDims.Size.Y)
return ActionBounds{
SyncPrimary: syncPrimary,
SyncToggle: syncToggle,
Lock: lock,
MainMenu: mainMenu,
}
}
+59
View File
@@ -0,0 +1,59 @@
package layout
import (
"image"
"gioui.org/layout"
"gioui.org/op"
"gioui.org/unit"
)
func IntrinsicCompactCard(gtx layout.Context, w layout.Widget, card func(layout.Context, layout.Widget) layout.Dimensions, logger func(name string, constraints layout.Constraints, dims layout.Dimensions)) layout.Dimensions {
measureGTX := gtx
measureGTX.Constraints.Min = image.Point{}
measureGTX.Constraints.Max.X = gtx.Constraints.Max.X
macro := op.Record(gtx.Ops)
contentDims := w(measureGTX)
_ = macro.Stop()
if logger != nil {
logger("intrinsic-measure", measureGTX.Constraints, contentDims)
}
width := contentDims.Size.X + gtx.Dp(unit.Dp(20))
maxWidth := gtx.Constraints.Max.X
if maxWidth > 0 && width > maxWidth {
width = maxWidth
}
if width > 0 {
gtx.Constraints.Min.X = width
gtx.Constraints.Max.X = width
}
dims := card(gtx, w)
if logger != nil {
logger("intrinsic-card", gtx.Constraints, dims)
}
return dims
}
func MenuActionWidth(gtx layout.Context, rows []layout.Widget) int {
width := 0
for _, row := range rows {
measureGTX := gtx
measureGTX.Constraints.Min = image.Point{}
macro := op.Record(gtx.Ops)
dims := row(measureGTX)
_ = macro.Stop()
if dims.Size.X > width {
width = dims.Size.X
}
}
return width
}
func RightAlignedAction(gtx layout.Context, width int, child layout.Widget) layout.Dimensions {
if width <= 0 {
return child(gtx)
}
gtx.Constraints.Min.X = width
gtx.Constraints.Max.X = width
return layout.E.Layout(gtx, child)
}
+54
View File
@@ -0,0 +1,54 @@
package header
import (
"image/color"
"image"
"gioui.org/layout"
"gioui.org/unit"
"gioui.org/widget"
"gioui.org/widget/material"
headerlayout "git.julianfamily.org/keepassgo/internal/appui/header/layout"
)
func MainMenu(gtx layout.Context, theme *material.Theme, rows []layout.Widget, card func(layout.Context, layout.Widget) layout.Dimensions, logger func(name string, constraints layout.Constraints, dims layout.Dimensions)) layout.Dimensions {
rowWidth := headerlayout.MenuActionWidth(gtx, rows)
if logger != nil {
logger("row-width", gtx.Constraints, layout.Dimensions{Size: image.Pt(rowWidth, 0)})
}
dims := headerlayout.IntrinsicCompactCard(gtx, func(gtx layout.Context) layout.Dimensions {
children := make([]layout.FlexChild, 0, (len(rows)*2)-1)
for i, row := range rows {
if i > 0 {
children = append(children, layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout))
}
current := row
children = append(children, layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return headerlayout.RightAlignedAction(gtx, rowWidth, current)
}))
}
dims := layout.Flex{Axis: layout.Vertical}.Layout(gtx, children...)
if logger != nil {
logger("rows", gtx.Constraints, dims)
}
return dims
}, card, logger)
if logger != nil {
logger("card", gtx.Constraints, dims)
}
return dims
}
func MainMenuButtonGroup(gtx layout.Context, theme *material.Theme, click *widget.Clickable, icon *widget.Icon, open bool, selectedColor, accentColor color.NRGBA) layout.Dimensions {
btn := material.IconButton(theme, click, icon, "Menu")
if open {
btn.Background = accentColor
btn.Color = color.NRGBA{R: 255, G: 252, B: 247, A: 255}
} else {
btn.Background = selectedColor
btn.Color = accentColor
}
btn.Size = unit.Dp(18)
btn.Inset = layout.UniformInset(unit.Dp(8))
return btn.Layout(gtx)
}
+362
View File
@@ -0,0 +1,362 @@
package appui
import (
"image"
"image/color"
"runtime"
"strings"
"gioui.org/layout"
"gioui.org/op"
"gioui.org/unit"
"gioui.org/widget"
"gioui.org/widget/material"
"git.julianfamily.org/keepassgo/internal/appstate"
syncmodel "git.julianfamily.org/keepassgo/internal/appui/sync"
"git.julianfamily.org/keepassgo/internal/vault"
)
func (u *ui) syncButtonGroupWithMetrics(gtx layout.Context) (layout.Dimensions, layout.Dimensions, layout.Dimensions) {
spacing := unit.Dp(4)
if u.usesCompactViewport() {
spacing = unit.Dp(3)
}
var primaryDims layout.Dimensions
var toggleDims layout.Dimensions
groupDims := layout.Flex{Alignment: layout.Middle}.Layout(gtx,
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
primaryDims = syncPrimaryButton(gtx, u.theme, &u.synchronizeVault, "Sync", u.usesCompactViewport())
return primaryDims
}),
layout.Rigid(layout.Spacer{Width: spacing}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
toggleDims = u.syncMenuToggle(gtx)
return toggleDims
}),
)
return groupDims, primaryDims, toggleDims
}
func (u *ui) syncMenuToggle(gtx layout.Context) layout.Dimensions {
btn := material.IconButton(u.theme, &u.toggleSyncMenu, u.chevronDownIcon, "More synchronize actions")
if u.syncMenuOpen {
btn.Background = accentColor
btn.Color = color.NRGBA{R: 255, G: 252, B: 247, A: 255}
} else {
btn.Background = color.NRGBA{R: 231, G: 236, B: 232, A: 255}
btn.Color = accentColor
}
btn.Size = unit.Dp(18)
btn.Inset = layout.UniformInset(unit.Dp(8))
if u.usesCompactViewport() {
btn.Size = unit.Dp(16)
btn.Inset = layout.UniformInset(unit.Dp(7))
}
return btn.Layout(gtx)
}
func (u *ui) syncMenuWidget() layout.Widget {
if !u.syncMenuOpen {
return nil
}
return u.syncMenu
}
func (u *ui) syncMenu(gtx layout.Context) layout.Dimensions {
model := u.buildSyncMenuModel()
profiles := u.availableRemoteProfiles()
credentials := u.availableRemoteCredentialEntries()
if len(u.vaultRemoteProfileClicks) < len(profiles) {
u.vaultRemoteProfileClicks = make([]widget.Clickable, len(profiles))
}
if len(u.vaultRemoteCredentialClicks) < len(credentials) {
u.vaultRemoteCredentialClicks = make([]widget.Clickable, len(credentials))
}
actionRows := u.syncMenuActionRows(model)
actionWidth := menuActionWidth(gtx, actionRows)
menu := func(gtx layout.Context) layout.Dimensions {
return intrinsicCompactCard(gtx, func(gtx layout.Context) layout.Dimensions {
return layout.Flex{Axis: layout.Vertical}.Layout(gtx, u.syncMenuRows(model, profiles, credentials, actionWidth)...)
})
}
reserveWidth := u.syncMenuTrailingReserveWidth(gtx)
if reserveWidth <= 0 {
return menu(gtx)
}
return layout.Flex{}.Layout(gtx,
layout.Rigid(menu),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return layout.Dimensions{Size: image.Pt(reserveWidth, 0)}
}),
)
}
func (u *ui) syncMenuTrailingReserveWidth(gtx layout.Context) int {
spacing := gtx.Dp(unit.Dp(8))
if u.usesCompactViewport() {
spacing = gtx.Dp(unit.Dp(8))
}
measureGTX := gtx
measureGTX.Constraints.Min = image.Point{}
lockOps := op.Record(gtx.Ops)
lockDims := func(gtx layout.Context) layout.Dimensions {
btn := material.Button(u.theme, &u.lockVault, "Lock")
return btn.Layout(gtx)
}(measureGTX)
_ = lockOps.Stop()
menuOps := op.Record(gtx.Ops)
menuDims := u.mainMenuButtonGroup(measureGTX)
_ = menuOps.Stop()
return spacing + lockDims.Size.X + spacing + menuDims.Size.X
}
func (u *ui) syncMenuActionRows(model syncmodel.MenuModel) []layout.Widget {
rows := []layout.Widget{
func(gtx layout.Context) layout.Dimensions {
return tonedButton(gtx, u.theme, &u.openAdvancedSync, "Open Advanced Sync")
},
}
if model.ShowShare {
rows = append(rows, func(gtx layout.Context) layout.Dimensions {
return tonedButton(gtx, u.theme, &u.shareCurrentVault, "Share Vault")
})
}
if model.ShowRemoteSyncSetupShortcut() {
rows = append(rows, func(gtx layout.Context) layout.Dimensions {
return tonedButton(gtx, u.theme, &u.useSavedAdvancedSyncRemote, model.RemoteSyncSetupShortcutLabel())
})
}
if model.ShowDirectRemoteSyncShortcut() {
rows = append(rows, func(gtx layout.Context) layout.Dimensions {
return tonedButton(gtx, u.theme, &u.openSelectedVaultRemote, model.DirectRemoteSyncShortcutLabel())
})
}
if model.ShowRemoteSyncSettingsShortcut() {
rows = append(rows, func(gtx layout.Context) layout.Dimensions {
return tonedButton(gtx, u.theme, &u.useSavedAdvancedSyncRemote, model.RemoteSyncSettingsShortcutLabel())
})
}
if model.ShowRemoveRemoteSyncShortcut() {
rows = append(rows, func(gtx layout.Context) layout.Dimensions {
return tonedButton(gtx, u.theme, &u.removeSelectedRemoteBinding, model.RemoveRemoteSyncShortcutLabel())
})
}
return rows
}
func (u *ui) syncMenuRows(model syncmodel.MenuModel, profiles []vault.RemoteProfile, credentials []vault.Entry, actionWidth int) []layout.FlexChild {
rows := u.syncMenuPrimaryRows(model, actionWidth)
rows = append(rows, u.syncMenuSavedBindingRows(model, profiles, credentials)...)
if model.ShowSaveCurrentBinding {
rows = append(rows, u.syncMenuSaveBindingRows(model)...)
}
return rows
}
func (u *ui) syncMenuPrimaryRows(model syncmodel.MenuModel, actionWidth int) []layout.FlexChild {
rows := []layout.FlexChild{}
if model.ShowShare {
rows = append(rows, layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return rightAlignedMenuAction(gtx, actionWidth, func(gtx layout.Context) layout.Dimensions {
return tonedButton(gtx, u.theme, &u.shareCurrentVault, "Share Vault")
})
}),
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
)
}))
}
rows = append(rows, u.syncMenuActionRow(actionWidth, &u.openAdvancedSync, "Open Advanced Sync"))
if model.ShowRemoteSyncSetupShortcut() {
rows = append(rows, layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout))
rows = append(rows, u.syncMenuActionRow(actionWidth, &u.useSavedAdvancedSyncRemote, model.RemoteSyncSetupShortcutLabel()))
}
if model.ShowDirectRemoteSyncShortcut() {
rows = append(rows, layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout))
rows = append(rows, u.syncMenuActionRow(actionWidth, &u.openSelectedVaultRemote, model.DirectRemoteSyncShortcutLabel()))
}
if model.ShowRemoteSyncSettingsShortcut() {
rows = append(rows, layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout))
rows = append(rows, u.syncMenuActionRow(actionWidth, &u.useSavedAdvancedSyncRemote, model.RemoteSyncSettingsShortcutLabel()))
}
if model.ShowRemoveRemoteSyncShortcut() {
rows = append(rows, layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout))
rows = append(rows, u.syncMenuActionRow(actionWidth, &u.removeSelectedRemoteBinding, model.RemoveRemoteSyncShortcutLabel()))
}
return rows
}
func (u *ui) syncMenuActionRow(actionWidth int, click *widget.Clickable, label string) layout.FlexChild {
return layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return rightAlignedMenuAction(gtx, actionWidth, func(gtx layout.Context) layout.Dimensions {
return tonedButton(gtx, u.theme, click, label)
})
})
}
func (u *ui) syncMenuSavedBindingRows(model syncmodel.MenuModel, profiles []vault.RemoteProfile, credentials []vault.Entry) []layout.FlexChild {
if !u.hasOpenVault() || len(profiles) == 0 || len(credentials) == 0 {
return nil
}
rows := []layout.FlexChild{
layout.Rigid(layout.Spacer{Height: unit.Dp(10)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
lbl := material.Label(u.theme, unit.Sp(11), model.SavedBindingHeading())
lbl.Color = mutedColor
return lbl.Layout(gtx)
}),
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
}
if !model.ShowSelectors {
rows = append(rows, layout.Rigid(u.syncMenuSavedBindingSummary(model)))
} else {
rows = append(rows, u.syncMenuSelectorRows(model, profiles, credentials)...)
}
if _, ok := u.selectedVaultRemoteProfile(); ok {
if _, ok := u.selectedVaultRemoteCredentialEntry(); ok {
rows = append(rows,
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return tonedButton(gtx, u.theme, &u.openSelectedVaultRemote, u.openSelectedVaultRemoteButtonLabel())
}),
)
}
}
return rows
}
func (u *ui) syncMenuSavedBindingSummary(model syncmodel.MenuModel) layout.Widget {
return func(gtx layout.Context) layout.Dimensions {
summary := model.SavedBindingSummary
if !summary.OK {
return layout.Dimensions{}
}
return layout.Background{}.Layout(gtx, fill(color.NRGBA{R: 242, G: 245, B: 240, A: 255}), func(gtx layout.Context) layout.Dimensions {
return layout.UniformInset(unit.Dp(10)).Layout(gtx, 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(u.theme, unit.Sp(13), summary.ProfileLabel)
lbl.Color = accentColor
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(12), "Credential: "+summary.CredentialLabel)
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(12), summary.SyncLabel)
lbl.Color = mutedColor
return lbl.Layout(gtx)
}),
)
})
})
}
}
func (u *ui) syncMenuSaveBindingRows(model syncmodel.MenuModel) []layout.FlexChild {
return []layout.FlexChild{
layout.Rigid(layout.Spacer{Height: unit.Dp(10)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
lbl := material.Label(u.theme, unit.Sp(11), model.SaveCurrentRemoteBindingHeading())
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 tonedButton(gtx, u.theme, &u.saveCurrentRemoteBinding, model.SaveCurrentRemoteBindingButtonLabel())
}),
}
}
func (u *ui) syncMenuSelectorRows(_ syncmodel.MenuModel, profiles []vault.RemoteProfile, credentials []vault.Entry) []layout.FlexChild {
rows := make([]layout.FlexChild, 0, len(profiles)+len(credentials)+4)
for i, profile := range profiles {
i := i
profile := profile
rows = append(rows, layout.Rigid(func(gtx layout.Context) layout.Dimensions {
selected := u.selectedVaultRemoteProfileID == profile.ID
return layout.Inset{Bottom: unit.Dp(4)}.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
return recentSelectionCard(gtx, selected, func(gtx layout.Context) layout.Dimensions {
return u.vaultRemoteProfileClicks[i].Layout(gtx, func(gtx layout.Context) layout.Dimensions {
lbl := material.Label(u.theme, unit.Sp(13), profile.Name)
lbl.Color = accentColor
return lbl.Layout(gtx)
})
})
})
}))
}
rows = append(rows, layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout))
for i, entry := range credentials {
i := i
entry := entry
rows = append(rows, layout.Rigid(func(gtx layout.Context) layout.Dimensions {
selected := u.selectedVaultRemoteCredentialEntryID == entry.ID
label := entry.Title
if entry.Username != "" {
label += " · " + entry.Username
}
return layout.Inset{Bottom: unit.Dp(4)}.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
return recentSelectionCard(gtx, selected, func(gtx layout.Context) layout.Dimensions {
return u.vaultRemoteCredentialClicks[i].Layout(gtx, func(gtx layout.Context) layout.Dimensions {
lbl := material.Label(u.theme, unit.Sp(13), label)
lbl.Color = accentColor
return lbl.Layout(gtx)
})
})
})
}))
}
return rows
}
func (u *ui) buildSyncMenuModel() syncmodel.MenuModel {
model := syncmodel.MenuModel{
HasOpenVault: u.hasOpenVault(),
ShowSelectors: u.shouldShowSavedRemoteBindingSelectors(),
ShowShare: supportsVaultShare(runtime.GOOS) && u.vaultSharer != nil && strings.TrimSpace(u.currentShareableVaultPath()) != "",
RemoteBaseURL: strings.TrimSpace(u.remoteBaseURL.Text()),
RemotePath: strings.TrimSpace(u.remotePath.Text()),
RemoteUsername: strings.TrimSpace(u.remoteUsername.Text()),
RemotePassword: u.remotePassword.Text(),
SelectedVaultSyncMode: normalizeUISyncMode(u.selectedVaultRemoteSyncMode),
}
_, model.HasSelectedBinding = u.selectedVaultRemoteBinding()
model.SavedBindingSummary = u.computeSavedRemoteBindingSummary()
model.ShowSaveCurrentBinding = model.HasOpenVault && model.RemoteBaseURL != "" && model.RemotePath != "" && model.RemoteUsername != "" && model.RemotePassword != ""
return model
}
func (u *ui) computeSavedRemoteBindingSummary() syncmodel.MenuBindingSummary {
profile, ok := u.selectedVaultRemoteProfile()
if !ok {
return syncmodel.MenuBindingSummary{}
}
entry, ok := u.selectedVaultRemoteCredentialEntry()
if !ok {
return syncmodel.MenuBindingSummary{}
}
credentialLabel := entry.Title
if strings.TrimSpace(entry.Username) != "" {
credentialLabel += " · " + strings.TrimSpace(entry.Username)
}
syncLabel := "Sync manually when you choose Use Remote Sync."
if normalizeUISyncMode(u.selectedVaultRemoteSyncMode) == appstate.SyncModeAutomaticOnOpenSave {
syncLabel = "Syncs automatically on open and save."
}
return syncmodel.MenuBindingSummary{
ProfileLabel: profile.Name,
CredentialLabel: credentialLabel,
SyncLabel: syncLabel,
OK: true,
}
}
@@ -5,28 +5,42 @@ import (
"strconv"
"strings"
"gioui.org/io/event"
"gioui.org/io/key"
"gioui.org/layout"
"git.julianfamily.org/keepassgo/internal/appstate"
editormodel "git.julianfamily.org/keepassgo/internal/appui/editor"
"git.julianfamily.org/keepassgo/internal/clipboard"
)
type focusID string
type detailField string
type detailField = editormodel.Field
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"
detailFieldID = editormodel.FieldID
detailFieldTitle = editormodel.FieldTitle
detailFieldUsername = editormodel.FieldUsername
detailFieldPassword = editormodel.FieldPassword
detailFieldURL = editormodel.FieldURL
detailFieldPath = editormodel.FieldPath
detailFieldTags = editormodel.FieldTags
detailFieldPasswordProfile = editormodel.FieldPasswordProfile
detailFieldNotes = editormodel.FieldNotes
detailFieldFields = editormodel.FieldFields
detailFieldHistoryIndex = editormodel.FieldHistoryIndex
)
const (
shortcutSearch = "search"
shortcutSave = "save"
shortcutLock = "lock"
shortcutNewEntry = "new-entry"
shortcutCopyUser = "copy-user"
shortcutCopyPassword = "copy-password"
shortcutCopyURL = "copy-url"
)
func breadcrumbFocusID(index int) focusID {
@@ -41,6 +55,68 @@ func detailFocusID(field detailField) focusID {
return focusID("detail:" + string(field))
}
func (u *ui) processShortcuts(gtx layout.Context) {
event.Op(gtx.Ops, u)
for {
ev, ok := gtx.Event(
key.Filter{Name: "F", Required: key.ModShortcut},
key.Filter{Name: "S", Required: key.ModShortcut},
key.Filter{Name: "L", Required: key.ModShortcut},
key.Filter{Name: "N", Required: key.ModShortcut},
key.Filter{Name: "U", Required: key.ModShortcut},
key.Filter{Name: "P", Required: key.ModShortcut},
key.Filter{Name: "O", Required: key.ModShortcut},
key.Filter{Name: key.NameTab, Optional: key.ModShift},
key.Filter{Name: key.NameLeftArrow},
key.Filter{Name: key.NameRightArrow},
key.Filter{Name: key.NameUpArrow},
key.Filter{Name: key.NameDownArrow},
key.Filter{Name: key.NameReturn},
key.Filter{Name: key.NameBack},
key.Filter{Name: key.NameEscape},
)
if !ok {
break
}
ke, ok := ev.(key.Event)
if !ok || ke.State != key.Press {
continue
}
u.handleKeyPress(ke.Name, ke.Modifiers)
if ke.Name == key.NameBack || ke.Name == key.NameEscape {
_ = u.handlePhoneBack()
}
}
}
func (u *ui) performShortcut(name string) error {
switch name {
case shortcutSearch:
u.keyboardFocus = focusSearch
return nil
case shortcutSave:
return u.saveAction()
case shortcutLock:
return u.lockAction()
case shortcutNewEntry:
u.state.BeginNewEntry()
u.loadSelectedEntryIntoEditor()
u.entryPath.SetText(strings.Join(u.state.CurrentPath, " / "))
u.keyboardFocus = detailFocusID(detailFieldTitle)
return nil
case shortcutCopyUser:
return u.copySelectedFieldAction(clipboard.TargetUsername)
case shortcutCopyPassword:
return u.copySelectedFieldAction(clipboard.TargetPassword)
case shortcutCopyURL:
return u.copySelectedFieldAction(clipboard.TargetURL)
default:
return nil
}
}
func (u *ui) handleKeyPress(name key.Name, modifiers key.Modifiers) bool {
if u.handleShortcutKey(name, modifiers) {
return true
@@ -336,19 +412,7 @@ func (u *ui) focusedDetailIndex() int {
}
func detailFocusOrder() []detailField {
return []detailField{
detailFieldID,
detailFieldTitle,
detailFieldUsername,
detailFieldPassword,
detailFieldURL,
detailFieldPath,
detailFieldTags,
detailFieldPasswordProfile,
detailFieldNotes,
detailFieldFields,
detailFieldHistoryIndex,
}
return editormodel.FocusOrder()
}
func canonicalFocusID(id focusID) focusID {
+9
View File
@@ -0,0 +1,9 @@
package lifecycle
type OpenIntent string
const (
OpenIntentNone OpenIntent = ""
OpenIntentRemoteSyncSetup OpenIntent = "remote_sync_setup"
OpenIntentRemoteSyncSettings OpenIntent = "remote_sync_settings"
)
+778
View File
@@ -0,0 +1,778 @@
package appui
import (
"errors"
"fmt"
"io"
"os"
"path/filepath"
"runtime"
"strings"
"gioui.org/layout"
"gioui.org/x/explorer"
"git.julianfamily.org/keepassgo/internal/appstate"
"git.julianfamily.org/keepassgo/internal/session"
"git.julianfamily.org/keepassgo/internal/vault"
"git.julianfamily.org/keepassgo/internal/webdav"
)
func (u *ui) createVaultAction() error {
key, err := u.currentMasterKey()
defer u.clearMasterPassword()
if err != nil {
return err
}
if err := u.state.ConfigureSecurity(vault.SecuritySettings{
Cipher: strings.TrimSpace(u.securityCipher.Text()),
KDF: strings.TrimSpace(u.securityKDF.Text()),
}); err != nil {
return err
}
if err := u.state.CreateVault(key); err != nil {
return err
}
if u.lifecycleMode == "local" {
u.selectedVaultRemoteProfileID = ""
u.selectedVaultRemoteCredentialEntryID = ""
u.selectedVaultRemoteSyncMode = appstate.SyncModeManual
u.remoteBaseURL.SetText("")
u.remotePath.SetText("")
u.remoteUsername.SetText("")
u.remotePassword.SetText("")
if err := u.state.SaveAs(u.saveAsTargetPath()); err != nil {
return err
}
u.vaultPath.SetText(u.saveAsTargetPath())
u.noteRecentVault(u.saveAsTargetPath())
}
u.resetPasswordPeek()
u.adoptStateCurrentPath()
u.loadSecuritySettingsFromSession()
u.editingEntry = false
u.filter()
return nil
}
func (u *ui) openVaultAction() error {
key, err := u.currentMasterKey()
defer u.clearMasterPassword()
if err != nil {
return err
}
path := strings.TrimSpace(u.vaultPath.Text())
if path == "" {
return errors.New(errVaultPathRequired)
}
if err := u.state.OpenVault(path, key); err != nil {
return err
}
u.noteRecentVault(path)
u.resetPasswordPeek()
u.adoptStateCurrentPath()
u.restoreRecentVaultGroup(path)
u.syncSavedRemoteBindingSelection()
if err := u.synchronizeSelectedRemoteBindingOnOpen(); err != nil {
u.showStatusMessage("Remote sync on open failed: " + err.Error())
}
u.loadSecuritySettingsFromSession()
u.editingEntry = false
u.filter()
u.applyPendingLifecycleOpenIntent()
return nil
}
func (u *ui) startOpenVaultAction() {
manager, ok := u.state.Session.(*session.Manager)
if !ok {
u.runAction("open vault", u.openVaultAction)
return
}
key, err := u.currentMasterKey()
u.clearMasterPassword()
if err != nil {
u.state.ErrorMessage = u.describeActionError("open vault", err)
u.requestMasterPassFocus = true
return
}
path := strings.TrimSpace(u.vaultPath.Text())
if path == "" {
u.state.ErrorMessage = u.describeActionError("open vault", errors.New(errVaultPathRequired))
u.requestMasterPassFocus = true
return
}
u.lastLifecycleAction = "open vault"
u.runBackgroundAction("open vault", func() (func() error, error) {
prepared, err := session.PrepareLocalOpen(path, key)
if err != nil {
return nil, err
}
return func() error {
manager.ApplyPreparedLocalOpen(prepared)
u.noteRecentVault(path)
u.resetPasswordPeek()
u.adoptStateCurrentPath()
u.restoreRecentVaultGroup(path)
u.syncSavedRemoteBindingSelection()
if err := u.synchronizeSelectedRemoteBindingOnOpen(); err != nil {
u.showStatusMessage("Remote sync on open failed: " + err.Error())
}
u.loadSecuritySettingsFromSession()
u.editingEntry = false
u.filter()
u.applyPendingLifecycleOpenIntent()
return nil
}, nil
})
}
func (u *ui) shouldShowLifecycleRemoteSyncAction() bool {
return strings.TrimSpace(u.vaultPath.Text()) != ""
}
func (u *ui) lifecycleRemoteSyncActionLabel() string {
path := strings.TrimSpace(u.vaultPath.Text())
if path == "" {
return "Open Vault And Set Up Remote Sync"
}
if hasBoundRecentRemote(u.recentRemotes, path) {
return "Open Vault And Open Remote Sync Settings"
}
return "Open Vault And Set Up Remote Sync"
}
func (u *ui) beginLifecycleRemoteSyncOpen() {
path := strings.TrimSpace(u.vaultPath.Text())
switch {
case path == "":
u.pendingLifecycleOpenIntent = lifecycleOpenIntentNone
case hasBoundRecentRemote(u.recentRemotes, path):
u.pendingLifecycleOpenIntent = lifecycleOpenIntentRemoteSyncSettings
default:
u.pendingLifecycleOpenIntent = lifecycleOpenIntentRemoteSyncSetup
}
u.startOpenVaultAction()
}
func (u *ui) applyPendingLifecycleOpenIntent() {
intent := u.pendingLifecycleOpenIntent
u.pendingLifecycleOpenIntent = lifecycleOpenIntentNone
switch intent {
case lifecycleOpenIntentRemoteSyncSetup, lifecycleOpenIntentRemoteSyncSettings:
u.openRemoteSyncSetupDialog()
}
}
func (u *ui) saveAction() error {
if err := u.state.Save(); err != nil {
return err
}
if err := u.synchronizeSelectedRemoteBindingOnSave(); err != nil {
return err
}
u.filter()
return nil
}
func (u *ui) saveAsAction() error {
path := u.saveAsTargetPath()
if err := u.state.SaveAs(path); err != nil {
return err
}
u.vaultPath.SetText(path)
u.noteRecentVault(path)
u.filter()
return nil
}
func (u *ui) openRemoteAction() error {
key, err := u.currentMasterKey()
defer u.clearMasterPassword()
if err != nil {
return err
}
if binding, resolved, ok, err := u.bootstrapSelectedVaultRemoteBinding(key); err != nil {
return err
} else if ok {
if err := u.state.OpenBoundRemoteVault(binding, key); err != nil {
return err
}
u.remoteBaseURL.SetText(resolved.Profile.BaseURL)
u.remotePath.SetText(resolved.Profile.Path)
u.noteRecentRemote(resolved.Profile.BaseURL, resolved.Profile.Path)
u.resetPasswordPeek()
u.restoreRecentRemoteGroup(resolved.Profile.BaseURL, resolved.Profile.Path)
u.loadSecuritySettingsFromSession()
u.editingEntry = false
u.filter()
return nil
}
client := webdav.Client{
BaseURL: strings.TrimSpace(u.remoteBaseURL.Text()),
Username: strings.TrimSpace(u.remoteUsername.Text()),
Password: u.remotePassword.Text(),
}
if err := u.state.OpenRemoteVault(client, strings.TrimSpace(u.remotePath.Text()), key); err != nil {
return err
}
if err := u.materializeCurrentRemoteCache(); err != nil {
return err
}
u.noteRecentRemote(
strings.TrimSpace(u.remoteBaseURL.Text()),
strings.TrimSpace(u.remotePath.Text()),
)
u.resetPasswordPeek()
u.restoreRecentRemoteGroup(strings.TrimSpace(u.remoteBaseURL.Text()), strings.TrimSpace(u.remotePath.Text()))
u.loadSecuritySettingsFromSession()
u.editingEntry = false
u.filter()
return nil
}
func (u *ui) startOpenRemoteAction() {
manager, ok := u.state.Session.(*session.Manager)
if !ok {
u.runAction("open remote vault", u.openRemoteAction)
return
}
key, err := u.currentMasterKey()
u.clearMasterPassword()
if err != nil {
u.state.ErrorMessage = u.describeActionError("open remote vault", err)
u.requestMasterPassFocus = true
return
}
client := webdav.Client{
BaseURL: strings.TrimSpace(u.remoteBaseURL.Text()),
Username: strings.TrimSpace(u.remoteUsername.Text()),
Password: u.remotePassword.Text(),
}
remotePath := strings.TrimSpace(u.remotePath.Text())
u.lastLifecycleAction = "open remote vault"
u.runBackgroundAction("open remote vault", func() (func() error, error) {
binding, bindingOK := u.selectedVaultRemoteBinding()
if bindingOK && !u.hasOpenVault() && strings.TrimSpace(binding.LocalVaultPath) != "" {
preparedLocal, err := session.PrepareLocalOpen(binding.LocalVaultPath, key)
if err != nil {
return nil, err
}
resolved, err := binding.Resolve(preparedLocal.Model)
if err != nil {
return nil, err
}
preparedRemote, err := session.PrepareRemoteOpen(webdav.Client{
BaseURL: resolved.Profile.BaseURL,
Username: resolved.Credentials.Username,
Password: resolved.Credentials.Password,
}, resolved.Profile.Path, key)
if err != nil {
return nil, err
}
return func() error {
manager.ApplyPreparedLocalOpen(preparedLocal)
u.vaultPath.SetText(binding.LocalVaultPath)
u.noteRecentVault(binding.LocalVaultPath)
u.restoreRecentVaultGroup(binding.LocalVaultPath)
manager.ApplyPreparedRemoteOpen(preparedRemote)
u.remoteBaseURL.SetText(resolved.Profile.BaseURL)
u.remotePath.SetText(resolved.Profile.Path)
u.noteRecentRemote(resolved.Profile.BaseURL, resolved.Profile.Path)
u.resetPasswordPeek()
u.restoreRecentRemoteGroup(resolved.Profile.BaseURL, resolved.Profile.Path)
u.loadSecuritySettingsFromSession()
u.editingEntry = false
u.filter()
return nil
}, nil
}
if u.hasOpenVault() {
if _, resolved, ok, err := u.resolvedSelectedVaultRemoteBinding(); err != nil {
return nil, err
} else if ok {
client = webdav.Client{
BaseURL: resolved.Profile.BaseURL,
Username: resolved.Credentials.Username,
Password: resolved.Credentials.Password,
}
remotePath = resolved.Profile.Path
u.remoteBaseURL.SetText(resolved.Profile.BaseURL)
u.remotePath.SetText(resolved.Profile.Path)
}
}
prepared, err := session.PrepareRemoteOpen(client, remotePath, key)
if err != nil {
return nil, err
}
return func() error {
manager.ApplyPreparedRemoteOpen(prepared)
if err := u.materializeCurrentRemoteCache(); err != nil {
return err
}
u.noteRecentRemote(
strings.TrimSpace(u.remoteBaseURL.Text()),
remotePath,
)
u.resetPasswordPeek()
u.restoreRecentRemoteGroup(strings.TrimSpace(u.remoteBaseURL.Text()), remotePath)
u.loadSecuritySettingsFromSession()
u.editingEntry = false
u.filter()
return nil
}, nil
})
}
func (u *ui) lockAction() error {
u.clearMasterPassword()
if err := u.state.Lock(); err != nil {
return err
}
u.requestMasterPassFocus = true
u.adoptStateCurrentPath()
u.resetPasswordPeek()
u.editingEntry = false
u.filter()
return nil
}
func (u *ui) unlockAction() error {
key, err := u.currentMasterKey()
defer u.clearMasterPassword()
if err != nil {
return err
}
if err := u.state.Unlock(key); err != nil {
return err
}
u.resetPasswordPeek()
u.adoptStateCurrentPath()
u.loadSecuritySettingsFromSession()
u.editingEntry = false
u.filter()
return nil
}
func (u *ui) startUnlockAction() {
manager, ok := u.state.Session.(*session.Manager)
if !ok {
u.runAction("unlock vault", u.unlockAction)
return
}
key, err := u.currentMasterKey()
u.clearMasterPassword()
if err != nil {
u.state.ErrorMessage = u.describeActionError("unlock vault", err)
u.requestMasterPassFocus = true
return
}
encoded := append([]byte(nil), manager.EncodedBytes()...)
u.runBackgroundAction("unlock vault", func() (func() error, error) {
prepared, err := session.PrepareUnlock(encoded, key)
if err != nil {
return nil, err
}
return func() error {
manager.ApplyPreparedUnlock(prepared)
u.resetPasswordPeek()
u.adoptStateCurrentPath()
u.loadSecuritySettingsFromSession()
u.editingEntry = false
u.filter()
return nil
}, nil
})
}
func (u *ui) changeMasterKeyAction() error {
key, err := u.currentMasterKey()
defer u.clearMasterPassword()
if err != nil {
return err
}
return u.state.ChangeMasterKey(key)
}
func (u *ui) loadSecuritySettingsFromSession() {
settings, err := u.state.SecuritySettings()
if err != nil {
return
}
u.securityCipher.SetText(settings.Cipher)
u.securityKDF.SetText(settings.KDF)
}
func (u *ui) clearMasterPassword() {
u.masterPassword.SetText("")
}
func (u *ui) synchronizeAction() error {
if err := u.state.Synchronize(); err != nil {
return err
}
u.syncMenuOpen = false
u.filter()
return nil
}
func (u *ui) openAdvancedSyncDialog() {
u.syncDialogOpen = true
u.syncMenuOpen = false
u.showSyncPassword = false
u.syncDialogList.Position = layout.Position{}
u.syncDialogPurpose = syncDialogPurposeAdvanced
u.syncSourceMode = u.syncDefaultSourceMode
u.syncDirection = u.syncDefaultDirection
if strings.TrimSpace(u.syncLocalPath.Text()) == "" {
u.syncLocalPath.SetText(strings.TrimSpace(u.vaultPath.Text()))
}
u.syncSavedRemoteBindingSelection()
u.prefillAdvancedSyncRemoteFromSavedBinding()
}
func (u *ui) openRemoteSyncSetupDialog() {
u.syncDialogOpen = true
u.syncMenuOpen = false
u.showSyncPassword = false
u.syncDialogList.Position = layout.Position{}
u.syncDialogPurpose = syncDialogPurposeRemoteSetup
u.syncSourceMode = syncSourceRemote
u.syncDirection = syncDirectionPush
u.syncSetupAutomatic.Value = true
if strings.TrimSpace(u.syncLocalPath.Text()) == "" {
u.syncLocalPath.SetText(strings.TrimSpace(u.vaultPath.Text()))
}
u.syncSavedRemoteBindingSelection()
u.prefillAdvancedSyncRemoteFromSavedBinding()
if _, ok := u.selectedVaultRemoteBinding(); ok && u.selectedVaultRemoteSyncMode == appstate.SyncModeManual {
u.syncSetupAutomatic.Value = false
}
}
func (u *ui) clearSyncLocalImport() {
u.syncLocalImportName = ""
u.syncLocalImportContent = nil
}
func (u *ui) selectedSyncLocalImport() (string, []byte, bool) {
name := strings.TrimSpace(u.syncLocalImportName)
if name == "" || name != strings.TrimSpace(u.syncLocalPath.Text()) || len(u.syncLocalImportContent) == 0 {
return "", nil, false
}
return name, append([]byte(nil), u.syncLocalImportContent...), true
}
func sanitizeSyncSourceMode(mode syncSourceMode) syncSourceMode {
switch mode {
case syncSourceRemote:
return syncSourceRemote
default:
return syncSourceLocal
}
}
func sanitizeSyncDirection(direction syncDirection) syncDirection {
switch direction {
case syncDirectionPush:
return syncDirectionPush
default:
return syncDirectionPull
}
}
func (u *ui) advancedSyncAction() error {
switch u.syncDirection {
case syncDirectionPush:
return u.advancedSyncToAction()
default:
return u.advancedSyncFromAction()
}
}
func (u *ui) advancedSyncFromAction() error {
switch u.syncSourceMode {
case syncSourceRemote:
client := webdav.Client{
BaseURL: strings.TrimSpace(u.syncRemoteBaseURL.Text()),
Username: strings.TrimSpace(u.syncRemoteUsername.Text()),
Password: u.syncRemotePassword.Text(),
}
if err := u.state.SynchronizeFromRemote(client, strings.TrimSpace(u.syncRemotePath.Text())); err != nil {
return err
}
default:
if name, content, ok := u.selectedSyncLocalImport(); ok {
if err := u.state.SynchronizeFromLocalBytes(name, content); err != nil {
return err
}
break
}
path := strings.TrimSpace(u.syncLocalPath.Text())
if path == "" {
return errors.New(errVaultPathRequired)
}
if err := u.state.SynchronizeFromLocal(path); err != nil {
return err
}
}
u.syncDialogOpen = false
u.showSyncPassword = false
u.filter()
return nil
}
func (u *ui) startChooseSyncLocalSourceAction() {
if runtime.GOOS != "android" || u.fileExplorer == nil {
u.runAction("choose sync path", func() error {
u.clearSyncLocalImport()
return u.chooseExistingFileAction(&u.syncLocalPath)
})
return
}
u.runBackgroundAction("choose sync file", func() (func() error, error) {
file, err := u.fileExplorer.ChooseFile(".kdbx")
if err != nil {
if errors.Is(err, explorer.ErrUserDecline) {
return func() error { return nil }, nil
}
return nil, err
}
defer file.Close()
content, err := io.ReadAll(file)
if err != nil {
return nil, err
}
label := "Selected Android vault"
return func() error {
u.syncLocalImportName = label
u.syncLocalImportContent = append([]byte(nil), content...)
u.syncLocalPath.SetText(label)
return nil
}, nil
})
}
func pickedDocumentName(file io.ReadCloser, fallback string) string {
if named, ok := file.(interface{ Name() string }); ok {
if base := filepath.Base(strings.TrimSpace(named.Name())); base != "" && base != "." && base != string(filepath.Separator) {
return base
}
}
fallback = filepath.Base(strings.TrimSpace(fallback))
if fallback == "" || fallback == "." || fallback == string(filepath.Separator) {
return "selected-vault.kdbx"
}
return fallback
}
func (u *ui) startChooseVaultPathAction() {
if runtime.GOOS != "android" || u.fileExplorer == nil {
u.runAction("choose vault path", func() error { return u.chooseExistingFileAction(&u.vaultPath) })
return
}
u.runBackgroundAction("choose vault file", func() (func() error, error) {
file, err := u.fileExplorer.ChooseFile(".kdbx")
if err != nil {
if errors.Is(err, explorer.ErrUserDecline) {
return func() error { return nil }, nil
}
return nil, err
}
defer file.Close()
content, err := io.ReadAll(file)
if err != nil {
return nil, err
}
name := pickedDocumentName(file, "selected-vault.kdbx")
return func() error {
return u.importSharedVaultBytesAction(name, content)
}, nil
})
}
func (u *ui) startImportSharedVaultAction() {
if !supportsSharedVaultImport(runtime.GOOS) || u.fileExplorer == nil {
return
}
u.runBackgroundAction("import shared vault", func() (func() error, error) {
file, err := u.fileExplorer.ChooseFile(".kdbx")
if err != nil {
if errors.Is(err, explorer.ErrUserDecline) {
return func() error { return nil }, nil
}
return nil, err
}
defer file.Close()
content, err := io.ReadAll(file)
if err != nil {
return nil, err
}
return func() error {
return u.importSharedVaultBytesAction("shared-vault.kdbx", content)
}, nil
})
}
func (u *ui) advancedSyncToAction() error {
switch u.syncSourceMode {
case syncSourceRemote:
baseURL := strings.TrimSpace(u.syncRemoteBaseURL.Text())
remotePath := strings.TrimSpace(u.syncRemotePath.Text())
client := webdav.Client{
BaseURL: baseURL,
Username: strings.TrimSpace(u.syncRemoteUsername.Text()),
Password: u.syncRemotePassword.Text(),
}
if err := u.state.SynchronizeToRemote(client, remotePath); err != nil {
return err
}
if u.syncDialogPurpose == syncDialogPurposeRemoteSetup {
if err := u.persistSyncDialogRemoteBinding(baseURL, remotePath); err != nil {
return err
}
u.showStatusMessage("Remote sync is set up for this vault.")
}
default:
path := strings.TrimSpace(u.syncLocalPath.Text())
if path == "" {
return errors.New(errVaultPathRequired)
}
if err := u.state.SynchronizeToLocal(path); err != nil {
return err
}
}
u.syncDialogOpen = false
u.showSyncPassword = false
u.filter()
return nil
}
func (u *ui) persistSyncDialogRemoteBinding(baseURL, remotePath string) error {
baseURL = strings.TrimSpace(baseURL)
remotePath = strings.TrimSpace(remotePath)
if baseURL == "" || remotePath == "" {
return fmt.Errorf("remote setup requires base URL and path")
}
input := appstate.RemoteBindingInput{
LocalVaultPath: strings.TrimSpace(u.vaultPath.Text()),
RemoteProfileID: "remote-profile-" + remoteBindingSuffix(baseURL, remotePath, strings.TrimSpace(u.syncRemoteUsername.Text())),
RemoteProfileName: friendlyRecentRemoteLabel(recentRemoteRecord{BaseURL: baseURL, Path: remotePath}),
BaseURL: baseURL,
RemotePath: remotePath,
CredentialEntryID: "remote-credential-" + remoteBindingSuffix(baseURL, remotePath, strings.TrimSpace(u.syncRemoteUsername.Text())),
CredentialTitle: "WebDAV Sign-In" + func() string {
if user := strings.TrimSpace(u.syncRemoteUsername.Text()); user != "" {
return " · " + user
}
return ""
}(),
Username: strings.TrimSpace(u.syncRemoteUsername.Text()),
Password: u.syncRemotePassword.Text(),
CredentialPath: append([]string(nil), u.currentPath...),
SyncMode: u.syncSetupMode(),
}
binding, err := u.state.ConfigureRemoteBinding(input)
if err != nil {
return err
}
if err := u.state.Save(); err != nil {
return err
}
u.selectedVaultRemoteProfileID = binding.RemoteProfileID
u.selectedVaultRemoteCredentialEntryID = binding.CredentialEntryID
u.selectedVaultRemoteSyncMode = binding.SyncMode
u.remoteBaseURL.SetText(baseURL)
u.remotePath.SetText(remotePath)
u.remoteUsername.SetText(strings.TrimSpace(u.syncRemoteUsername.Text()))
u.remotePassword.SetText(u.syncRemotePassword.Text())
u.noteRecentRemote(baseURL, remotePath)
return nil
}
func (u *ui) saveAsTargetPath() string {
path := strings.TrimSpace(u.saveAsPath.Text())
if path != "" {
return path
}
return u.defaultSaveAsPath
}
func (u *ui) importedVaultDestination(name string) string {
baseTarget := u.saveAsTargetPath()
baseDir := filepath.Dir(baseTarget)
baseName := filepath.Base(strings.TrimSpace(name))
switch {
case baseName == "" || baseName == "." || baseName == string(filepath.Separator):
return baseTarget
case strings.HasSuffix(strings.ToLower(baseName), ".kdbx"):
return filepath.Join(baseDir, baseName)
default:
return baseTarget
}
}
func (u *ui) consumePendingSharedVaultImport() {
path := strings.TrimSpace(u.pendingSharedVaultPath)
if path == "" {
return
}
content, err := os.ReadFile(path)
if err != nil {
if !errors.Is(err, os.ErrNotExist) {
u.state.ErrorMessage = fmt.Sprintf("import shared vault: %v", err)
}
return
}
name := "shared-vault.kdbx"
if namePath := strings.TrimSpace(u.pendingSharedVaultNamePath); namePath != "" {
if rawName, err := os.ReadFile(namePath); err == nil {
if trimmed := strings.TrimSpace(string(rawName)); trimmed != "" {
name = trimmed
}
}
}
if err := u.importSharedVaultBytesAction(name, content); err != nil {
u.state.ErrorMessage = fmt.Sprintf("import shared vault: %v", err)
return
}
_ = os.Remove(path)
if namePath := strings.TrimSpace(u.pendingSharedVaultNamePath); namePath != "" {
_ = os.Remove(namePath)
}
}
func (u *ui) importSharedVaultBytesAction(name string, content []byte) error {
target := u.importedVaultDestination(name)
if err := os.MkdirAll(filepath.Dir(target), 0o700); err != nil {
return err
}
if err := os.WriteFile(target, append([]byte(nil), content...), 0o600); err != nil {
return err
}
u.lifecycleMode = "local"
u.vaultPath.SetText(target)
u.noteRecentVault(target)
u.state.ErrorMessage = ""
u.state.StatusMessage = ""
u.requestMasterPassFocus = true
u.filter()
return nil
}
func (u *ui) currentShareableVaultPath() string {
return strings.TrimSpace(u.vaultPath.Text())
}
func (u *ui) shareCurrentVaultAction() error {
if u.vaultSharer == nil {
return fmt.Errorf("vault sharing is not available on this platform")
}
path := u.currentShareableVaultPath()
if path == "" {
return errors.New(errVaultPathRequired)
}
if err := u.state.Save(); err != nil {
return err
}
return u.vaultSharer.ShareVault(path, friendlyRecentVaultLabel(path))
}
@@ -18,6 +18,23 @@ import (
"git.julianfamily.org/keepassgo/internal/appstate"
)
func (u *ui) lifecycleScreen(gtx layout.Context) layout.Dimensions {
panel := card
if u.usesCompactViewport() {
panel = compactCard
}
return panel(gtx, func(gtx layout.Context) layout.Dimensions {
rows := []layout.Widget{
u.lifecycleBranding,
layout.Spacer{Height: unit.Dp(8)}.Layout,
u.lifecycleControls,
}
return material.List(u.theme, &u.lifecycleList).Layout(gtx, len(rows), func(gtx layout.Context, i int) layout.Dimensions {
return rows[i](gtx)
})
})
}
func (u *ui) lifecycleControls(gtx layout.Context) layout.Dimensions {
busy := u.lifecycleBusy()
showLocalChooser := u.showLocalVaultChooser()
+12
View File
@@ -0,0 +1,12 @@
package layout
type TopSection string
const (
TopSearch TopSection = "search"
TopNavigation TopSection = "navigation"
TopPath TopSection = "path"
TopGroup TopSection = "group"
TopGroupTools TopSection = "group_tools"
TopPrimary TopSection = "primary"
)
+8
View File
@@ -0,0 +1,8 @@
package list
type EntriesSectionState struct {
Path []string
SearchQuery string
SelectedEntryID string
Editing bool
}
+391 -19
View File
@@ -25,7 +25,8 @@ import (
"git.julianfamily.org/keepassgo/internal/apiaudit"
"git.julianfamily.org/keepassgo/internal/apitokens"
"git.julianfamily.org/keepassgo/internal/appstate"
appuilayout "git.julianfamily.org/keepassgo/internal/appui/layout"
headerlayout "git.julianfamily.org/keepassgo/internal/appui/header/layout"
listlayout "git.julianfamily.org/keepassgo/internal/appui/list/layout"
"git.julianfamily.org/keepassgo/internal/clipboard"
"git.julianfamily.org/keepassgo/internal/passwords"
"git.julianfamily.org/keepassgo/internal/session"
@@ -260,13 +261,13 @@ func TestUIListPanelTopSectionsMatchAcrossDesktopAndPhoneForEntries(t *testing.T
phone := newUIWithModel("phone", vault.Model{})
phone.state.Section = appstate.SectionEntries
want := []listPanelTopSection{
listPanelTopSearch,
listPanelTopNavigation,
listPanelTopPath,
listPanelTopGroup,
listPanelTopGroupTools,
listPanelTopPrimary,
want := []listlayout.TopSection{
listlayout.TopSearch,
listlayout.TopNavigation,
listlayout.TopPath,
listlayout.TopGroup,
listlayout.TopGroupTools,
listlayout.TopPrimary,
}
if got := desktop.listPanelTopSections(); !slices.Equal(got, want) {
t.Fatalf("desktop.listPanelTopSections() = %v, want %v", got, want)
@@ -373,10 +374,10 @@ func TestUIHeaderMenusUseOverlayModelAcrossModes(t *testing.T) {
func TestAnchoredMenuXAllowsWiderMenusToExtendLeft(t *testing.T) {
t.Parallel()
if got := appuilayout.AnchoredMenuX(48, 160); got != -112 {
if got := headerlayout.AnchoredMenuX(48, 160); got != -112 {
t.Fatalf("anchoredMenuX(48, 160) = %d, want -112", got)
}
if got := appuilayout.AnchoredMenuX(160, 48); got != 112 {
if got := headerlayout.AnchoredMenuX(160, 48); got != 112 {
t.Fatalf("anchoredMenuX(160, 48) = %d, want 112", got)
}
}
@@ -384,10 +385,10 @@ func TestAnchoredMenuXAllowsWiderMenusToExtendLeft(t *testing.T) {
func TestAnchoredMenuOriginXClampsToVisibleContainer(t *testing.T) {
t.Parallel()
if got := appuilayout.AnchoredMenuOriginX(360, 312, 360, 140); got != 220 {
if got := headerlayout.AnchoredMenuOriginX(360, 312, 360, 140); got != 220 {
t.Fatalf("anchoredMenuOriginX should keep a right-aligned menu visible, got %d want 220", got)
}
if got := appuilayout.AnchoredMenuOriginX(360, 0, 44, 160); got != 0 {
if got := headerlayout.AnchoredMenuOriginX(360, 0, 44, 160); got != 0 {
t.Fatalf("anchoredMenuOriginX should clamp oversized left overflow to zero, got %d want 0", got)
}
}
@@ -395,7 +396,7 @@ func TestAnchoredMenuOriginXClampsToVisibleContainer(t *testing.T) {
func TestHeaderActionMetricsComputeTriggerAnchors(t *testing.T) {
t.Parallel()
metrics := appuilayout.HeaderActionMetrics{
metrics := headerlayout.ActionMetrics{
RowOriginX: 24,
Spacing: 8,
RowDims: layout.Dimensions{Size: image.Pt(180, 40)},
@@ -404,10 +405,10 @@ func TestHeaderActionMetricsComputeTriggerAnchors(t *testing.T) {
MainDims: layout.Dimensions{Size: image.Pt(36, 40)},
}
if got := metrics.SyncAnchor(); got != (appuilayout.DropdownAnchor{TriggerRightX: 76, TriggerBottomY: 40}) {
if got := metrics.SyncAnchor(); got != (headerlayout.DropdownAnchor{TriggerRightX: 76, TriggerBottomY: 40}) {
t.Fatalf("metrics.syncAnchor() = %+v, want right=76 bottom=40", got)
}
if got := metrics.MainAnchor(); got != (appuilayout.DropdownAnchor{TriggerRightX: 172, TriggerBottomY: 40}) {
if got := metrics.MainAnchor(); got != (headerlayout.DropdownAnchor{TriggerRightX: 172, TriggerBottomY: 40}) {
t.Fatalf("metrics.mainAnchor() = %+v, want right=172 bottom=40", got)
}
}
@@ -415,14 +416,14 @@ func TestHeaderActionMetricsComputeTriggerAnchors(t *testing.T) {
func TestDropdownSurfaceOriginKeepsMenusWithinVisibleArea(t *testing.T) {
t.Parallel()
surface := appuilayout.DropdownSurface{ContainerWidth: 320, LeftInset: 16, TopInset: 16}
anchor := appuilayout.DropdownAnchor{TriggerRightX: 300, TriggerBottomY: 42}
surface := headerlayout.DropdownSurface{ContainerWidth: 320, LeftInset: 16, TopInset: 16}
anchor := headerlayout.DropdownAnchor{TriggerRightX: 300, TriggerBottomY: 42}
if got := surface.Origin(anchor, 140); got != image.Pt(176, 58) {
t.Fatalf("surface.origin(anchor, 140) = %v, want (176,58)", got)
}
leftAnchor := appuilayout.DropdownAnchor{TriggerRightX: 36, TriggerBottomY: 42}
leftAnchor := headerlayout.DropdownAnchor{TriggerRightX: 36, TriggerBottomY: 42}
if got := surface.Origin(leftAnchor, 120); got != image.Pt(16, 58) {
t.Fatalf("surface.origin(leftAnchor, 120) = %v, want (16,58)", got)
}
@@ -753,6 +754,23 @@ func TestUIAPITokenLifecycleManagement(t *testing.T) {
t.Fatal("apiTokenSecret after rotate = empty, want one-time secret")
}
u.apiTokenName.SetText("Browser Extension Updated")
u.apiTokenClientName.SetText("firefox-desktop")
u.apiTokenExpiresAt.SetText("2026-05-01T00:00:00Z")
if err := u.saveAPITokenAction(); err != nil {
t.Fatalf("saveAPITokenAction() error = %v", err)
}
updated, ok := u.selectedAPIToken()
if !ok {
t.Fatal("selectedAPIToken() ok = false, want true after save")
}
if updated.Name != "Browser Extension Updated" || updated.ClientName != "firefox-desktop" {
t.Fatalf("updated token = %#v, want renamed/firefox-desktop", updated)
}
if updated.ExpiresAt == nil || updated.ExpiresAt.UTC().Format(time.RFC3339) != "2026-05-01T00:00:00Z" {
t.Fatalf("updated.ExpiresAt = %#v, want 2026-05-01T00:00:00Z", updated.ExpiresAt)
}
if err := u.disableAPITokenAction(); err != nil {
t.Fatalf("disableAPITokenAction() error = %v", err)
}
@@ -760,9 +778,17 @@ func TestUIAPITokenLifecycleManagement(t *testing.T) {
if !ok || !disabled.Disabled {
t.Fatalf("selectedAPIToken() = %#v, want disabled token", disabled)
}
if err := u.revokeAPITokenAction(); err != nil {
t.Fatalf("revokeAPITokenAction() error = %v", err)
}
revoked, ok := u.selectedAPIToken()
if !ok || revoked.RevokedAt == nil {
t.Fatalf("selectedAPIToken() = %#v, want revoked token", revoked)
}
}
func TestUIAPITokenPolicyRulesCanBeAddedAndRemoved(t *testing.T) {
func TestUIAPITokenPolicyRulesCanBeCreatedUpdatedAndRemoved(t *testing.T) {
t.Parallel()
u := newUIWithSession("desktop", &session.Manager{}, statePaths{
@@ -798,6 +824,33 @@ func TestUIAPITokenPolicyRulesCanBeAddedAndRemoved(t *testing.T) {
if token.Policies[0].Resource.Kind != apitokens.ResourceGroup {
t.Fatalf("rule kind = %q, want group", token.Policies[0].Resource.Kind)
}
if len(u.apiPolicyEdits) != 1 {
t.Fatalf("len(apiPolicyEdits) = %d, want 1", len(u.apiPolicyEdits))
}
u.apiPolicyEdits[0].Click()
u.handleAPIPolicyClicks(layout.Context{})
if u.selectedAPIPolicyIndex != 0 {
t.Fatalf("selectedAPIPolicyIndex = %d, want 0 after edit click", u.selectedAPIPolicyIndex)
}
if got := u.apiPolicyPath.Text(); got != "Root / Internet" {
t.Fatalf("apiPolicyPath = %q, want Root / Internet after edit load", got)
}
u.apiPolicyPath.SetText("Root / Security")
u.saveAPIPolicyRule.Click()
u.handleAPIPolicyClicks(layout.Context{})
token, ok = u.selectedAPIToken()
if !ok || len(token.Policies) != 1 {
t.Fatalf("selectedAPIToken().Policies after save = %#v, want 1 rule", token.Policies)
}
if got := strings.Join(token.Policies[0].Resource.Path, " / "); got != "Root / Security" {
t.Fatalf("updated policy path = %q, want Root / Security", got)
}
if u.selectedAPIPolicyIndex != -1 {
t.Fatalf("selectedAPIPolicyIndex after save = %d, want -1", u.selectedAPIPolicyIndex)
}
if err := u.removeAPIPolicyRuleAction(0); err != nil {
t.Fatalf("removeAPIPolicyRuleAction() error = %v", err)
@@ -808,6 +861,72 @@ func TestUIAPITokenPolicyRulesCanBeAddedAndRemoved(t *testing.T) {
}
}
func TestUIAPITokenPolicyButtonsRemainClickableAfterPanelLayout(t *testing.T) {
t.Parallel()
u := newUIWithSession("desktop", &session.Manager{}, statePaths{
DefaultSaveAsPath: filepath.Join(t.TempDir(), "vault.kdbx"),
RecentVaultsPath: filepath.Join(t.TempDir(), "recent-vaults.json"),
RecentRemotesPath: filepath.Join(t.TempDir(), "recent-remotes.json"),
UIPreferencesPath: filepath.Join(t.TempDir(), "ui-prefs.json"),
})
u.masterPassword.SetText("correct horse battery staple")
if err := u.createVaultAction(); err != nil {
t.Fatalf("createVaultAction() error = %v", err)
}
u.showAPITokensSection()
u.apiTokenName.SetText("CLI")
u.apiTokenClientName.SetText("grpc-cli")
if err := u.issueAPITokenAction(); err != nil {
t.Fatalf("issueAPITokenAction() error = %v", err)
}
u.apiPolicyOperation.SetText(string(apitokens.OperationListEntries))
u.apiPolicyPath.SetText("Root / Internet")
u.apiPolicyAllow.Value = true
u.apiPolicyGroupScopeW.Value = true
if err := u.addAPIPolicyRuleAction(); err != nil {
t.Fatalf("addAPIPolicyRuleAction() error = %v", err)
}
token, ok := u.selectedAPIToken()
if !ok || len(token.Policies) != 1 {
t.Fatalf("selectedAPIToken().Policies before layout = %#v, want 1 rule", token.Policies)
}
if len(u.apiPolicyEdits) != 1 {
t.Fatalf("len(apiPolicyEdits) before layout = %d, want 1", len(u.apiPolicyEdits))
}
gtx := layout.Context{
Ops: new(op.Ops),
Constraints: layout.Exact(image.Pt(800, 600)),
}
_ = u.apiTokenDetailPanel(gtx)
if len(u.apiPolicyEdits) != 1 {
t.Fatalf("len(apiPolicyEdits) after layout = %d, want 1", len(u.apiPolicyEdits))
}
u.apiPolicyEdits[0].Click()
u.handleAPIPolicyClicks(layout.Context{})
if u.selectedAPIPolicyIndex != 0 {
t.Fatalf("selectedAPIPolicyIndex after rendered edit click = %d, want 0", u.selectedAPIPolicyIndex)
}
u.selectedAPIPolicyIndex = -1
u.loadSelectedAPITokenIntoEditor()
_ = u.apiTokenDetailPanel(gtx)
if len(u.apiPolicyRemoves) != 1 {
t.Fatalf("len(apiPolicyRemoves) after layout = %d, want 1", len(u.apiPolicyRemoves))
}
u.apiPolicyRemoves[0].Click()
u.handleAPIPolicyClicks(layout.Context{})
token, ok = u.selectedAPIToken()
if !ok || len(token.Policies) != 0 {
t.Fatalf("selectedAPIToken().Policies after rendered remove click = %#v, want empty", token.Policies)
}
}
func TestAPITokenStatusSummary(t *testing.T) {
t.Parallel()
@@ -4857,6 +4976,112 @@ func TestUIResolvePendingApprovalDelegatesToApprovalManager(t *testing.T) {
}
}
func TestUIApprovalDialogVisibleForPendingRequest(t *testing.T) {
t.Parallel()
u := newUIWithModel("desktop", vault.Model{})
u.state.Approvals = &mainStubApprovalManager{
pending: []apiapproval.Request{{
ID: "approval-1",
TokenName: "CLI",
ClientName: "grpc-cli",
Operation: apitokens.OperationReadEntry,
Resource: apitokens.Resource{
Kind: apitokens.ResourceEntry,
EntryID: "vault-console",
},
}},
}
dims := u.approvalDialogContent(layout.Context{
Ops: new(op.Ops),
Constraints: layout.Exact(image.Pt(800, 600)),
})
if dims.Size.X == 0 || dims.Size.Y == 0 {
t.Fatalf("approvalDialogContent() = %v, want visible dimensions for pending approval", dims.Size)
}
}
func TestUIApprovalButtonsResolveAllOutcomes(t *testing.T) {
t.Parallel()
tests := []struct {
name string
click func(*ui)
want apiapproval.Outcome
wantMsg string
}{
{
name: "allow once",
click: func(u *ui) {
u.allowApprovalOnce.Click()
},
want: apiapproval.OutcomeAllowOnce,
wantMsg: "allow API request complete",
},
{
name: "allow permanently",
click: func(u *ui) {
u.allowApprovalPermanent.Click()
},
want: apiapproval.OutcomeAllowPermanent,
wantMsg: "allow API request permanently complete",
},
{
name: "deny once",
click: func(u *ui) {
u.denyApprovalOnce.Click()
},
want: apiapproval.OutcomeDenyOnce,
wantMsg: "deny API request complete",
},
{
name: "deny permanently",
click: func(u *ui) {
u.denyApprovalPermanent.Click()
},
want: apiapproval.OutcomeDenyPermanent,
wantMsg: "deny API request permanently complete",
},
{
name: "cancel",
click: func(u *ui) {
u.cancelApproval.Click()
},
want: apiapproval.OutcomeCancel,
wantMsg: "cancel API request complete",
},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
manager := &mainStubApprovalManager{
pending: []apiapproval.Request{{
ID: "approval-1",
TokenName: "CLI",
ClientName: "grpc-cli",
Operation: apitokens.OperationReadEntry,
}},
}
u := newUIWithModel("desktop", vault.Model{})
u.state.Approvals = manager
tt.click(u)
u.handleApprovalClicks(layout.Context{})
if manager.lastID != "approval-1" || manager.lastOutcome != tt.want {
t.Fatalf("handleApprovalClicks() delegated (%q, %q), want (approval-1, %q)", manager.lastID, manager.lastOutcome, tt.want)
}
if got := u.state.StatusMessage; got != tt.wantMsg {
t.Fatalf("state.StatusMessage = %q, want %q", got, tt.wantMsg)
}
})
}
}
func TestUIRequiresExplicitEditModeForEntryEditor(t *testing.T) {
t.Parallel()
@@ -4911,6 +5136,33 @@ func TestUIAutoEntersSingleVaultRootGroupAndDisplaysSlashRoot(t *testing.T) {
}
}
func TestUIAutoEntersSingleVaultRootWhenRecycleBinAlsoExists(t *testing.T) {
t.Parallel()
u := newUIWithModel("desktop", vault.Model{
Entries: []vault.Entry{
{ID: "vault-console", Title: "Vault Console", Path: []string{"keepass", "Crew", "Internet"}},
},
Groups: [][]string{
{"keepass"},
{"keepass", "Crew"},
{"Recycle Bin"},
},
})
u.showEntriesSection()
if got := u.currentPath; !slices.Equal(got, []string{"keepass"}) {
t.Fatalf("currentPath = %v, want [keepass]", got)
}
if got := u.displayPath(); len(got) != 0 {
t.Fatalf("displayPath() = %v, want root slash path", got)
}
if got := u.childGroups(); !slices.Equal(got, []string{"Crew"}) {
t.Fatalf("childGroups() = %v, want [Crew]", got)
}
}
func TestUIShowEntriesSectionRestoresHiddenRootAfterLeavingEntries(t *testing.T) {
t.Parallel()
@@ -4940,6 +5192,37 @@ func TestUIShowEntriesSectionRestoresHiddenRootAfterLeavingEntries(t *testing.T)
}
}
func TestUISyncCurrentPathNormalizesHiddenRootAfterSectionSwitch(t *testing.T) {
t.Parallel()
u := newUIWithModel("desktop", vault.Model{
Entries: []vault.Entry{
{ID: "1", Title: "Vault Console", Path: []string{"keepass", "Crew", "Internet"}},
},
Groups: [][]string{
{"keepass"},
{"keepass", "Crew"},
{"Recycle Bin"},
},
})
u.showEntriesSection()
u.showAPITokensSection()
u.state.Section = appstate.SectionEntries
u.state.CurrentPath = []string{"Root"}
u.currentPath = nil
u.syncedPath = nil
u.syncCurrentPath()
if got := u.currentPath; !slices.Equal(got, []string{"keepass"}) {
t.Fatalf("currentPath after syncCurrentPath() = %v, want [keepass]", got)
}
if got := u.displayPath(); len(got) != 0 {
t.Fatalf("displayPath() after syncCurrentPath() = %v, want root slash path", got)
}
}
func TestUIShowEntriesSectionRestoresEntriesViewState(t *testing.T) {
t.Parallel()
@@ -5459,6 +5742,55 @@ func TestUISyncDefaultsPersistInSettings(t *testing.T) {
}
}
func TestUIRemoteAutosavePersistsInSettings(t *testing.T) {
t.Parallel()
configPath := filepath.Join(t.TempDir(), "settings.json")
first := newUIWithSession("desktop", &session.Manager{}, statePaths{
SettingsPath: configPath,
})
first.autoSaveRemote = true
first.state.AutoSaveRemote = true
first.saveSettings()
second := newUIWithSession("desktop", &session.Manager{}, statePaths{
SettingsPath: configPath,
})
second.autoSaveRemote = false
second.state.AutoSaveRemote = false
second.loadSettings()
if !second.autoSaveRemote {
t.Fatal("autoSaveRemote = false, want true after reload")
}
if !second.state.AutoSaveRemote {
t.Fatal("state.AutoSaveRemote = false, want true after reload")
}
}
func TestUIDebugHeaderBoundsPersistInSettings(t *testing.T) {
t.Parallel()
configPath := filepath.Join(t.TempDir(), "settings.json")
first := newUIWithSession("phone", &session.Manager{}, statePaths{
SettingsPath: configPath,
})
first.debugLogHeaderBounds = true
first.saveSettings()
second := newUIWithSession("phone", &session.Manager{}, statePaths{
SettingsPath: configPath,
})
second.debugLogHeaderBounds = false
second.loadSettings()
if !second.debugLogHeaderBounds {
t.Fatal("debugLogHeaderBounds = false, want true after reload")
}
}
func TestUILoadSettingsFallsBackToLegacySyncDefaultsInUIPreferences(t *testing.T) {
t.Parallel()
@@ -5533,6 +5865,7 @@ func TestUISaveSecuritySettingsPersistsSyncDefaults(t *testing.T) {
u.loadSettingsDraft()
u.settingsDraft.Sync.SourceDefault = syncSourceRemote
u.settingsDraft.Sync.DirectionDefault = syncDirectionPush
u.settingsAutoSaveRemote.Value = true
if err := u.saveSecuritySettingsAction(); err != nil {
t.Fatalf("saveSecuritySettingsAction() error = %v", err)
@@ -5549,6 +5882,45 @@ func TestUISaveSecuritySettingsPersistsSyncDefaults(t *testing.T) {
if got := reloaded.syncDefaultDirection; got != syncDirectionPush {
t.Fatalf("reloaded syncDefaultDirection = %q, want push", got)
}
if !reloaded.autoSaveRemote {
t.Fatal("reloaded autoSaveRemote = false, want true")
}
if !reloaded.state.AutoSaveRemote {
t.Fatal("reloaded state.AutoSaveRemote = false, want true")
}
}
func TestUISaveSecuritySettingsPersistsDebugHeaderBounds(t *testing.T) {
t.Parallel()
manager := &session.Manager{}
dir := t.TempDir()
u := newUIWithSession("phone", manager, statePaths{
DefaultSaveAsPath: filepath.Join(dir, "vault.kdbx"),
SettingsPath: filepath.Join(dir, "settings.json"),
UIPreferencesPath: filepath.Join(dir, "ui-prefs.json"),
})
u.masterPassword.SetText("correct horse battery staple")
if err := u.createVaultAction(); err != nil {
t.Fatalf("createVaultAction() error = %v", err)
}
u.securityCipher.SetText(vault.CipherAES256)
u.securityKDF.SetText(vault.KDFAES)
u.loadSettingsDraft()
u.settingsDebugHeaderBounds.Value = true
if err := u.saveSecuritySettingsAction(); err != nil {
t.Fatalf("saveSecuritySettingsAction() error = %v", err)
}
reloaded := newUIWithSession("phone", &session.Manager{}, statePaths{
SettingsPath: u.settingsPath,
})
reloaded.loadSettings()
if !reloaded.debugLogHeaderBounds {
t.Fatal("reloaded debugLogHeaderBounds = false, want true")
}
}
func TestUIAccessibilityPreferencesPersist(t *testing.T) {
@@ -0,0 +1,22 @@
//go:build android
package platform
/*
#cgo CFLAGS: -Werror
#cgo LDFLAGS: -landroid
#include <android/log.h>
#include <stdlib.h>
*/
import "C"
import "unsafe"
func LogInfo(tag, msg string) {
ctag := C.CString(tag)
defer C.free(unsafe.Pointer(ctag))
cmsg := C.CString(msg)
defer C.free(unsafe.Pointer(cmsg))
C.__android_log_write(C.ANDROID_LOG_INFO, ctag, cmsg)
}
@@ -2,6 +2,12 @@
package platform
import "log"
func NewVaultSharer(goos string) VaultSharer {
return nil
}
func LogInfo(tag, msg string) {
log.Printf("%s: %s", tag, msg)
}
File diff suppressed because it is too large Load Diff
+210
View File
@@ -0,0 +1,210 @@
package appui
import (
"flag"
"fmt"
"os"
"os/exec"
"runtime"
"strings"
"gioui.org/app"
"gioui.org/op"
"gioui.org/unit"
"gioui.org/x/explorer"
"git.julianfamily.org/keepassgo/internal/api"
"git.julianfamily.org/keepassgo/internal/apiapproval"
"git.julianfamily.org/keepassgo/internal/apitokens"
"git.julianfamily.org/keepassgo/internal/appui/platform"
"git.julianfamily.org/keepassgo/internal/browserbridge"
"git.julianfamily.org/keepassgo/internal/grpcaddr"
"git.julianfamily.org/keepassgo/internal/passwords"
"git.julianfamily.org/keepassgo/internal/session"
"git.julianfamily.org/keepassgo/internal/vault"
)
func Main() {
mode := flag.String("mode", "", "window mode: desktop or phone")
stateDir := flag.String("state-dir", "", "directory for KeePassGO state such as recent-vault history and default save targets")
grpcAddr := flag.String("grpc-addr", "", "address for the local gRPC API listener; use 'off' to disable")
flag.Parse()
resolvedMode := resolveFlagOrEnv(*mode, "KEEPASSGO_MODE", defaultModeForRuntime(runtime.GOOS))
resolvedStateDir := resolveFlagOrEnv(*stateDir, "KEEPASSGO_STATE_DIR", "")
resolvedGRPCAddr := resolveFlagOrEnv(*grpcAddr, "KEEPASSGO_GRPC_ADDR", defaultGRPCAddr(runtime.GOOS))
width := unit.Dp(1180)
height := unit.Dp(760)
if strings.EqualFold(resolvedMode, "phone") {
width = unit.Dp(412)
height = unit.Dp(915)
}
go func() {
w := new(app.Window)
options := []app.Option{app.Title(productName)}
if shouldUsePreviewWindowSize(resolvedMode, runtime.GOOS) {
options = append(options, app.Size(width, height))
}
w.Option(options...)
if err := run(w, strings.ToLower(resolvedMode), defaultStatePaths(resolvedStateDir), resolvedGRPCAddr); err != nil {
panic(err)
}
if !strings.EqualFold(runtime.GOOS, "android") {
os.Exit(0)
}
}()
app.Main()
}
func defaultGRPCAddr(goos string) string {
return grpcaddr.Default(goos)
}
func run(w *app.Window, mode string, paths statePaths, grpcAddr string) error {
ensureBrowserNativeHosts()
var ops op.Ops
manager := &session.Manager{}
ui := newUIWithSession(mode, manager, paths)
ui.fileExplorer = explorer.NewExplorer(w)
ui.invalidate = w.Invalidate
ui.clipboardWriter = platform.NewClipboardWriter(runtime.GOOS, w.Invalidate)
host, err := api.StartHost(grpcAddr, manager, passwords.DefaultProfiles(), ui.clipboardWriter, func() bool { return ui.state.Dirty })
if err != nil {
ui.state.ErrorMessage = fmt.Sprintf("start gRPC API: %v", err)
} else if host != nil {
ui.apiHost = host
ui.auditLog = host.Server().AuditLog()
ui.state.AuditLog = ui.auditLog
ui.grpcAddress = host.Address()
ui.state.Approvals = &uiApprovalManager{server: host.Server()}
host.Server().SetChangeNotifier(func() {
ui.state.Dirty = true
ui.invalidate()
})
host.Server().ApprovalBroker().SetChangeNotifier(ui.invalidate)
defer func() { _ = host.Stop() }()
}
for {
e := w.Event()
ui.fileExplorer.ListenEvents(e)
switch e := e.(type) {
case app.DestroyEvent:
return e.Err
case app.FrameEvent:
gtx := app.NewContext(&ops, e)
ui.processBackgroundActions()
ui.layout(gtx)
platform.ProcessClipboardWrites(gtx, ui.clipboardWriter)
e.Frame(gtx.Ops)
}
}
}
func ensureBrowserNativeHosts() {
if runtime.GOOS != "linux" {
return
}
appBinaryPath, err := os.Executable()
if err != nil {
return
}
if err := browserbridge.EnsureNativeHostManifests(appBinaryPath); err != nil {
platform.LogInfo("KeePassGO", fmt.Sprintf("keepassgo browser native host registration failed: %v", err))
}
}
type uiApprovalManager struct {
server *api.Server
}
func (m *uiApprovalManager) Pending() []apiapproval.Request {
if m == nil || m.server == nil {
return nil
}
return m.server.ApprovalBroker().Pending()
}
func (m *uiApprovalManager) Resolve(id string, outcome apiapproval.Outcome) (apiapproval.Request, *apitokens.PolicyRule, error) {
if m == nil || m.server == nil {
return apiapproval.Request{}, nil, fmt.Errorf("approval manager is not configured")
}
return m.server.ResolveApproval(id, outcome)
}
type uiSession struct {
model vault.Model
locked bool
}
func (s *uiSession) HasVault() bool {
return len(s.model.Entries) > 0 || len(s.model.Templates) > 0 || len(s.model.RecycleBin) > 0 || len(s.model.Groups) > 0 || s.locked
}
func (s *uiSession) IsLocked() bool {
return s.locked
}
func (s *uiSession) IsRemote() bool {
return false
}
func (s *uiSession) Current() (vault.Model, error) {
if s.locked {
return vault.Model{}, session.ErrLocked
}
return s.model, nil
}
func (s *uiSession) Replace(model vault.Model) {
s.model = model
}
func (s *uiSession) Lock() error {
s.locked = true
return nil
}
func (s *uiSession) Unlock(vault.MasterKey) error {
if !s.locked {
return nil
}
s.locked = false
return nil
}
func pickExistingFile() (string, error) {
if path, err := runFilePicker("kdialog", "--getopenfilename", "--title", "Choose KeePass file"); err == nil {
return path, nil
}
if path, err := runFilePicker("zenity", "--file-selection", "--title=Choose KeePass file"); err == nil {
return path, nil
}
return "", fmt.Errorf("no supported file picker found; install kdialog or zenity")
}
func runFilePicker(name string, args ...string) (string, error) {
if _, err := exec.LookPath(name); err != nil {
return "", err
}
cmd := exec.Command(name, args...)
output, err := cmd.Output()
if err != nil {
return "", err
}
return parsePickedFilePath(output)
}
func parsePickedFilePath(output []byte) (string, error) {
lines := strings.Split(strings.ReplaceAll(string(output), "\r\n", "\n"), "\n")
for i := len(lines) - 1; i >= 0; i-- {
line := strings.TrimSpace(lines[i])
if line == "" {
continue
}
if strings.HasPrefix(line, "/") || strings.HasPrefix(line, "~/") {
return line, nil
}
}
return "", fmt.Errorf("file picker did not return a path")
}
@@ -2,6 +2,8 @@ package appui
import (
"encoding/json"
"fmt"
"image"
"image/color"
"os"
"path/filepath"
@@ -9,47 +11,56 @@ import (
"time"
"gioui.org/layout"
"gioui.org/op/clip"
"gioui.org/op/paint"
"gioui.org/unit"
"gioui.org/widget"
"gioui.org/widget/material"
editormodel "git.julianfamily.org/keepassgo/internal/appui/editor"
headerlayout "git.julianfamily.org/keepassgo/internal/appui/header/layout"
"git.julianfamily.org/keepassgo/internal/appui/platform"
settingsmodel "git.julianfamily.org/keepassgo/internal/appui/settings"
"git.julianfamily.org/keepassgo/internal/vault"
)
const (
displayDensityDense = "dense"
displayDensityComfortable = "comfortable"
displayDensityDense = settingsmodel.DisplayDensityDense
displayDensityComfortable = settingsmodel.DisplayDensityComfortable
contrastStandard = "standard"
contrastHigh = "high"
contrastStandard = settingsmodel.ContrastStandard
contrastHigh = settingsmodel.ContrastHigh
keyboardFocusStandard = "standard"
keyboardFocusProminent = "prominent"
keyboardFocusStandard = settingsmodel.KeyboardFocusStandard
keyboardFocusProminent = settingsmodel.KeyboardFocusProminent
)
type accessibilityPreferences struct {
DisplayDensity string
Contrast string
ReducedMotion bool
KeyboardFocus string
}
type accessibilityPreferences = settingsmodel.AccessibilityPreferences
type settingsFile struct {
Sync syncSettings `json:"sync,omitempty"`
Sync syncSettings `json:"sync,omitempty"`
Debug debugSettings `json:"debug,omitempty"`
}
type syncSettings struct {
SourceDefault string `json:"sourceDefault,omitempty"`
DirectionDefault string `json:"directionDefault,omitempty"`
AutoSaveRemote bool `json:"autoSaveRemote,omitempty"`
}
type debugSettings struct {
LogHeaderBounds bool `json:"logHeaderBounds,omitempty"`
}
type syncSettingsDraft struct {
SourceDefault syncSourceMode
DirectionDefault syncDirection
AutoSaveRemote bool
}
type settingsDraft struct {
Accessibility accessibilityPreferences
Sync syncSettingsDraft
Debug debugSettings
}
type legacySyncPreferences struct {
@@ -63,37 +74,23 @@ type choiceSpec struct {
Active bool
}
type focusAppearance struct {
BorderColor color.NRGBA
OutlineColor color.NRGBA
OutlineWidth int
MinHeight int
}
func defaultAccessibilityPreferences() accessibilityPreferences {
return accessibilityPreferences{
DisplayDensity: displayDensityForDenseLayout(true),
Contrast: contrastStandard,
KeyboardFocus: keyboardFocusStandard,
}
return settingsmodel.DefaultAccessibilityPreferences()
}
func displayDensityForDenseLayout(dense bool) string {
if dense {
return displayDensityDense
}
return displayDensityComfortable
return settingsmodel.DisplayDensityForDenseLayout(dense)
}
func normalizeAccessibilityPreferences(prefs accessibilityPreferences) accessibilityPreferences {
normalized := defaultAccessibilityPreferences()
switch prefs.DisplayDensity {
case displayDensityDense, displayDensityComfortable:
normalized.DisplayDensity = prefs.DisplayDensity
}
switch prefs.Contrast {
case contrastStandard, contrastHigh:
normalized.Contrast = prefs.Contrast
}
switch prefs.KeyboardFocus {
case keyboardFocusStandard, keyboardFocusProminent:
normalized.KeyboardFocus = prefs.KeyboardFocus
}
normalized.ReducedMotion = prefs.ReducedMotion
return normalized
return settingsmodel.NormalizeAccessibilityPreferences(prefs)
}
func (u *ui) applyAccessibilityPreferences(prefs accessibilityPreferences) {
@@ -102,6 +99,96 @@ func (u *ui) applyAccessibilityPreferences(prefs accessibilityPreferences) {
u.accessibilityPrefs = normalized
}
func fieldFocusAppearance(metric unit.Metric, prefs accessibilityPreferences, focused bool) focusAppearance {
prefs = normalizeAccessibilityPreferences(prefs)
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 prefs.DisplayDensity == displayDensityComfortable {
appearance.MinHeight = metric.Dp(unit.Dp(52))
}
if prefs.Contrast == contrastHigh {
appearance.BorderColor = color.NRGBA{R: 108, G: 101, B: 90, A: 255}
}
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)))
if prefs.Contrast == contrastHigh {
appearance.BorderColor = color.NRGBA{R: 16, G: 60, B: 44, A: 255}
appearance.OutlineColor = color.NRGBA{R: 20, G: 74, B: 55, A: 124}
}
if prefs.KeyboardFocus == keyboardFocusProminent {
appearance.OutlineWidth = max(3, metric.Dp(unit.Dp(3)))
appearance.OutlineColor = color.NRGBA{R: 20, G: 74, B: 55, A: 148}
}
}
return appearance
}
func buttonFocusColors(prefs accessibilityPreferences, focused bool) (background color.NRGBA, text color.NRGBA) {
prefs = normalizeAccessibilityPreferences(prefs)
background = color.NRGBA{R: 231, G: 239, B: 235, A: 255}
text = accentColor
if prefs.Contrast == contrastHigh {
background = color.NRGBA{R: 225, G: 235, B: 230, A: 255}
text = color.NRGBA{R: 19, G: 57, B: 43, A: 255}
}
if focused {
background = color.NRGBA{R: 214, G: 229, B: 221, A: 255}
if prefs.Contrast == contrastHigh || prefs.KeyboardFocus == keyboardFocusProminent {
background = color.NRGBA{R: 202, G: 222, B: 212, 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 {
return editormodel.Label(field)
}
func (u *ui) loadSettingsDraft() {
u.settingsDraft = settingsDraft{
Accessibility: accessibilityPreferences{
@@ -113,8 +200,14 @@ func (u *ui) loadSettingsDraft() {
Sync: syncSettingsDraft{
SourceDefault: u.syncDefaultSourceMode,
DirectionDefault: u.syncDefaultDirection,
AutoSaveRemote: u.autoSaveRemote,
},
Debug: debugSettings{
LogHeaderBounds: u.debugLogHeaderBounds,
},
}
u.settingsDebugHeaderBounds.Value = u.settingsDraft.Debug.LogHeaderBounds
u.settingsAutoSaveRemote.Value = u.settingsDraft.Sync.AutoSaveRemote
}
func (u *ui) saveSecuritySettingsAction() error {
@@ -136,9 +229,17 @@ func (u *ui) applySecuritySettingsLive() error {
if u.settingsDraft.Accessibility.DisplayDensity == displayDensityForDenseLayout(u.denseLayout) {
u.settingsDraft.Accessibility.DisplayDensity = displayDensityForDenseLayout(u.settingsDenseLayout.Value)
}
u.settingsDraft.Debug.LogHeaderBounds = u.settingsDebugHeaderBounds.Value
u.settingsDraft.Sync.AutoSaveRemote = u.settingsAutoSaveRemote.Value
u.settingsDenseLayout.Value = u.settingsDraft.Accessibility.DisplayDensity == displayDensityDense
u.syncDefaultSourceMode = sanitizeSyncSourceMode(u.settingsDraft.Sync.SourceDefault)
u.syncDefaultDirection = sanitizeSyncDirection(u.settingsDraft.Sync.DirectionDefault)
u.autoSaveRemote = u.settingsDraft.Sync.AutoSaveRemote
u.state.AutoSaveRemote = u.autoSaveRemote
u.debugLogHeaderBounds = u.settingsDraft.Debug.LogHeaderBounds
if !u.debugLogHeaderBounds {
u.lastHeaderBoundsLog = ""
}
u.applySettingsFormToPreferences()
u.applyAccessibilityPreferences(u.settingsDraft.Accessibility)
u.saveSettings()
@@ -149,6 +250,7 @@ func (u *ui) applySecuritySettingsLive() error {
func (u *ui) loadSettings() {
u.syncDefaultSourceMode = syncSourceLocal
u.syncDefaultDirection = syncDirectionPull
u.autoSaveRemote = false
if strings.TrimSpace(u.settingsPath) != "" {
content, err := os.ReadFile(u.settingsPath)
@@ -157,12 +259,16 @@ func (u *ui) loadSettings() {
if json.Unmarshal(content, &settings) == nil {
u.syncDefaultSourceMode = sanitizeSyncSourceMode(syncSourceMode(settings.Sync.SourceDefault))
u.syncDefaultDirection = sanitizeSyncDirection(syncDirection(settings.Sync.DirectionDefault))
u.autoSaveRemote = settings.Sync.AutoSaveRemote
u.state.AutoSaveRemote = u.autoSaveRemote
u.debugLogHeaderBounds = settings.Debug.LogHeaderBounds
return
}
}
}
u.loadLegacySyncDefaultsFromUIPreferences()
u.state.AutoSaveRemote = u.autoSaveRemote
}
func (u *ui) loadLegacySyncDefaultsFromUIPreferences() {
@@ -192,6 +298,10 @@ func (u *ui) saveSettings() {
Sync: syncSettings{
SourceDefault: string(u.syncDefaultSourceMode),
DirectionDefault: string(u.syncDefaultDirection),
AutoSaveRemote: u.autoSaveRemote,
},
Debug: debugSettings{
LogHeaderBounds: u.debugLogHeaderBounds,
},
}, "", " ")
if err != nil {
@@ -200,6 +310,44 @@ func (u *ui) saveSettings() {
_ = os.WriteFile(u.settingsPath, content, 0o600)
}
func (u *ui) maybeLogHeaderBounds(bounds headerButtonBounds) {
if !u.debugLogHeaderBounds {
return
}
line := bounds.logLine(u.mode)
if line == u.lastHeaderBoundsLog {
return
}
platform.LogInfo("KeePassGO", line)
u.lastHeaderBoundsLog = line
}
func (u *ui) maybeLogHeaderMenuToggle(menu string, open bool) {
if !u.debugLogHeaderBounds {
return
}
platform.LogInfo("KeePassGO", fmt.Sprintf("keepassgo header-menu-toggle menu=%s open=%t", menu, open))
}
func (u *ui) maybeLogHeaderMenuPlacement(menu string, surface headerlayout.DropdownSurface, placement headerlayout.DropdownPlacement) {
if !u.debugLogHeaderBounds {
return
}
platform.LogInfo("KeePassGO", fmt.Sprintf(
"keepassgo header-menu-placement menu=%s anchor=%d,%d origin=%d,%d size=%dx%d container=%d inset=%d,%d",
menu,
placement.Anchor.TriggerRightX,
placement.Anchor.TriggerBottomY,
placement.Origin.X,
placement.Origin.Y,
placement.Size.X,
placement.Size.Y,
surface.ContainerWidth,
surface.LeftInset,
surface.TopInset,
))
}
func (u *ui) showStatusMessage(message string) {
u.state.StatusMessage = message
if u.accessibilityPrefs.ReducedMotion {
+50
View File
@@ -0,0 +1,50 @@
package settings
type AccessibilityPreferences struct {
DisplayDensity string
Contrast string
ReducedMotion bool
KeyboardFocus string
}
const (
DisplayDensityDense = "dense"
DisplayDensityComfortable = "comfortable"
ContrastStandard = "standard"
ContrastHigh = "high"
KeyboardFocusStandard = "standard"
KeyboardFocusProminent = "prominent"
)
func DefaultAccessibilityPreferences() AccessibilityPreferences {
return AccessibilityPreferences{
DisplayDensity: DisplayDensityDense,
Contrast: ContrastStandard,
KeyboardFocus: KeyboardFocusStandard,
}
}
func DisplayDensityForDenseLayout(dense bool) string {
if dense {
return DisplayDensityDense
}
return DisplayDensityComfortable
}
func NormalizeAccessibilityPreferences(prefs AccessibilityPreferences) AccessibilityPreferences {
normalized := DefaultAccessibilityPreferences()
switch prefs.DisplayDensity {
case DisplayDensityDense, DisplayDensityComfortable:
normalized.DisplayDensity = prefs.DisplayDensity
}
switch prefs.Contrast {
case ContrastStandard, ContrastHigh:
normalized.Contrast = prefs.Contrast
}
switch prefs.KeyboardFocus {
case KeyboardFocusStandard, KeyboardFocusProminent:
normalized.KeyboardFocus = prefs.KeyboardFocus
}
normalized.ReducedMotion = prefs.ReducedMotion
return normalized
}
+119
View File
@@ -0,0 +1,119 @@
package sync
import "git.julianfamily.org/keepassgo/internal/appstate"
type SourceMode string
const (
SourceLocal SourceMode = "local"
SourceRemote SourceMode = "remote"
)
type Direction string
const (
DirectionPull Direction = "pull"
DirectionPush Direction = "push"
)
type DialogPurpose string
const (
DialogPurposeAdvanced DialogPurpose = "advanced"
DialogPurposeRemoteSetup DialogPurpose = "remote-setup"
)
type MenuModel struct {
HasOpenVault bool
HasSelectedBinding bool
ShowSelectors bool
ShowShare bool
ShowSaveCurrentBinding bool
SavedBindingSummary MenuBindingSummary
RemoteBaseURL string
RemotePath string
RemoteUsername string
RemotePassword string
SelectedVaultSyncMode appstate.SyncMode
}
type MenuBindingSummary struct {
ProfileLabel string
CredentialLabel string
SyncLabel string
OK bool
}
func (m MenuModel) SavedBindingHeading() string {
if !m.ShowSelectors {
return "Use this vault's saved remote sync target"
}
return "Use a saved remote profile from this vault"
}
func (m MenuModel) OpenSelectedButtonLabel() string {
if !m.ShowSelectors {
return "Use Remote Sync"
}
return "Open Saved Remote"
}
func (m MenuModel) ShowDirectRemoteSyncShortcut() bool {
return m.HasOpenVault && m.HasSelectedBinding
}
func (m MenuModel) DirectRemoteSyncShortcutLabel() string { return "Use Remote Sync" }
func (m MenuModel) ShowRemoteSyncSettingsShortcut() bool {
return m.HasOpenVault && m.HasSelectedBinding
}
func (m MenuModel) RemoteSyncSettingsShortcutLabel() string { return "Remote Sync Settings" }
func (m MenuModel) ShowRemoveRemoteSyncShortcut() bool { return m.ShowRemoteSyncSettingsShortcut() }
func (m MenuModel) RemoveRemoteSyncShortcutLabel() string { return "Stop Using Remote Sync" }
func (m MenuModel) ShowRemoteSyncSetupShortcut() bool {
return m.HasOpenVault && !m.HasSelectedBinding
}
func (m MenuModel) RemoteSyncSetupShortcutLabel() string { return "Set Up Remote Sync" }
func (m MenuModel) ActionLabels() []string {
labels := []string{"Open Advanced Sync"}
if m.ShowRemoteSyncSetupShortcut() {
labels = append(labels, m.RemoteSyncSetupShortcutLabel())
}
if m.ShowDirectRemoteSyncShortcut() {
labels = append(labels, m.DirectRemoteSyncShortcutLabel())
}
if m.ShowRemoteSyncSettingsShortcut() {
labels = append(labels, m.RemoteSyncSettingsShortcutLabel())
}
if m.ShowRemoveRemoteSyncShortcut() {
labels = append(labels, m.RemoveRemoteSyncShortcutLabel())
}
return labels
}
func (m MenuModel) SaveCurrentRemoteBindingHeading() string {
return "Bind this local vault to the current remote target"
}
func (m MenuModel) SaveCurrentRemoteBindingButtonLabel() string { return "Save Remote In Vault" }
func SummaryText(purpose DialogPurpose, source SourceMode, direction Direction) string {
if purpose == DialogPurposeRemoteSetup {
return "Push this local vault to a WebDAV target and save that target for future sync."
}
sourceLabel := "another local vault file"
if source == SourceRemote {
sourceLabel = "another WebDAV-backed vault"
}
action := "Pull changes from"
if direction == DirectionPush {
action = "Push the current vault into"
}
return action + " " + sourceLabel + "."
}
-135
View File
@@ -1,135 +0,0 @@
package appui
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, prefs accessibilityPreferences, focused bool) focusAppearance {
prefs = normalizeAccessibilityPreferences(prefs)
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 prefs.DisplayDensity == displayDensityComfortable {
appearance.MinHeight = metric.Dp(unit.Dp(52))
}
if prefs.Contrast == contrastHigh {
appearance.BorderColor = color.NRGBA{R: 108, G: 101, B: 90, A: 255}
}
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)))
if prefs.Contrast == contrastHigh {
appearance.BorderColor = color.NRGBA{R: 16, G: 60, B: 44, A: 255}
appearance.OutlineColor = color.NRGBA{R: 20, G: 74, B: 55, A: 124}
}
if prefs.KeyboardFocus == keyboardFocusProminent {
appearance.OutlineWidth = max(3, metric.Dp(unit.Dp(3)))
appearance.OutlineColor = color.NRGBA{R: 20, G: 74, B: 55, A: 148}
}
}
return appearance
}
func buttonFocusColors(prefs accessibilityPreferences, focused bool) (background color.NRGBA, text color.NRGBA) {
prefs = normalizeAccessibilityPreferences(prefs)
background = color.NRGBA{R: 231, G: 239, B: 235, A: 255}
text = accentColor
if prefs.Contrast == contrastHigh {
background = color.NRGBA{R: 225, G: 235, B: 230, A: 255}
text = color.NRGBA{R: 19, G: 57, B: 43, A: 255}
}
if focused {
background = color.NRGBA{R: 214, G: 229, B: 221, A: 255}
if prefs.Contrast == contrastHigh || prefs.KeyboardFocus == keyboardFocusProminent {
background = color.NRGBA{R: 202, G: 222, B: 212, 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), "-", " ")
}
}
-44
View File
@@ -1,44 +0,0 @@
package appui
import (
"image"
"gioui.org/layout"
"gioui.org/op/paint"
"gioui.org/unit"
"gioui.org/widget"
)
func (u *ui) lifecycleBranding(gtx layout.Context) layout.Dimensions {
if !u.usesCompactViewport() {
return layout.Dimensions{}
}
return layout.Dimensions{}
}
func (u *ui) brandMark(gtx layout.Context, widthDP, heightDP float32) layout.Dimensions {
if u.usesCompactViewport() {
return u.brandImage(gtx, u.splashSquare, widthDP, heightDP)
}
return u.brandImage(gtx, u.logoHorizontal, widthDP, heightDP)
}
func (u *ui) brandImage(gtx layout.Context, src paint.ImageOp, widthDP, heightDP float32) layout.Dimensions {
width := gtx.Dp(unit.Dp(widthDP))
height := gtx.Dp(unit.Dp(heightDP))
if width > gtx.Constraints.Max.X {
width = gtx.Constraints.Max.X
}
if height > gtx.Constraints.Max.Y && gtx.Constraints.Max.Y > 0 {
height = gtx.Constraints.Max.Y
}
img := widget.Image{
Src: src,
Fit: widget.Contain,
Position: layout.W,
Scale: 1.0 / gtx.Metric.PxPerDp,
}
gtx.Constraints.Min = image.Point{}
gtx.Constraints.Max = image.Pt(width, height)
return img.Layout(gtx)
}
-546
View File
@@ -1,546 +0,0 @@
package appui
import (
"image"
"image/color"
"gioui.org/layout"
"gioui.org/op"
"gioui.org/unit"
"gioui.org/widget"
"gioui.org/widget/material"
"git.julianfamily.org/keepassgo/internal/appui/actions"
appuilayout "git.julianfamily.org/keepassgo/internal/appui/layout"
"git.julianfamily.org/keepassgo/internal/vault"
)
func (u *ui) header(gtx layout.Context) layout.Dimensions {
if u.usesCompactViewport() {
if u.shouldShowLifecycleSetup() || u.isVaultLocked() {
return layout.Dimensions{}
}
gtx.Constraints.Min.X = gtx.Constraints.Max.X
return u.headerActions(gtx)
}
if u.shouldShowDesktopWorkingHeader() {
return layout.Dimensions{}
}
return card(gtx, func(gtx layout.Context) layout.Dimensions {
return layout.Flex{Alignment: layout.Middle}.Layout(gtx,
layout.Flexed(1, func(gtx layout.Context) layout.Dimensions {
return u.brandMark(gtx, 196, 56)
}),
layout.Rigid(u.headerActions),
)
})
}
func (u *ui) headerActions(gtx layout.Context) layout.Dimensions {
if u.shouldShowLifecycleSetup() {
return layout.Dimensions{}
}
if u.isVaultLocked() {
return layout.Dimensions{}
}
if u.shouldShowDesktopWorkingHeader() {
return layout.Dimensions{}
}
spacing := gtx.Dp(unit.Dp(8))
metrics := appuilayout.HeaderActionMetrics{Spacing: spacing}
row := func(gtx layout.Context) layout.Dimensions {
return layout.Flex{Spacing: layout.SpaceStart}.Layout(gtx,
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
metrics.SyncDims = u.syncButtonGroup(gtx)
return metrics.SyncDims
}),
layout.Rigid(layout.Spacer{Width: unit.Dp(8)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
btn := material.Button(u.theme, &u.lockVault, "Lock")
metrics.LockDims = btn.Layout(gtx)
return metrics.LockDims
}),
layout.Rigid(layout.Spacer{Width: unit.Dp(8)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
metrics.MainDims = u.mainMenuButtonGroup(gtx)
return metrics.MainDims
}),
)
}
rowOps := op.Record(gtx.Ops)
metrics.RowDims = row(gtx)
rowCall := rowOps.Stop()
if u.usesCompactViewport() {
metrics.RowOriginX = max(0, gtx.Constraints.Max.X-metrics.RowDims.Size.X)
}
surface := appuilayout.DropdownSurface{ContainerWidth: gtx.Constraints.Max.X, LeftInset: 0, TopInset: 0}
rowStack := op.Offset(image.Pt(metrics.RowOriginX, 0)).Push(gtx.Ops)
rowCall.Add(gtx.Ops)
rowStack.Pop()
if u.usesCompactViewport() {
if u.syncMenuOpen {
u.phoneSyncMenuVisible = true
u.phoneSyncMenuAnchor = metrics.SyncAnchor().Point()
}
if u.mainMenuOpen {
u.phoneMainMenuVisible = true
u.phoneMainMenuAnchor = metrics.MainAnchor().Point()
}
width := gtx.Constraints.Max.X
return layout.Dimensions{Size: image.Pt(width, metrics.RowDims.Size.Y)}
}
if u.syncMenuOpen {
surface.Draw(gtx, metrics.SyncAnchor(), u.syncMenu)
}
if u.mainMenuOpen {
surface.Draw(gtx, metrics.MainAnchor(), u.mainMenu)
}
width := metrics.RowDims.Size.X
return layout.Dimensions{Size: image.Pt(width, metrics.RowDims.Size.Y)}
}
func (u *ui) mainMenu(gtx layout.Context) layout.Dimensions {
rows := []layout.Widget{
func(gtx layout.Context) layout.Dimensions {
return tonedButton(gtx, u.theme, &u.showEntries, "Entries")
},
func(gtx layout.Context) layout.Dimensions {
return tonedButton(gtx, u.theme, &u.showRecycle, "Recycle Bin")
},
func(gtx layout.Context) layout.Dimensions {
return tonedButton(gtx, u.theme, &u.showAPITokens, "API Tokens")
},
func(gtx layout.Context) layout.Dimensions {
return tonedButton(gtx, u.theme, &u.showAPIAudit, "API Audit")
},
func(gtx layout.Context) layout.Dimensions { return tonedButton(gtx, u.theme, &u.showAbout, "About") },
func(gtx layout.Context) layout.Dimensions {
return tonedButton(gtx, u.theme, &u.openSecuritySettings, "Settings")
},
}
rowWidth := menuActionWidth(gtx, rows)
return intrinsicCompactCard(gtx, func(gtx layout.Context) layout.Dimensions {
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return rightAlignedMenuAction(gtx, rowWidth, rows[0])
}),
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return rightAlignedMenuAction(gtx, rowWidth, rows[1])
}),
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return rightAlignedMenuAction(gtx, rowWidth, rows[2])
}),
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return rightAlignedMenuAction(gtx, rowWidth, rows[3])
}),
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return rightAlignedMenuAction(gtx, rowWidth, rows[4])
}),
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return rightAlignedMenuAction(gtx, rowWidth, rows[5])
}),
)
})
}
func (u *ui) syncButtonGroup(gtx layout.Context) layout.Dimensions {
label := "Sync"
spacing := unit.Dp(4)
if u.usesCompactViewport() {
spacing = unit.Dp(3)
}
row := func(gtx layout.Context) layout.Dimensions {
return layout.Flex{Alignment: layout.Middle}.Layout(gtx,
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return syncPrimaryButton(gtx, u.theme, &u.synchronizeVault, label, u.usesCompactViewport())
}),
layout.Rigid(layout.Spacer{Width: spacing}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return u.syncMenuToggle(gtx)
}),
)
}
return row(gtx)
}
func (u *ui) syncMenuToggle(gtx layout.Context) layout.Dimensions {
btn := material.IconButton(u.theme, &u.toggleSyncMenu, u.chevronDownIcon, "More synchronize actions")
if u.syncMenuOpen {
btn.Background = accentColor
btn.Color = color.NRGBA{R: 255, G: 252, B: 247, A: 255}
} else {
btn.Background = color.NRGBA{R: 231, G: 236, B: 232, A: 255}
btn.Color = accentColor
}
btn.Size = unit.Dp(18)
btn.Inset = layout.UniformInset(unit.Dp(8))
if u.usesCompactViewport() {
btn.Size = unit.Dp(16)
btn.Inset = layout.UniformInset(unit.Dp(7))
}
return btn.Layout(gtx)
}
func (u *ui) syncMenu(gtx layout.Context) layout.Dimensions {
model := u.buildSyncMenuModel()
profiles := u.availableRemoteProfiles()
credentials := u.availableRemoteCredentialEntries()
if len(u.vaultRemoteProfileClicks) < len(profiles) {
u.vaultRemoteProfileClicks = make([]widget.Clickable, len(profiles))
}
if len(u.vaultRemoteCredentialClicks) < len(credentials) {
u.vaultRemoteCredentialClicks = make([]widget.Clickable, len(credentials))
}
actionRows := u.syncMenuActionRows(model)
actionWidth := menuActionWidth(gtx, actionRows)
return intrinsicCompactCard(gtx, func(gtx layout.Context) layout.Dimensions {
rows := u.syncMenuRows(model, profiles, credentials, actionWidth)
return layout.Flex{Axis: layout.Vertical}.Layout(gtx, rows...)
})
}
func (u *ui) syncMenuActionRows(model actions.SyncMenuModel) []layout.Widget {
rows := []layout.Widget{
func(gtx layout.Context) layout.Dimensions {
return tonedButton(gtx, u.theme, &u.openAdvancedSync, "Open Advanced Sync")
},
}
if model.ShowShare {
rows = append(rows, func(gtx layout.Context) layout.Dimensions {
return tonedButton(gtx, u.theme, &u.shareCurrentVault, "Share Vault")
})
}
if model.ShowRemoteSyncSetupShortcut() {
rows = append(rows, func(gtx layout.Context) layout.Dimensions {
return tonedButton(gtx, u.theme, &u.useSavedAdvancedSyncRemote, model.RemoteSyncSetupShortcutLabel())
})
}
if model.ShowDirectRemoteSyncShortcut() {
rows = append(rows, func(gtx layout.Context) layout.Dimensions {
return tonedButton(gtx, u.theme, &u.openSelectedVaultRemote, model.DirectRemoteSyncShortcutLabel())
})
}
if model.ShowRemoteSyncSettingsShortcut() {
rows = append(rows, func(gtx layout.Context) layout.Dimensions {
return tonedButton(gtx, u.theme, &u.useSavedAdvancedSyncRemote, model.RemoteSyncSettingsShortcutLabel())
})
}
if model.ShowRemoveRemoteSyncShortcut() {
rows = append(rows, func(gtx layout.Context) layout.Dimensions {
return tonedButton(gtx, u.theme, &u.removeSelectedRemoteBinding, model.RemoveRemoteSyncShortcutLabel())
})
}
return rows
}
func (u *ui) syncMenuRows(model actions.SyncMenuModel, profiles []vault.RemoteProfile, credentials []vault.Entry, actionWidth int) []layout.FlexChild {
rows := u.syncMenuPrimaryRows(model, actionWidth)
rows = append(rows, u.syncMenuSavedBindingRows(model, profiles, credentials)...)
if model.ShowSaveCurrentBinding {
rows = append(rows, u.syncMenuSaveBindingRows(model)...)
}
return rows
}
func (u *ui) syncMenuPrimaryRows(model actions.SyncMenuModel, actionWidth int) []layout.FlexChild {
rows := []layout.FlexChild{
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
lbl := material.Label(u.theme, unit.Sp(11), "Need another source or direction?")
lbl.Color = mutedColor
return lbl.Layout(gtx)
}),
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
}
if model.ShowShare {
rows = append(rows, layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return rightAlignedMenuAction(gtx, actionWidth, func(gtx layout.Context) layout.Dimensions {
return tonedButton(gtx, u.theme, &u.shareCurrentVault, "Share Vault")
})
}),
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
)
}))
}
rows = append(rows, u.syncMenuActionRow(actionWidth, &u.openAdvancedSync, "Open Advanced Sync"))
if model.ShowRemoteSyncSetupShortcut() {
rows = append(rows, layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout))
rows = append(rows, u.syncMenuActionRow(actionWidth, &u.useSavedAdvancedSyncRemote, model.RemoteSyncSetupShortcutLabel()))
}
if model.ShowDirectRemoteSyncShortcut() {
rows = append(rows, layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout))
rows = append(rows, u.syncMenuActionRow(actionWidth, &u.openSelectedVaultRemote, model.DirectRemoteSyncShortcutLabel()))
}
if model.ShowRemoteSyncSettingsShortcut() {
rows = append(rows, layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout))
rows = append(rows, u.syncMenuActionRow(actionWidth, &u.useSavedAdvancedSyncRemote, model.RemoteSyncSettingsShortcutLabel()))
}
if model.ShowRemoveRemoteSyncShortcut() {
rows = append(rows, layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout))
rows = append(rows, u.syncMenuActionRow(actionWidth, &u.removeSelectedRemoteBinding, model.RemoveRemoteSyncShortcutLabel()))
}
return rows
}
func (u *ui) syncMenuActionRow(actionWidth int, click *widget.Clickable, label string) layout.FlexChild {
return layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return rightAlignedMenuAction(gtx, actionWidth, func(gtx layout.Context) layout.Dimensions {
return tonedButton(gtx, u.theme, click, label)
})
})
}
func (u *ui) syncMenuSavedBindingRows(model actions.SyncMenuModel, profiles []vault.RemoteProfile, credentials []vault.Entry) []layout.FlexChild {
if !u.hasOpenVault() || len(profiles) == 0 || len(credentials) == 0 {
return nil
}
rows := []layout.FlexChild{
layout.Rigid(layout.Spacer{Height: unit.Dp(10)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
lbl := material.Label(u.theme, unit.Sp(11), model.SavedBindingHeading())
lbl.Color = mutedColor
return lbl.Layout(gtx)
}),
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
}
if !model.ShowSelectors {
rows = append(rows, layout.Rigid(u.syncMenuSavedBindingSummary(model)))
} else {
rows = append(rows, u.syncMenuSelectorRows(model, profiles, credentials)...)
}
if _, ok := u.selectedVaultRemoteProfile(); ok {
if _, ok := u.selectedVaultRemoteCredentialEntry(); ok {
rows = append(rows,
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return tonedButton(gtx, u.theme, &u.openSelectedVaultRemote, u.openSelectedVaultRemoteButtonLabel())
}),
)
}
}
return rows
}
func (u *ui) syncMenuSavedBindingSummary(model actions.SyncMenuModel) layout.Widget {
return func(gtx layout.Context) layout.Dimensions {
summary := model.SavedBindingSummary
if !summary.OK {
return layout.Dimensions{}
}
return layout.Background{}.Layout(gtx, fill(color.NRGBA{R: 242, G: 245, B: 240, A: 255}), func(gtx layout.Context) layout.Dimensions {
return layout.UniformInset(unit.Dp(10)).Layout(gtx, 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(u.theme, unit.Sp(13), summary.ProfileLabel)
lbl.Color = accentColor
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(12), "Credential: "+summary.CredentialLabel)
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(12), summary.SyncLabel)
lbl.Color = mutedColor
return lbl.Layout(gtx)
}),
)
})
})
}
}
func (u *ui) syncMenuSaveBindingRows(model actions.SyncMenuModel) []layout.FlexChild {
return []layout.FlexChild{
layout.Rigid(layout.Spacer{Height: unit.Dp(10)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
lbl := material.Label(u.theme, unit.Sp(11), model.SaveCurrentRemoteBindingHeading())
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 tonedButton(gtx, u.theme, &u.saveCurrentRemoteBinding, model.SaveCurrentRemoteBindingButtonLabel())
}),
}
}
func (u *ui) syncMenuSelectorRows(_ actions.SyncMenuModel, profiles []vault.RemoteProfile, credentials []vault.Entry) []layout.FlexChild {
rows := make([]layout.FlexChild, 0, len(profiles)+len(credentials)+4)
for i, profile := range profiles {
i := i
profile := profile
rows = append(rows, layout.Rigid(func(gtx layout.Context) layout.Dimensions {
selected := u.selectedVaultRemoteProfileID == profile.ID
return layout.Inset{Bottom: unit.Dp(4)}.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
return recentSelectionCard(gtx, selected, func(gtx layout.Context) layout.Dimensions {
return u.vaultRemoteProfileClicks[i].Layout(gtx, func(gtx layout.Context) layout.Dimensions {
lbl := material.Label(u.theme, unit.Sp(13), profile.Name)
lbl.Color = accentColor
return lbl.Layout(gtx)
})
})
})
}))
}
rows = append(rows, layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout))
for i, entry := range credentials {
i := i
entry := entry
rows = append(rows, layout.Rigid(func(gtx layout.Context) layout.Dimensions {
selected := u.selectedVaultRemoteCredentialEntryID == entry.ID
label := entry.Title
if entry.Username != "" {
label += " · " + entry.Username
}
return layout.Inset{Bottom: unit.Dp(4)}.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
return recentSelectionCard(gtx, selected, func(gtx layout.Context) layout.Dimensions {
return u.vaultRemoteCredentialClicks[i].Layout(gtx, func(gtx layout.Context) layout.Dimensions {
lbl := material.Label(u.theme, unit.Sp(13), label)
lbl.Color = accentColor
return lbl.Layout(gtx)
})
})
})
}))
}
return rows
}
func intrinsicCompactCard(gtx layout.Context, w layout.Widget) layout.Dimensions {
measureGTX := gtx
measureGTX.Constraints.Min = image.Point{}
measureGTX.Constraints.Max.X = gtx.Constraints.Max.X
macro := op.Record(gtx.Ops)
contentDims := w(measureGTX)
_ = macro.Stop()
width := contentDims.Size.X + gtx.Dp(unit.Dp(20))
maxWidth := gtx.Constraints.Max.X
if maxWidth > 0 && width > maxWidth {
width = maxWidth
}
if width > 0 {
gtx.Constraints.Min.X = width
gtx.Constraints.Max.X = width
}
return compactCard(gtx, w)
}
func (u *ui) topRightActionOrder() []string {
if u.isVaultLocked() {
return nil
}
return []string{"Sync", "Lock", "Menu"}
}
func (u *ui) mainMenuButtonGroup(gtx layout.Context) layout.Dimensions {
button := func(gtx layout.Context) layout.Dimensions {
icon := u.menuIcon
if icon == nil {
icon = u.settingsIcon
}
btn := material.IconButton(u.theme, &u.toggleMainMenu, icon, "Menu")
if u.mainMenuOpen {
btn.Background = accentColor
btn.Color = color.NRGBA{R: 255, G: 252, B: 247, A: 255}
} else {
btn.Background = selectedColor
btn.Color = accentColor
}
btn.Size = unit.Dp(18)
btn.Inset = layout.UniformInset(unit.Dp(8))
return btn.Layout(gtx)
}
return button(gtx)
}
func (u *ui) phoneHeaderMenus(gtx layout.Context) layout.Dimensions {
if !u.usesCompactViewport() {
return layout.Dimensions{}
}
if !u.syncMenuVisibleOnPhone() && !u.mainMenuVisibleOnPhone() {
return layout.Dimensions{}
}
gtx.Constraints.Min = gtx.Constraints.Max
contentInsetPx := gtx.Dp(unit.Dp(16))
surface := appuilayout.DropdownSurface{
ContainerWidth: max(0, gtx.Constraints.Max.X-(contentInsetPx*2)),
LeftInset: contentInsetPx,
TopInset: contentInsetPx,
}
if u.syncMenuVisibleOnPhone() {
surface.Draw(gtx, appuilayout.DropdownAnchor{TriggerRightX: u.phoneSyncMenuAnchor.X, TriggerBottomY: u.phoneSyncMenuAnchor.Y}, u.syncMenu)
}
if u.mainMenuVisibleOnPhone() {
surface.Draw(gtx, appuilayout.DropdownAnchor{TriggerRightX: u.phoneMainMenuAnchor.X, TriggerBottomY: u.phoneMainMenuAnchor.Y}, u.mainMenu)
}
return layout.Dimensions{Size: gtx.Constraints.Max}
}
func (u *ui) syncMenuVisibleOnPhone() bool {
return u.usesCompactViewport() && u.phoneSyncMenuVisible && u.syncMenuOpen
}
func (u *ui) mainMenuVisibleOnPhone() bool {
return u.usesCompactViewport() && u.phoneMainMenuVisible && u.mainMenuOpen
}
func (u *ui) syncMenuDropsBelowTrigger() bool {
return true
}
func (u *ui) syncMenuRightAlignsToTrigger() bool {
return true
}
func (u *ui) headerMenusUseOverlayModel() bool {
return true
}
func (u *ui) mainMenuDropsBelowTrigger() bool {
return true
}
func (u *ui) mainMenuRightAlignsToTrigger() bool {
return true
}
func menuActionWidth(gtx layout.Context, rows []layout.Widget) int {
width := 0
for _, row := range rows {
measureGTX := gtx
measureGTX.Constraints.Min = image.Point{}
macro := op.Record(gtx.Ops)
dims := row(measureGTX)
_ = macro.Stop()
if dims.Size.X > width {
width = dims.Size.X
}
}
return width
}
func rightAlignedMenuAction(gtx layout.Context, width int, child layout.Widget) layout.Dimensions {
if width <= 0 {
return child(gtx)
}
gtx.Constraints.Min.X = width
gtx.Constraints.Max.X = width
return layout.E.Layout(gtx, child)
}
-24
View File
@@ -1,24 +0,0 @@
package appui
import (
"gioui.org/layout"
"gioui.org/unit"
"gioui.org/widget/material"
)
func (u *ui) lifecycleScreen(gtx layout.Context) layout.Dimensions {
panel := card
if u.usesCompactViewport() {
panel = compactCard
}
return panel(gtx, func(gtx layout.Context) layout.Dimensions {
rows := []layout.Widget{
u.lifecycleBranding,
layout.Spacer{Height: unit.Dp(8)}.Layout,
u.lifecycleControls,
}
return material.List(u.theme, &u.lifecycleList).Layout(gtx, len(rows), func(gtx layout.Context, i int) layout.Dimensions {
return rows[i](gtx)
})
})
}
-83
View File
@@ -1,83 +0,0 @@
package appui
import (
"strings"
"gioui.org/io/event"
"gioui.org/io/key"
"gioui.org/layout"
"git.julianfamily.org/keepassgo/internal/clipboard"
)
const (
shortcutSearch = "search"
shortcutSave = "save"
shortcutLock = "lock"
shortcutNewEntry = "new-entry"
shortcutCopyUser = "copy-user"
shortcutCopyPassword = "copy-password"
shortcutCopyURL = "copy-url"
)
func (u *ui) processShortcuts(gtx layout.Context) {
event.Op(gtx.Ops, u)
for {
ev, ok := gtx.Event(
key.Filter{Name: "F", Required: key.ModShortcut},
key.Filter{Name: "S", Required: key.ModShortcut},
key.Filter{Name: "L", Required: key.ModShortcut},
key.Filter{Name: "N", Required: key.ModShortcut},
key.Filter{Name: "U", Required: key.ModShortcut},
key.Filter{Name: "P", Required: key.ModShortcut},
key.Filter{Name: "O", Required: key.ModShortcut},
key.Filter{Name: key.NameTab, Optional: key.ModShift},
key.Filter{Name: key.NameLeftArrow},
key.Filter{Name: key.NameRightArrow},
key.Filter{Name: key.NameUpArrow},
key.Filter{Name: key.NameDownArrow},
key.Filter{Name: key.NameReturn},
key.Filter{Name: key.NameBack},
key.Filter{Name: key.NameEscape},
)
if !ok {
break
}
ke, ok := ev.(key.Event)
if !ok || ke.State != key.Press {
continue
}
u.handleKeyPress(ke.Name, ke.Modifiers)
if ke.Name == key.NameBack || ke.Name == key.NameEscape {
_ = u.handlePhoneBack()
}
}
}
func (u *ui) performShortcut(name string) error {
switch name {
case shortcutSearch:
u.keyboardFocus = focusSearch
return nil
case shortcutSave:
return u.saveAction()
case shortcutLock:
return u.lockAction()
case shortcutNewEntry:
u.state.BeginNewEntry()
u.loadSelectedEntryIntoEditor()
u.entryPath.SetText(strings.Join(u.state.CurrentPath, " / "))
u.keyboardFocus = detailFocusID(detailFieldTitle)
return nil
case shortcutCopyUser:
return u.copySelectedFieldAction(clipboard.TargetUsername)
case shortcutCopyPassword:
return u.copySelectedFieldAction(clipboard.TargetPassword)
case shortcutCopyURL:
return u.copySelectedFieldAction(clipboard.TargetURL)
default:
return nil
}
}
-51
View File
@@ -1,51 +0,0 @@
package appui
import (
"runtime"
"strings"
"git.julianfamily.org/keepassgo/internal/appstate"
appuiactions "git.julianfamily.org/keepassgo/internal/appui/actions"
)
func (u *ui) buildSyncMenuModel() appuiactions.SyncMenuModel {
model := appuiactions.SyncMenuModel{
HasOpenVault: u.hasOpenVault(),
ShowSelectors: u.shouldShowSavedRemoteBindingSelectors(),
ShowShare: supportsVaultShare(runtime.GOOS) && u.vaultSharer != nil && strings.TrimSpace(u.currentShareableVaultPath()) != "",
RemoteBaseURL: strings.TrimSpace(u.remoteBaseURL.Text()),
RemotePath: strings.TrimSpace(u.remotePath.Text()),
RemoteUsername: strings.TrimSpace(u.remoteUsername.Text()),
RemotePassword: u.remotePassword.Text(),
SelectedVaultSyncMode: normalizeUISyncMode(u.selectedVaultRemoteSyncMode),
}
_, model.HasSelectedBinding = u.selectedVaultRemoteBinding()
model.SavedBindingSummary = u.computeSavedRemoteBindingSummary()
model.ShowSaveCurrentBinding = model.HasOpenVault && model.RemoteBaseURL != "" && model.RemotePath != "" && model.RemoteUsername != "" && model.RemotePassword != ""
return model
}
func (u *ui) computeSavedRemoteBindingSummary() appuiactions.SyncMenuBindingSummary {
profile, ok := u.selectedVaultRemoteProfile()
if !ok {
return appuiactions.SyncMenuBindingSummary{}
}
entry, ok := u.selectedVaultRemoteCredentialEntry()
if !ok {
return appuiactions.SyncMenuBindingSummary{}
}
credentialLabel := entry.Title
if strings.TrimSpace(entry.Username) != "" {
credentialLabel += " · " + strings.TrimSpace(entry.Username)
}
syncLabel := "Sync manually when you choose Use Remote Sync."
if normalizeUISyncMode(u.selectedVaultRemoteSyncMode) == appstate.SyncModeAutomaticOnOpenSave {
syncLabel = "Syncs automatically on open and save."
}
return appuiactions.SyncMenuBindingSummary{
ProfileLabel: profile.Name,
CredentialLabel: credentialLabel,
SyncLabel: syncLabel,
OK: true,
}
}
+346
View File
@@ -0,0 +1,346 @@
package browserbridge
import (
"context"
"crypto/sha256"
"encoding/base64"
"encoding/binary"
"encoding/json"
"fmt"
"io"
"os"
"path/filepath"
"runtime"
"strings"
"git.julianfamily.org/keepassgo/internal/grpcaddr"
keepassgov1 "git.julianfamily.org/keepassgo/proto/keepassgo/v1"
gcodes "google.golang.org/grpc/codes"
gstatus "google.golang.org/grpc/status"
)
const (
NativeHostName = "com.keepassgo.browser"
defaultFirefoxID = "browser@keepassgo.com"
maxNativeMessageSize = 1024 * 1024
chromiumIDBytes = 16
responseVersion = "1"
)
type Request struct {
Action string `json:"action"`
BearerToken string `json:"bearerToken,omitempty"`
URL string `json:"url,omitempty"`
EntryID string `json:"entryId,omitempty"`
}
type Response struct {
Success bool `json:"success"`
Error string `json:"error,omitempty"`
Status *Status `json:"status,omitempty"`
Matches []Match `json:"matches,omitempty"`
Credential *Credential `json:"credential,omitempty"`
Version string `json:"version,omitempty"`
}
type Status struct {
Connected bool `json:"connected"`
Locked bool `json:"locked"`
Dirty bool `json:"dirty,omitempty"`
EntryCount uint32 `json:"entryCount,omitempty"`
PendingApprovalCount uint32 `json:"pendingApprovalCount,omitempty"`
TokenPendingApprovalCount uint32 `json:"tokenPendingApprovalCount,omitempty"`
GRPCAddress string `json:"grpcAddress,omitempty"`
}
type Match struct {
ID string `json:"id"`
Title string `json:"title"`
Username string `json:"username,omitempty"`
URL string `json:"url,omitempty"`
Path []string `json:"path,omitempty"`
Quality string `json:"quality,omitempty"`
}
type Credential struct {
ID string `json:"id"`
Username string `json:"username,omitempty"`
Password string `json:"password,omitempty"`
URL string `json:"url,omitempty"`
}
type Connection struct {
GRPCAddress string
BearerToken string
}
type Client interface {
Status(context.Context) (*keepassgov1.GetSessionStatusResponse, error)
FindBrowserLogins(context.Context, string) ([]*keepassgov1.BrowserLoginMatch, error)
GetBrowserCredential(context.Context, string, string) (*keepassgov1.GetBrowserCredentialResponse, error)
}
type Browser string
const (
BrowserFirefox Browser = "firefox"
BrowserChrome Browser = "chrome"
BrowserChromium Browser = "chromium"
)
type NativeHostManifest struct {
Name string `json:"name"`
Description string `json:"description"`
Path string `json:"path"`
Type string `json:"type"`
AllowedExtensions []string `json:"allowed_extensions,omitempty"`
AllowedOrigins []string `json:"allowed_origins,omitempty"`
}
func DefaultFirefoxExtensionID() string {
return defaultFirefoxID
}
func ReadRequest(r io.Reader) (Request, error) {
var sizeBuf [4]byte
if _, err := io.ReadFull(r, sizeBuf[:]); err != nil {
return Request{}, err
}
size := binary.LittleEndian.Uint32(sizeBuf[:])
if size == 0 || size > maxNativeMessageSize {
return Request{}, fmt.Errorf("invalid native message size %d", size)
}
body := make([]byte, size)
if _, err := io.ReadFull(r, body); err != nil {
return Request{}, err
}
var req Request
if err := json.Unmarshal(body, &req); err != nil {
return Request{}, fmt.Errorf("decode native request: %w", err)
}
return req, nil
}
func WriteResponse(w io.Writer, resp Response) error {
data, err := json.Marshal(resp)
if err != nil {
return fmt.Errorf("encode native response: %w", err)
}
if len(data) > maxNativeMessageSize {
return fmt.Errorf("native response too large: %d", len(data))
}
var sizeBuf [4]byte
binary.LittleEndian.PutUint32(sizeBuf[:], uint32(len(data)))
if _, err := w.Write(sizeBuf[:]); err != nil {
return err
}
_, err = w.Write(data)
return err
}
func (r Request) Connection(grpcAddr string) (Connection, error) {
return normalizeConnection(Connection{
GRPCAddress: strings.TrimSpace(grpcAddr),
BearerToken: strings.TrimSpace(r.BearerToken),
})
}
func normalizeConnection(conn Connection) (Connection, error) {
if strings.TrimSpace(conn.GRPCAddress) == "" {
conn.GRPCAddress = grpcaddr.Default(runtime.GOOS)
}
if strings.TrimSpace(conn.BearerToken) == "" {
return Connection{}, fmt.Errorf("browser bridge bearer token is required")
}
conn.GRPCAddress = strings.TrimSpace(conn.GRPCAddress)
conn.BearerToken = strings.TrimSpace(conn.BearerToken)
return conn, nil
}
func HandleRequest(ctx context.Context, req Request, grpcAddr string, client Client) Response {
conn, err := req.Connection(grpcAddr)
if err != nil {
return Response{Success: false, Error: err.Error()}
}
action := strings.TrimSpace(req.Action)
switch action {
case "status":
status, err := statusResponse(ctx, client, conn.GRPCAddress)
if err != nil {
return Response{Success: false, Error: err.Error(), Status: disconnectedStatus(conn.GRPCAddress)}
}
return Response{Success: true, Status: status, Version: responseVersion}
case "find-logins":
matches, err := findMatches(ctx, client, req.URL)
if err != nil {
if status := inferredActionStatus(conn.GRPCAddress, err); status != nil {
return Response{Success: true, Status: status, Matches: nil, Version: responseVersion}
}
return Response{Success: false, Error: err.Error(), Status: disconnectedStatus(conn.GRPCAddress)}
}
return Response{Success: true, Status: availableStatus(conn.GRPCAddress), Matches: matches, Version: responseVersion}
case "get-login":
credential, err := loadCredential(ctx, client, req.EntryID, req.URL)
if err != nil {
if status := inferredActionStatus(conn.GRPCAddress, err); status != nil {
return Response{Success: false, Error: err.Error(), Status: status}
}
return Response{Success: false, Error: err.Error(), Status: disconnectedStatus(conn.GRPCAddress)}
}
return Response{Success: true, Status: availableStatus(conn.GRPCAddress), Credential: credential, Version: responseVersion}
default:
return Response{Success: false, Error: fmt.Sprintf("unsupported action %q", action)}
}
}
func disconnectedStatus(addr string) *Status {
return &Status{Connected: false, GRPCAddress: strings.TrimSpace(addr)}
}
func availableStatus(addr string) *Status {
return &Status{Connected: true, Locked: false, GRPCAddress: strings.TrimSpace(addr)}
}
func inferredActionStatus(addr string, err error) *Status {
switch gstatus.Code(err) {
case gcodes.FailedPrecondition:
return &Status{Connected: true, Locked: true, GRPCAddress: strings.TrimSpace(addr)}
case gcodes.OK:
return availableStatus(addr)
default:
return nil
}
}
func statusResponse(ctx context.Context, client Client, addr string) (*Status, error) {
resp, err := client.Status(ctx)
if err != nil {
return nil, err
}
return &Status{
Connected: true,
Locked: resp.GetLocked(),
Dirty: resp.GetDirty(),
EntryCount: resp.GetEntryCount(),
PendingApprovalCount: resp.GetPendingApprovalCount(),
TokenPendingApprovalCount: resp.GetTokenPendingApprovalCount(),
GRPCAddress: strings.TrimSpace(addr),
}, nil
}
func findMatches(ctx context.Context, client Client, rawURL string) ([]Match, error) {
resp, err := client.FindBrowserLogins(ctx, strings.TrimSpace(rawURL))
if err != nil {
return nil, err
}
out := make([]Match, 0, len(resp))
for _, match := range resp {
out = append(out, Match{
ID: match.GetId(),
Title: match.GetTitle(),
Username: match.GetUsername(),
URL: match.GetUrl(),
Path: append([]string(nil), match.GetPath()...),
Quality: match.GetQuality(),
})
}
return out, nil
}
func loadCredential(ctx context.Context, client Client, entryID, rawURL string) (*Credential, error) {
id := strings.TrimSpace(entryID)
if id == "" {
return nil, fmt.Errorf("entry id is required")
}
resp, err := client.GetBrowserCredential(ctx, id, strings.TrimSpace(rawURL))
if err != nil {
return nil, err
}
return &Credential{
ID: resp.GetId(),
Username: resp.GetUsername(),
Password: resp.GetPassword(),
URL: resp.GetUrl(),
}, nil
}
func Manifest(browser Browser, binaryPath, extensionID string) (NativeHostManifest, error) {
path := strings.TrimSpace(binaryPath)
if path == "" {
return NativeHostManifest{}, fmt.Errorf("native host binary path is required")
}
switch browser {
case BrowserFirefox:
id := strings.TrimSpace(extensionID)
if id == "" {
id = defaultFirefoxID
}
return NativeHostManifest{
Name: NativeHostName,
Description: "KeePassGO browser bridge",
Path: path,
Type: "stdio",
AllowedExtensions: []string{id},
}, nil
case BrowserChrome, BrowserChromium:
id := strings.TrimSpace(extensionID)
if id == "" {
return NativeHostManifest{}, fmt.Errorf("%s extension id is required", browser)
}
return NativeHostManifest{
Name: NativeHostName,
Description: "KeePassGO browser bridge",
Path: path,
Type: "stdio",
AllowedOrigins: []string{"chrome-extension://" + id + "/"},
}, nil
default:
return NativeHostManifest{}, fmt.Errorf("unsupported browser %q", browser)
}
}
func ChromiumExtensionIDFromManifestKey(raw string) (string, error) {
normalized := strings.TrimSpace(raw)
normalized = strings.ReplaceAll(normalized, "-----BEGIN PUBLIC KEY-----", "")
normalized = strings.ReplaceAll(normalized, "-----END PUBLIC KEY-----", "")
normalized = strings.ReplaceAll(normalized, "\n", "")
normalized = strings.ReplaceAll(normalized, "\r", "")
normalized = strings.ReplaceAll(normalized, "\t", "")
normalized = strings.ReplaceAll(normalized, " ", "")
if normalized == "" {
return "", fmt.Errorf("chromium extension key is required")
}
publicKeyDER, err := base64.StdEncoding.DecodeString(normalized)
if err != nil {
return "", fmt.Errorf("decode chromium extension key: %w", err)
}
hash := sha256.Sum256(publicKeyDER)
var builder strings.Builder
builder.Grow(chromiumIDBytes * 2)
for _, b := range hash[:chromiumIDBytes] {
builder.WriteByte('a' + ((b >> 4) & 0x0f))
builder.WriteByte('a' + (b & 0x0f))
}
return builder.String(), nil
}
func DefaultManifestPath(browser Browser) (string, error) {
home, err := os.UserHomeDir()
if err != nil {
return "", err
}
switch browser {
case BrowserFirefox:
return filepath.Join(home, ".mozilla", "native-messaging-hosts", NativeHostName+".json"), nil
case BrowserChrome:
return filepath.Join(home, ".config", "google-chrome", "NativeMessagingHosts", NativeHostName+".json"), nil
case BrowserChromium:
return filepath.Join(home, ".config", "chromium", "NativeMessagingHosts", NativeHostName+".json"), nil
default:
return "", fmt.Errorf("unsupported browser %q", browser)
}
}
func InstallManifest(browser Browser, binaryPath, extensionID, outputPath string) (string, error) {
return InstallManifestSet(browser, binaryPath, []string{strings.TrimSpace(extensionID)}, outputPath)
}
+396
View File
@@ -0,0 +1,396 @@
package browserbridge
import (
"bytes"
"context"
"encoding/binary"
"encoding/json"
"os"
"path/filepath"
"runtime"
"slices"
"strings"
"testing"
keepassgov1 "git.julianfamily.org/keepassgo/proto/keepassgo/v1"
gcodes "google.golang.org/grpc/codes"
gstatus "google.golang.org/grpc/status"
)
func TestReadRequestAndWriteResponse(t *testing.T) {
t.Parallel()
var input bytes.Buffer
body, err := json.Marshal(Request{
Action: "find-logins",
BearerToken: "secret",
URL: "https://example.invalid/login",
})
if err != nil {
t.Fatalf("Marshal() error = %v", err)
}
if err := binary.Write(&input, binary.LittleEndian, uint32(len(body))); err != nil {
t.Fatalf("binary.Write() error = %v", err)
}
if _, err := input.Write(body); err != nil {
t.Fatalf("Write() error = %v", err)
}
req, err := ReadRequest(&input)
if err != nil {
t.Fatalf("ReadRequest() error = %v", err)
}
if req.Action != "find-logins" || req.BearerToken != "secret" {
t.Fatalf("ReadRequest() = %#v, want action and token preserved", req)
}
if conn, err := req.Connection("127.0.0.1:47777"); err != nil || conn.GRPCAddress != "127.0.0.1:47777" {
t.Fatalf("req.Connection(127.0.0.1:47777) = (%#v, %v), want explicit tcp address preserved", conn, err)
}
var output bytes.Buffer
if err := WriteResponse(&output, Response{Success: true, Version: "1"}); err != nil {
t.Fatalf("WriteResponse() error = %v", err)
}
var size uint32
if err := binary.Read(&output, binary.LittleEndian, &size); err != nil {
t.Fatalf("binary.Read() error = %v", err)
}
payload := make([]byte, size)
if _, err := output.Read(payload); err != nil {
t.Fatalf("Read() payload error = %v", err)
}
var resp Response
if err := json.Unmarshal(payload, &resp); err != nil {
t.Fatalf("Unmarshal() error = %v", err)
}
if !resp.Success || resp.Version != "1" {
t.Fatalf("response = %#v, want success version 1", resp)
}
}
func TestHandleRequestFindLogins(t *testing.T) {
t.Parallel()
client := &fakeClient{
matches: []*keepassgov1.BrowserLoginMatch{
{Id: "vault-console", Title: "Vault Console", Username: "dannyocean", Url: "https://vault.example.invalid", Quality: "exact-host"},
},
}
resp := HandleRequest(context.Background(), Request{
Action: "find-logins",
BearerToken: "secret",
URL: "https://vault.example.invalid/login",
}, "", client)
if !resp.Success {
t.Fatalf("HandleRequest() success = false, error = %q", resp.Error)
}
if len(resp.Matches) != 1 || resp.Matches[0].ID != "vault-console" {
t.Fatalf("HandleRequest().Matches = %#v, want vault-console", resp.Matches)
}
if client.statusCalls != 0 {
t.Fatalf("HandleRequest(find-logins) statusCalls = %d, want 0", client.statusCalls)
}
}
func TestHandleRequestStatusIncludesPendingApprovalCounts(t *testing.T) {
t.Parallel()
client := &fakeClient{
status: &keepassgov1.GetSessionStatusResponse{
Locked: false,
EntryCount: 2,
PendingApprovalCount: 3,
TokenPendingApprovalCount: 1,
},
}
resp := HandleRequest(context.Background(), Request{
Action: "status",
BearerToken: "secret",
}, "", client)
if !resp.Success {
t.Fatalf("HandleRequest(status) success = false, error = %q", resp.Error)
}
if resp.Status == nil {
t.Fatal("HandleRequest(status).Status = nil, want status")
}
if got := resp.Status.PendingApprovalCount; got != 3 {
t.Fatalf("HandleRequest(status).PendingApprovalCount = %d, want 3", got)
}
if got := resp.Status.TokenPendingApprovalCount; got != 1 {
t.Fatalf("HandleRequest(status).TokenPendingApprovalCount = %d, want 1", got)
}
}
func TestHandleRequestGetLogin(t *testing.T) {
t.Parallel()
client := &fakeClient{
credential: &keepassgov1.GetBrowserCredentialResponse{
Id: "vault-console",
Username: "dannyocean",
Password: "token-1",
Url: "https://vault.example.invalid",
},
}
resp := HandleRequest(context.Background(), Request{
Action: "get-login",
BearerToken: "secret",
EntryID: "vault-console",
URL: "https://vault.example.invalid/login",
}, "", client)
if !resp.Success {
t.Fatalf("HandleRequest() success = false, error = %q", resp.Error)
}
if resp.Credential == nil || resp.Credential.ID != "vault-console" {
t.Fatalf("HandleRequest().Credential = %#v, want vault-console", resp.Credential)
}
if client.statusCalls != 0 {
t.Fatalf("HandleRequest(get-login) statusCalls = %d, want 0", client.statusCalls)
}
}
func TestHandleRequestFindLoginsInfersLockedStatusFromRPC(t *testing.T) {
t.Parallel()
client := &fakeClient{matchesErr: gstatus.Error(gcodes.FailedPrecondition, "vault is locked")}
resp := HandleRequest(context.Background(), Request{
Action: "find-logins",
BearerToken: "secret",
URL: "https://vault.example.invalid/login",
}, "", client)
if !resp.Success {
t.Fatalf("HandleRequest(find-logins locked) success = false, error = %q", resp.Error)
}
if resp.Status == nil || !resp.Status.Locked {
t.Fatalf("HandleRequest(find-logins locked).Status = %#v, want locked status", resp.Status)
}
if client.statusCalls != 0 {
t.Fatalf("HandleRequest(find-logins locked) statusCalls = %d, want 0", client.statusCalls)
}
}
func TestHandleRequestRequiresBearerToken(t *testing.T) {
t.Parallel()
resp := HandleRequest(context.Background(), Request{Action: "status"}, "", &fakeClient{})
if resp.Success {
t.Fatal("HandleRequest().Success = true, want false without token")
}
}
func TestRequestConnectionDefaultsAddress(t *testing.T) {
t.Parallel()
req := Request{Action: "status", BearerToken: "secret"}
conn, err := req.Connection("")
if err != nil {
t.Fatalf("Connection(\"\") error = %v", err)
}
if conn.GRPCAddress == "" {
t.Fatal("Connection().GRPCAddress = empty, want default address")
}
if runtime.GOOS != "windows" && !strings.HasPrefix(conn.GRPCAddress, "unix://") && conn.GRPCAddress != "off" {
t.Fatalf("Connection().GRPCAddress = %q, want unix socket default on this platform", conn.GRPCAddress)
}
}
func TestInstallManifest(t *testing.T) {
t.Parallel()
tmp := t.TempDir()
binaryPath := filepath.Join(tmp, "keepassgo-browser-bridge")
if err := os.WriteFile(binaryPath, []byte("#!/bin/sh\n"), 0o755); err != nil {
t.Fatalf("WriteFile(binary) error = %v", err)
}
path, err := InstallManifest(BrowserFirefox, binaryPath, "", filepath.Join(tmp, "firefox-host.json"))
if err != nil {
t.Fatalf("InstallManifest() error = %v", err)
}
data, err := os.ReadFile(path)
if err != nil {
t.Fatalf("ReadFile() error = %v", err)
}
var manifest NativeHostManifest
if err := json.Unmarshal(data, &manifest); err != nil {
t.Fatalf("Unmarshal() error = %v", err)
}
if manifest.Path != binaryPath {
t.Fatalf("manifest.Path = %q, want %q", manifest.Path, binaryPath)
}
if len(manifest.AllowedExtensions) != 1 || manifest.AllowedExtensions[0] != DefaultFirefoxExtensionID() {
t.Fatalf("manifest.AllowedExtensions = %#v, want default firefox extension id", manifest.AllowedExtensions)
}
}
func TestChromiumExtensionIDFromManifestKey(t *testing.T) {
t.Parallel()
const publicKey = "MFwwDQYJKoZIhvcNAQEBBQADSwAwSAJBAMfW0u1k4K5A0uN2s0aH7uQKpM3x5Hf8mZfY1xVh0m7E2mJ7M8GiV4m0g0I2w9U9D1yqGQ6w8jzH5v8t7qB2RjMCAwEAAQ=="
got, err := ChromiumExtensionIDFromManifestKey(publicKey)
if err != nil {
t.Fatalf("ChromiumExtensionIDFromManifestKey() error = %v", err)
}
if got != "okcdfigpojphpoecpglkkmkjmiaefmpd" {
t.Fatalf("ChromiumExtensionIDFromManifestKey() = %q, want %q", got, "okcdfigpojphpoecpglkkmkjmiaefmpd")
}
}
func TestManifestSetChromiumIncludesAllOrigins(t *testing.T) {
t.Parallel()
manifest, err := ManifestSet(BrowserChromium, "/tmp/keepassgo-browser-bridge", []string{
"mjlnpdomnblnbblhacolncflebbgafhj",
"ddfbfpcgdjkffmjnialjpookcoedahcn",
"mjlnpdomnblnbblhacolncflebbgafhj",
})
if err != nil {
t.Fatalf("ManifestSet() error = %v", err)
}
want := []string{
"chrome-extension://ddfbfpcgdjkffmjnialjpookcoedahcn/",
"chrome-extension://mjlnpdomnblnbblhacolncflebbgafhj/",
}
if !slices.Equal(manifest.AllowedOrigins, want) {
t.Fatalf("ManifestSet().AllowedOrigins = %#v, want %#v", manifest.AllowedOrigins, want)
}
}
func TestDiscoverInstalledExtensionIDsInRoot(t *testing.T) {
t.Parallel()
root := t.TempDir()
writeExtensionManifest(t, filepath.Join(root, "Default", "Extensions", "mjlnpdomnblnbblhacolncflebbgafhj", "1.0.0", "manifest.json"), browserExtensionName)
writeExtensionManifest(t, filepath.Join(root, "Profile 1", "Extensions", "ddfbfpcgdjkffmjnialjpookcoedahcn", "1.2.0", "manifest.json"), browserExtensionName)
writeExtensionManifest(t, filepath.Join(root, "Profile 2", "Extensions", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", "3.4.5", "manifest.json"), "Bellagio Notes")
writeExtensionManifest(t, filepath.Join(root, "Profile 3", "Extensions", "mjlnpdomnblnbblhacolncflebbgafhj", "1.1.0", "manifest.json"), browserExtensionName)
got, err := DiscoverInstalledExtensionIDsInRoot(root)
if err != nil {
t.Fatalf("DiscoverInstalledExtensionIDsInRoot() error = %v", err)
}
want := []string{
"ddfbfpcgdjkffmjnialjpookcoedahcn",
"mjlnpdomnblnbblhacolncflebbgafhj",
}
if !slices.Equal(got, want) {
t.Fatalf("DiscoverInstalledExtensionIDsInRoot() = %#v, want %#v", got, want)
}
}
func TestEnsureNativeHostManifestsInstallsFirefoxAndDiscoveredChromium(t *testing.T) {
tmp := t.TempDir()
t.Setenv("HOME", filepath.Join(tmp, "home"))
appDir := filepath.Join(tmp, "app")
if err := os.MkdirAll(appDir, 0o755); err != nil {
t.Fatalf("MkdirAll(appDir) error = %v", err)
}
appBinaryPath := filepath.Join(appDir, "keepassgo")
if err := os.WriteFile(appBinaryPath, []byte("#!/bin/sh\n"), 0o755); err != nil {
t.Fatalf("WriteFile(appBinaryPath) error = %v", err)
}
bridgeBinaryPath := filepath.Join(appDir, "keepassgo-browser-bridge")
if err := os.WriteFile(bridgeBinaryPath, []byte("#!/bin/sh\n"), 0o755); err != nil {
t.Fatalf("WriteFile(bridgeBinaryPath) error = %v", err)
}
home := filepath.Join(tmp, "home")
writeExtensionManifest(t, filepath.Join(home, ".config", "chromium", "Default", "Extensions", "mjlnpdomnblnbblhacolncflebbgafhj", "1.0.0", "manifest.json"), browserExtensionName)
writeExtensionManifest(t, filepath.Join(home, ".config", "google-chrome", "Profile 7", "Extensions", "ddfbfpcgdjkffmjnialjpookcoedahcn", "1.0.0", "manifest.json"), browserExtensionName)
if err := EnsureNativeHostManifests(appBinaryPath); err != nil {
t.Fatalf("EnsureNativeHostManifests() error = %v", err)
}
assertManifestContainsExtension(t, filepath.Join(home, ".mozilla", "native-messaging-hosts", NativeHostName+".json"), "allowed_extensions", DefaultFirefoxExtensionID())
assertManifestContainsExtension(t, filepath.Join(home, ".config", "chromium", "NativeMessagingHosts", NativeHostName+".json"), "allowed_origins", "chrome-extension://mjlnpdomnblnbblhacolncflebbgafhj/")
assertManifestContainsExtension(t, filepath.Join(home, ".config", "google-chrome", "NativeMessagingHosts", NativeHostName+".json"), "allowed_origins", "chrome-extension://ddfbfpcgdjkffmjnialjpookcoedahcn/")
}
type fakeClient struct {
status *keepassgov1.GetSessionStatusResponse
matches []*keepassgov1.BrowserLoginMatch
credential *keepassgov1.GetBrowserCredentialResponse
err error
matchesErr error
credentialErr error
statusCalls int
}
func writeExtensionManifest(t *testing.T, path, name string) {
t.Helper()
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatalf("MkdirAll(%q) error = %v", filepath.Dir(path), err)
}
data, err := json.Marshal(map[string]string{"name": name})
if err != nil {
t.Fatalf("Marshal(manifest %q) error = %v", path, err)
}
if err := os.WriteFile(path, append(data, '\n'), 0o644); err != nil {
t.Fatalf("WriteFile(%q) error = %v", path, err)
}
}
func assertManifestContainsExtension(t *testing.T, path, field, want string) {
t.Helper()
data, err := os.ReadFile(path)
if err != nil {
t.Fatalf("ReadFile(%q) error = %v", path, err)
}
var manifest map[string]any
if err := json.Unmarshal(data, &manifest); err != nil {
t.Fatalf("Unmarshal(%q) error = %v", path, err)
}
valuesAny, ok := manifest[field]
if !ok {
t.Fatalf("manifest %q missing field %q", path, field)
}
valuesRaw, ok := valuesAny.([]any)
if !ok {
t.Fatalf("manifest %q field %q = %#v, want []any", path, field, valuesAny)
}
values := make([]string, 0, len(valuesRaw))
for _, raw := range valuesRaw {
text, ok := raw.(string)
if !ok {
t.Fatalf("manifest %q field %q value = %#v, want string", path, field, raw)
}
values = append(values, text)
}
if !slices.Contains(values, want) {
t.Fatalf("manifest %q field %q = %#v, want to contain %q", path, field, values, want)
}
}
func (f *fakeClient) Status(context.Context) (*keepassgov1.GetSessionStatusResponse, error) {
f.statusCalls++
if f.err != nil {
return nil, f.err
}
if f.status == nil {
return &keepassgov1.GetSessionStatusResponse{}, nil
}
return f.status, nil
}
func (f *fakeClient) FindBrowserLogins(context.Context, string) ([]*keepassgov1.BrowserLoginMatch, error) {
if f.matchesErr != nil {
return nil, f.matchesErr
}
if f.err != nil {
return nil, f.err
}
return f.matches, nil
}
func (f *fakeClient) GetBrowserCredential(context.Context, string, string) (*keepassgov1.GetBrowserCredentialResponse, error) {
if f.credentialErr != nil {
return nil, f.credentialErr
}
if f.err != nil {
return nil, f.err
}
if f.credential == nil {
return &keepassgov1.GetBrowserCredentialResponse{}, nil
}
return f.credential, nil
}
+73
View File
@@ -0,0 +1,73 @@
package browserbridge
import (
"context"
"fmt"
"net"
"strings"
"git.julianfamily.org/keepassgo/internal/grpcaddr"
keepassgov1 "git.julianfamily.org/keepassgo/proto/keepassgo/v1"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials/insecure"
"google.golang.org/grpc/metadata"
)
type GRPCClient struct {
client keepassgov1.VaultServiceClient
}
func DialRequest(ctx context.Context, req Request, grpcAddr string) (*grpc.ClientConn, *GRPCClient, context.Context, error) {
conn, err := req.Connection(grpcAddr)
if err != nil {
return nil, nil, nil, err
}
return Dial(ctx, conn)
}
func Dial(ctx context.Context, conn Connection) (*grpc.ClientConn, *GRPCClient, context.Context, error) {
normalized, err := normalizeConnection(conn)
if err != nil {
return nil, nil, nil, err
}
network, endpoint, err := grpcaddr.Parse(normalized.GRPCAddress)
if err != nil {
return nil, nil, nil, err
}
target := endpoint
if network == "unix" {
target = "passthrough:///" + endpoint
}
grpcConn, err := grpc.NewClient(target,
grpc.WithTransportCredentials(insecure.NewCredentials()),
grpc.WithContextDialer(func(context.Context, string) (net.Conn, error) {
return net.Dial(network, endpoint)
}),
)
if err != nil {
return nil, nil, nil, fmt.Errorf("dial gRPC host %s: %w", normalized.GRPCAddress, err)
}
ctx = metadata.AppendToOutgoingContext(ctx, "authorization", "Bearer "+normalized.BearerToken)
return grpcConn, &GRPCClient{client: keepassgov1.NewVaultServiceClient(grpcConn)}, ctx, nil
}
func (c *GRPCClient) Status(ctx context.Context) (*keepassgov1.GetSessionStatusResponse, error) {
return c.client.GetSessionStatus(ctx, &keepassgov1.GetSessionStatusRequest{})
}
func (c *GRPCClient) FindBrowserLogins(ctx context.Context, pageURL string) ([]*keepassgov1.BrowserLoginMatch, error) {
resp, err := c.client.FindBrowserLogins(ctx, &keepassgov1.FindBrowserLoginsRequest{
PageUrl: strings.TrimSpace(pageURL),
})
if err != nil {
return nil, err
}
return resp.GetMatches(), nil
}
func (c *GRPCClient) GetBrowserCredential(ctx context.Context, entryID, pageURL string) (*keepassgov1.GetBrowserCredentialResponse, error) {
return c.client.GetBrowserCredential(ctx, &keepassgov1.GetBrowserCredentialRequest{
Id: strings.TrimSpace(entryID),
PageUrl: strings.TrimSpace(pageURL),
})
}
@@ -0,0 +1,210 @@
package browserbridge
import (
"encoding/json"
"errors"
"fmt"
"os"
"os/exec"
"path/filepath"
"slices"
"strings"
)
const browserExtensionName = "KeePassGO Browser"
type extensionManifestMetadata struct {
Name string `json:"name"`
}
func ResolveBridgeBinaryPath(appBinaryPath string) (string, error) {
path := strings.TrimSpace(appBinaryPath)
if path == "" {
var err error
path, err = os.Executable()
if err != nil {
return "", fmt.Errorf("resolve app executable: %w", err)
}
}
if strings.TrimSpace(path) == "" {
return "", fmt.Errorf("app executable path is required")
}
if filepath.Base(path) == "keepassgo-browser-bridge" {
return path, nil
}
candidate := filepath.Join(filepath.Dir(path), "keepassgo-browser-bridge")
if info, err := os.Stat(candidate); err == nil && !info.IsDir() {
return candidate, nil
}
resolved, err := exec.LookPath("keepassgo-browser-bridge")
if err == nil {
return resolved, nil
}
return "", fmt.Errorf("locate keepassgo-browser-bridge next to %q or in PATH: %w", path, err)
}
func EnsureNativeHostManifests(appBinaryPath string) error {
bridgePath, err := ResolveBridgeBinaryPath(appBinaryPath)
if err != nil {
return err
}
var errs []error
if _, err := InstallManifest(BrowserFirefox, bridgePath, "", ""); err != nil {
errs = append(errs, fmt.Errorf("install firefox native host: %w", err))
}
for _, browser := range []Browser{BrowserChrome, BrowserChromium} {
ids, err := DiscoverInstalledExtensionIDs(browser)
if err != nil {
errs = append(errs, fmt.Errorf("discover %s extension ids: %w", browser, err))
continue
}
if len(ids) == 0 {
continue
}
if _, err := InstallManifestSet(browser, bridgePath, ids, ""); err != nil {
errs = append(errs, fmt.Errorf("install %s native host: %w", browser, err))
}
}
return errors.Join(errs...)
}
func DiscoverInstalledExtensionIDs(browser Browser) ([]string, error) {
root, err := defaultBrowserProfileRoot(browser)
if err != nil {
return nil, err
}
return DiscoverInstalledExtensionIDsInRoot(root)
}
func DiscoverInstalledExtensionIDsInRoot(root string) ([]string, error) {
base := strings.TrimSpace(root)
if base == "" {
return nil, fmt.Errorf("browser profile root is required")
}
pattern := filepath.Join(base, "*", "Extensions", "*", "*", "manifest.json")
paths, err := filepath.Glob(pattern)
if err != nil {
return nil, fmt.Errorf("glob browser extensions: %w", err)
}
ids := make(map[string]struct{}, len(paths))
for _, path := range paths {
ok, err := isKeePassGOExtensionManifest(path)
if err != nil {
return nil, err
}
if !ok {
continue
}
id := filepath.Base(filepath.Dir(filepath.Dir(path)))
if strings.TrimSpace(id) == "" {
continue
}
ids[id] = struct{}{}
}
out := make([]string, 0, len(ids))
for id := range ids {
out = append(out, id)
}
slices.Sort(out)
return out, nil
}
func InstallManifestSet(browser Browser, binaryPath string, extensionIDs []string, outputPath string) (string, error) {
manifest, err := ManifestSet(browser, binaryPath, extensionIDs)
if err != nil {
return "", err
}
path := strings.TrimSpace(outputPath)
if path == "" {
path, err = DefaultManifestPath(browser)
if err != nil {
return "", err
}
}
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return "", fmt.Errorf("create native host manifest dir: %w", err)
}
data, err := json.MarshalIndent(manifest, "", " ")
if err != nil {
return "", fmt.Errorf("encode native host manifest: %w", err)
}
data = append(data, '\n')
if err := os.WriteFile(path, data, 0o644); err != nil {
return "", fmt.Errorf("write native host manifest: %w", err)
}
return path, nil
}
func ManifestSet(browser Browser, binaryPath string, extensionIDs []string) (NativeHostManifest, error) {
path := strings.TrimSpace(binaryPath)
if path == "" {
return NativeHostManifest{}, fmt.Errorf("native host binary path is required")
}
switch browser {
case BrowserFirefox:
return Manifest(browser, path, "")
case BrowserChrome, BrowserChromium:
ids := normalizedExtensionIDs(extensionIDs)
if len(ids) == 0 {
return NativeHostManifest{}, fmt.Errorf("%s extension id is required", browser)
}
origins := make([]string, 0, len(ids))
for _, id := range ids {
origins = append(origins, "chrome-extension://"+id+"/")
}
return NativeHostManifest{
Name: NativeHostName,
Description: "KeePassGO browser bridge",
Path: path,
Type: "stdio",
AllowedOrigins: origins,
}, nil
default:
return NativeHostManifest{}, fmt.Errorf("unsupported browser %q", browser)
}
}
func defaultBrowserProfileRoot(browser Browser) (string, error) {
home, err := os.UserHomeDir()
if err != nil {
return "", err
}
switch browser {
case BrowserChrome:
return filepath.Join(home, ".config", "google-chrome"), nil
case BrowserChromium:
return filepath.Join(home, ".config", "chromium"), nil
default:
return "", fmt.Errorf("installed extension discovery is unsupported for %q", browser)
}
}
func isKeePassGOExtensionManifest(path string) (bool, error) {
data, err := os.ReadFile(path)
if err != nil {
return false, fmt.Errorf("read extension manifest %q: %w", path, err)
}
var metadata extensionManifestMetadata
if err := json.Unmarshal(data, &metadata); err != nil {
return false, fmt.Errorf("decode extension manifest %q: %w", path, err)
}
return strings.TrimSpace(metadata.Name) == browserExtensionName, nil
}
func normalizedExtensionIDs(ids []string) []string {
seen := make(map[string]struct{}, len(ids))
out := make([]string, 0, len(ids))
for _, raw := range ids {
id := strings.TrimSpace(raw)
if id == "" {
continue
}
if _, ok := seen[id]; ok {
continue
}
seen[id] = struct{}{}
out = append(out, id)
}
slices.Sort(out)
return out
}
+66
View File
@@ -0,0 +1,66 @@
package grpcaddr
import (
"fmt"
"os"
"path/filepath"
"runtime"
"strconv"
"strings"
)
const socketName = "keepassgo-grpc.sock"
func Default(goos string) string {
if strings.EqualFold(strings.TrimSpace(goos), "android") {
return "off"
}
if strings.EqualFold(strings.TrimSpace(goos), "windows") {
return "127.0.0.1:47777"
}
return "unix://" + DefaultSocketPath()
}
func DefaultSocketPath() string {
return filepath.Join(runtimeDir(), "keepassgo", socketName)
}
func runtimeDir() string {
if dir := strings.TrimSpace(os.Getenv("XDG_RUNTIME_DIR")); dir != "" {
return dir
}
if runtime.GOOS != "windows" {
uid := strconv.Itoa(os.Getuid())
runUserDir := filepath.Join("/run/user", uid)
if info, err := os.Stat(runUserDir); err == nil && info.IsDir() {
return runUserDir
}
}
return filepath.Join(os.TempDir(), fmt.Sprintf("keepassgo-runtime-%d", os.Getuid()))
}
func Parse(raw string) (network, endpoint string, err error) {
value := strings.TrimSpace(raw)
switch {
case value == "":
return "", "", fmt.Errorf("gRPC address is required")
case strings.EqualFold(value, "off"):
return "", "", nil
case strings.HasPrefix(value, "unix://"):
path := strings.TrimSpace(strings.TrimPrefix(value, "unix://"))
if path == "" {
return "", "", fmt.Errorf("unix gRPC socket path is required")
}
return "unix", path, nil
case strings.HasPrefix(value, "tcp://"):
addr := strings.TrimSpace(strings.TrimPrefix(value, "tcp://"))
if addr == "" {
return "", "", fmt.Errorf("tcp gRPC address is required")
}
return "tcp", addr, nil
case strings.HasPrefix(value, "/"):
return "unix", value, nil
default:
return "tcp", value, nil
}
}
+48
View File
@@ -0,0 +1,48 @@
package grpcaddr
import (
"path/filepath"
"runtime"
"testing"
)
func TestDefaultUsesUnixSocketOnUnixLikeSystems(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("unix default is not expected on windows")
}
t.Setenv("XDG_RUNTIME_DIR", "/tmp/keepassgo-runtime-test")
got := Default("linux")
want := "unix:///tmp/keepassgo-runtime-test/keepassgo/keepassgo-grpc.sock"
if got != want {
t.Fatalf("Default() = %q, want %q", got, want)
}
}
func TestParse(t *testing.T) {
t.Parallel()
tests := []struct {
name string
input string
wantNetwork string
wantEnd string
}{
{name: "unix scheme", input: "unix:///tmp/keepassgo.sock", wantNetwork: "unix", wantEnd: "/tmp/keepassgo.sock"},
{name: "tcp scheme", input: "tcp://127.0.0.1:47777", wantNetwork: "tcp", wantEnd: "127.0.0.1:47777"},
{name: "bare path", input: filepath.Clean("/tmp/keepassgo.sock"), wantNetwork: "unix", wantEnd: filepath.Clean("/tmp/keepassgo.sock")},
{name: "bare tcp", input: "127.0.0.1:47777", wantNetwork: "tcp", wantEnd: "127.0.0.1:47777"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
gotNetwork, gotEnd, err := Parse(tt.input)
if err != nil {
t.Fatalf("Parse() error = %v", err)
}
if gotNetwork != tt.wantNetwork || gotEnd != tt.wantEnd {
t.Fatalf("Parse() = (%q, %q), want (%q, %q)", gotNetwork, gotEnd, tt.wantNetwork, tt.wantEnd)
}
})
}
}
+4
View File
@@ -93,6 +93,10 @@ func (m *Manager) HasVault() bool {
return len(m.encoded) > 0 || m.path != "" || m.remotePath != ""
}
func (m *Manager) HasSaveTarget() bool {
return m.path != "" || (m.remoteClient != nil && m.remotePath != "")
}
func (m *Manager) EncodedBytes() []byte {
return append([]byte(nil), m.encoded...)
}
+23
View File
@@ -0,0 +1,23 @@
package vaultview
import "git.julianfamily.org/keepassgo/internal/vault"
// HiddenRoot returns the single synthetic top-level vault group that should be
// treated as an internal storage root rather than as a user-visible group.
func HiddenRoot(model vault.Model) string {
if len(model.EntriesInPath(nil)) != 0 {
return ""
}
groups := model.ChildGroups(nil)
roots := make([]string, 0, len(groups))
for _, group := range groups {
if group == "Recycle Bin" {
continue
}
roots = append(roots, group)
}
if len(roots) != 1 {
return ""
}
return roots[0]
}
+26
View File
@@ -0,0 +1,26 @@
package vaultview
import (
"testing"
"git.julianfamily.org/keepassgo/internal/vault"
)
func TestHiddenRootIgnoresRecycleBin(t *testing.T) {
t.Parallel()
model := vault.Model{
Entries: []vault.Entry{
{ID: "entry-1", Title: "Vault Console", Path: []string{"keepass", "Crew", "Internet"}},
},
Groups: [][]string{
{"keepass"},
{"keepass", "Crew"},
{"Recycle Bin"},
},
}
if got := HiddenRoot(model); got != "keepass" {
t.Fatalf("HiddenRoot() = %q, want %q", got, "keepass")
}
}
@@ -42,12 +42,34 @@ build() {
local app_version
app_version="$(git describe --tags --always --dirty)"
go build -ldflags "-X git.julianfamily.org/keepassgo/internal/appui.appVersion=${app_version}" -o keepassgo ./cmd/keepassgo
go build -ldflags "-X git.julianfamily.org/keepassgo/internal/appui.appVersion=${app_version}" -o keepassgo-browser-bridge ./cmd/keepassgo-browser-bridge
}
package() {
cd "$(_repo_dir)"
install -Dm755 keepassgo "${pkgdir}/usr/bin/keepassgo"
install -Dm755 keepassgo-browser-bridge "${pkgdir}/usr/bin/keepassgo-browser-bridge"
install -Dm644 browser/extension/README.md \
"${pkgdir}/usr/share/keepassgo/browser-extension/README.md"
install -Dm644 browser/extension/background.js \
"${pkgdir}/usr/share/keepassgo/browser-extension/background.js"
install -Dm644 browser/extension/content.js \
"${pkgdir}/usr/share/keepassgo/browser-extension/content.js"
install -Dm644 browser/extension/manifest.chromium.json \
"${pkgdir}/usr/share/keepassgo/browser-extension/manifest.chromium.json"
install -Dm644 browser/extension/manifest.firefox.json \
"${pkgdir}/usr/share/keepassgo/browser-extension/manifest.firefox.json"
install -Dm644 browser/extension/options.html \
"${pkgdir}/usr/share/keepassgo/browser-extension/options.html"
install -Dm644 browser/extension/options.js \
"${pkgdir}/usr/share/keepassgo/browser-extension/options.js"
install -Dm644 browser/extension/popup.html \
"${pkgdir}/usr/share/keepassgo/browser-extension/popup.html"
install -Dm644 browser/extension/popup.js \
"${pkgdir}/usr/share/keepassgo/browser-extension/popup.js"
install -Dm644 browser/extension/style.css \
"${pkgdir}/usr/share/keepassgo/browser-extension/style.css"
install -Dm644 internal/assets/keepassgo-icon.png \
"${pkgdir}/usr/share/icons/hicolor/512x512/apps/keepassgo.png"
install -Dm644 internal/assets/keepassgo-icon.svg \
File diff suppressed because it is too large Load Diff
+33
View File
@@ -11,6 +11,8 @@ service VaultService {
rpc SaveVault(SaveVaultRequest) returns (SaveVaultResponse);
rpc LockVault(LockVaultRequest) returns (LockVaultResponse);
rpc UnlockVault(UnlockVaultRequest) returns (UnlockVaultResponse);
rpc FindBrowserLogins(FindBrowserLoginsRequest) returns (FindBrowserLoginsResponse);
rpc GetBrowserCredential(GetBrowserCredentialRequest) returns (GetBrowserCredentialResponse);
rpc ListEntries(ListEntriesRequest) returns (ListEntriesResponse);
rpc ListGroups(ListGroupsRequest) returns (ListGroupsResponse);
rpc CreateGroup(CreateGroupRequest) returns (CreateGroupResponse);
@@ -39,6 +41,8 @@ message GetSessionStatusResponse {
bool locked = 1;
bool dirty = 2;
uint32 entry_count = 3;
uint32 pending_approval_count = 4;
uint32 token_pending_approval_count = 5;
}
message OpenVaultRequest {
@@ -75,6 +79,35 @@ message UnlockVaultRequest {
message UnlockVaultResponse {}
message FindBrowserLoginsRequest {
string page_url = 1;
}
message BrowserLoginMatch {
string id = 1;
string title = 2;
string username = 3;
string url = 4;
repeated string path = 5;
string quality = 6;
}
message FindBrowserLoginsResponse {
repeated BrowserLoginMatch matches = 1;
}
message GetBrowserCredentialRequest {
string id = 1;
string page_url = 2;
}
message GetBrowserCredentialResponse {
string id = 1;
string username = 2;
string password = 3;
string url = 4;
}
message ListEntriesRequest {
repeated string path = 1;
string query = 2;
+103 -27
View File
@@ -1,7 +1,7 @@
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
// versions:
// - protoc-gen-go-grpc v1.5.1
// - protoc v6.33.1
// - protoc v7.34.1
// source: proto/keepassgo/v1/keepassgo.proto
package keepassgov1
@@ -19,32 +19,34 @@ import (
const _ = grpc.SupportPackageIsVersion9
const (
VaultService_GetSessionStatus_FullMethodName = "/keepassgo.v1.VaultService/GetSessionStatus"
VaultService_OpenVault_FullMethodName = "/keepassgo.v1.VaultService/OpenVault"
VaultService_OpenRemoteVault_FullMethodName = "/keepassgo.v1.VaultService/OpenRemoteVault"
VaultService_SaveVault_FullMethodName = "/keepassgo.v1.VaultService/SaveVault"
VaultService_LockVault_FullMethodName = "/keepassgo.v1.VaultService/LockVault"
VaultService_UnlockVault_FullMethodName = "/keepassgo.v1.VaultService/UnlockVault"
VaultService_ListEntries_FullMethodName = "/keepassgo.v1.VaultService/ListEntries"
VaultService_ListGroups_FullMethodName = "/keepassgo.v1.VaultService/ListGroups"
VaultService_CreateGroup_FullMethodName = "/keepassgo.v1.VaultService/CreateGroup"
VaultService_RenameGroup_FullMethodName = "/keepassgo.v1.VaultService/RenameGroup"
VaultService_DeleteGroup_FullMethodName = "/keepassgo.v1.VaultService/DeleteGroup"
VaultService_UpsertEntry_FullMethodName = "/keepassgo.v1.VaultService/UpsertEntry"
VaultService_DeleteEntry_FullMethodName = "/keepassgo.v1.VaultService/DeleteEntry"
VaultService_RestoreEntry_FullMethodName = "/keepassgo.v1.VaultService/RestoreEntry"
VaultService_ListEntryHistory_FullMethodName = "/keepassgo.v1.VaultService/ListEntryHistory"
VaultService_RestoreEntryHistory_FullMethodName = "/keepassgo.v1.VaultService/RestoreEntryHistory"
VaultService_ListTemplates_FullMethodName = "/keepassgo.v1.VaultService/ListTemplates"
VaultService_UpsertTemplate_FullMethodName = "/keepassgo.v1.VaultService/UpsertTemplate"
VaultService_DeleteTemplate_FullMethodName = "/keepassgo.v1.VaultService/DeleteTemplate"
VaultService_InstantiateTemplate_FullMethodName = "/keepassgo.v1.VaultService/InstantiateTemplate"
VaultService_ListAttachments_FullMethodName = "/keepassgo.v1.VaultService/ListAttachments"
VaultService_UploadAttachment_FullMethodName = "/keepassgo.v1.VaultService/UploadAttachment"
VaultService_DownloadAttachment_FullMethodName = "/keepassgo.v1.VaultService/DownloadAttachment"
VaultService_DeleteAttachment_FullMethodName = "/keepassgo.v1.VaultService/DeleteAttachment"
VaultService_CopyEntryField_FullMethodName = "/keepassgo.v1.VaultService/CopyEntryField"
VaultService_GeneratePassword_FullMethodName = "/keepassgo.v1.VaultService/GeneratePassword"
VaultService_GetSessionStatus_FullMethodName = "/keepassgo.v1.VaultService/GetSessionStatus"
VaultService_OpenVault_FullMethodName = "/keepassgo.v1.VaultService/OpenVault"
VaultService_OpenRemoteVault_FullMethodName = "/keepassgo.v1.VaultService/OpenRemoteVault"
VaultService_SaveVault_FullMethodName = "/keepassgo.v1.VaultService/SaveVault"
VaultService_LockVault_FullMethodName = "/keepassgo.v1.VaultService/LockVault"
VaultService_UnlockVault_FullMethodName = "/keepassgo.v1.VaultService/UnlockVault"
VaultService_FindBrowserLogins_FullMethodName = "/keepassgo.v1.VaultService/FindBrowserLogins"
VaultService_GetBrowserCredential_FullMethodName = "/keepassgo.v1.VaultService/GetBrowserCredential"
VaultService_ListEntries_FullMethodName = "/keepassgo.v1.VaultService/ListEntries"
VaultService_ListGroups_FullMethodName = "/keepassgo.v1.VaultService/ListGroups"
VaultService_CreateGroup_FullMethodName = "/keepassgo.v1.VaultService/CreateGroup"
VaultService_RenameGroup_FullMethodName = "/keepassgo.v1.VaultService/RenameGroup"
VaultService_DeleteGroup_FullMethodName = "/keepassgo.v1.VaultService/DeleteGroup"
VaultService_UpsertEntry_FullMethodName = "/keepassgo.v1.VaultService/UpsertEntry"
VaultService_DeleteEntry_FullMethodName = "/keepassgo.v1.VaultService/DeleteEntry"
VaultService_RestoreEntry_FullMethodName = "/keepassgo.v1.VaultService/RestoreEntry"
VaultService_ListEntryHistory_FullMethodName = "/keepassgo.v1.VaultService/ListEntryHistory"
VaultService_RestoreEntryHistory_FullMethodName = "/keepassgo.v1.VaultService/RestoreEntryHistory"
VaultService_ListTemplates_FullMethodName = "/keepassgo.v1.VaultService/ListTemplates"
VaultService_UpsertTemplate_FullMethodName = "/keepassgo.v1.VaultService/UpsertTemplate"
VaultService_DeleteTemplate_FullMethodName = "/keepassgo.v1.VaultService/DeleteTemplate"
VaultService_InstantiateTemplate_FullMethodName = "/keepassgo.v1.VaultService/InstantiateTemplate"
VaultService_ListAttachments_FullMethodName = "/keepassgo.v1.VaultService/ListAttachments"
VaultService_UploadAttachment_FullMethodName = "/keepassgo.v1.VaultService/UploadAttachment"
VaultService_DownloadAttachment_FullMethodName = "/keepassgo.v1.VaultService/DownloadAttachment"
VaultService_DeleteAttachment_FullMethodName = "/keepassgo.v1.VaultService/DeleteAttachment"
VaultService_CopyEntryField_FullMethodName = "/keepassgo.v1.VaultService/CopyEntryField"
VaultService_GeneratePassword_FullMethodName = "/keepassgo.v1.VaultService/GeneratePassword"
)
// VaultServiceClient is the client API for VaultService service.
@@ -57,6 +59,8 @@ type VaultServiceClient interface {
SaveVault(ctx context.Context, in *SaveVaultRequest, opts ...grpc.CallOption) (*SaveVaultResponse, error)
LockVault(ctx context.Context, in *LockVaultRequest, opts ...grpc.CallOption) (*LockVaultResponse, error)
UnlockVault(ctx context.Context, in *UnlockVaultRequest, opts ...grpc.CallOption) (*UnlockVaultResponse, error)
FindBrowserLogins(ctx context.Context, in *FindBrowserLoginsRequest, opts ...grpc.CallOption) (*FindBrowserLoginsResponse, error)
GetBrowserCredential(ctx context.Context, in *GetBrowserCredentialRequest, opts ...grpc.CallOption) (*GetBrowserCredentialResponse, error)
ListEntries(ctx context.Context, in *ListEntriesRequest, opts ...grpc.CallOption) (*ListEntriesResponse, error)
ListGroups(ctx context.Context, in *ListGroupsRequest, opts ...grpc.CallOption) (*ListGroupsResponse, error)
CreateGroup(ctx context.Context, in *CreateGroupRequest, opts ...grpc.CallOption) (*CreateGroupResponse, error)
@@ -147,6 +151,26 @@ func (c *vaultServiceClient) UnlockVault(ctx context.Context, in *UnlockVaultReq
return out, nil
}
func (c *vaultServiceClient) FindBrowserLogins(ctx context.Context, in *FindBrowserLoginsRequest, opts ...grpc.CallOption) (*FindBrowserLoginsResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(FindBrowserLoginsResponse)
err := c.cc.Invoke(ctx, VaultService_FindBrowserLogins_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *vaultServiceClient) GetBrowserCredential(ctx context.Context, in *GetBrowserCredentialRequest, opts ...grpc.CallOption) (*GetBrowserCredentialResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(GetBrowserCredentialResponse)
err := c.cc.Invoke(ctx, VaultService_GetBrowserCredential_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *vaultServiceClient) ListEntries(ctx context.Context, in *ListEntriesRequest, opts ...grpc.CallOption) (*ListEntriesResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(ListEntriesResponse)
@@ -357,6 +381,8 @@ type VaultServiceServer interface {
SaveVault(context.Context, *SaveVaultRequest) (*SaveVaultResponse, error)
LockVault(context.Context, *LockVaultRequest) (*LockVaultResponse, error)
UnlockVault(context.Context, *UnlockVaultRequest) (*UnlockVaultResponse, error)
FindBrowserLogins(context.Context, *FindBrowserLoginsRequest) (*FindBrowserLoginsResponse, error)
GetBrowserCredential(context.Context, *GetBrowserCredentialRequest) (*GetBrowserCredentialResponse, error)
ListEntries(context.Context, *ListEntriesRequest) (*ListEntriesResponse, error)
ListGroups(context.Context, *ListGroupsRequest) (*ListGroupsResponse, error)
CreateGroup(context.Context, *CreateGroupRequest) (*CreateGroupResponse, error)
@@ -405,6 +431,12 @@ func (UnimplementedVaultServiceServer) LockVault(context.Context, *LockVaultRequ
func (UnimplementedVaultServiceServer) UnlockVault(context.Context, *UnlockVaultRequest) (*UnlockVaultResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method UnlockVault not implemented")
}
func (UnimplementedVaultServiceServer) FindBrowserLogins(context.Context, *FindBrowserLoginsRequest) (*FindBrowserLoginsResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method FindBrowserLogins not implemented")
}
func (UnimplementedVaultServiceServer) GetBrowserCredential(context.Context, *GetBrowserCredentialRequest) (*GetBrowserCredentialResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method GetBrowserCredential not implemented")
}
func (UnimplementedVaultServiceServer) ListEntries(context.Context, *ListEntriesRequest) (*ListEntriesResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method ListEntries not implemented")
}
@@ -594,6 +626,42 @@ func _VaultService_UnlockVault_Handler(srv interface{}, ctx context.Context, dec
return interceptor(ctx, in, info, handler)
}
func _VaultService_FindBrowserLogins_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(FindBrowserLoginsRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(VaultServiceServer).FindBrowserLogins(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: VaultService_FindBrowserLogins_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(VaultServiceServer).FindBrowserLogins(ctx, req.(*FindBrowserLoginsRequest))
}
return interceptor(ctx, in, info, handler)
}
func _VaultService_GetBrowserCredential_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(GetBrowserCredentialRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(VaultServiceServer).GetBrowserCredential(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: VaultService_GetBrowserCredential_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(VaultServiceServer).GetBrowserCredential(ctx, req.(*GetBrowserCredentialRequest))
}
return interceptor(ctx, in, info, handler)
}
func _VaultService_ListEntries_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(ListEntriesRequest)
if err := dec(in); err != nil {
@@ -985,6 +1053,14 @@ var VaultService_ServiceDesc = grpc.ServiceDesc{
MethodName: "UnlockVault",
Handler: _VaultService_UnlockVault_Handler,
},
{
MethodName: "FindBrowserLogins",
Handler: _VaultService_FindBrowserLogins_Handler,
},
{
MethodName: "GetBrowserCredential",
Handler: _VaultService_GetBrowserCredential_Handler,
},
{
MethodName: "ListEntries",
Handler: _VaultService_ListEntries_Handler,
@@ -0,0 +1,109 @@
package main
import (
"context"
"flag"
"fmt"
"net"
"os"
"strings"
"time"
keepassgov1 "git.julianfamily.org/keepassgo/proto/keepassgo/v1"
"google.golang.org/grpc"
)
type validationServer struct {
keepassgov1.UnimplementedVaultServiceServer
statePath string
pageURL string
}
func readState(path string) string {
data, err := os.ReadFile(path)
if err != nil {
return "idle"
}
return strings.TrimSpace(string(data))
}
func writeState(path, value string) {
_ = os.WriteFile(path, []byte(value), 0o644)
}
func (s *validationServer) GetSessionStatus(context.Context, *keepassgov1.GetSessionStatusRequest) (*keepassgov1.GetSessionStatusResponse, error) {
pending := uint32(0)
if readState(s.statePath) == "pending" {
pending = 1
}
return &keepassgov1.GetSessionStatusResponse{
Locked: false,
EntryCount: 1,
PendingApprovalCount: pending,
TokenPendingApprovalCount: pending,
}, nil
}
func (s *validationServer) FindBrowserLogins(context.Context, *keepassgov1.FindBrowserLoginsRequest) (*keepassgov1.FindBrowserLoginsResponse, error) {
return &keepassgov1.FindBrowserLoginsResponse{
Matches: []*keepassgov1.BrowserLoginMatch{
{
Id: "vault-console",
Title: "Vault Console",
Username: "dannyocean",
Url: s.pageURL,
Path: []string{"Root", "Crew"},
Quality: "exact-host",
},
},
}, nil
}
func (s *validationServer) GetBrowserCredential(ctx context.Context, req *keepassgov1.GetBrowserCredentialRequest) (*keepassgov1.GetBrowserCredentialResponse, error) {
writeState(s.statePath, "pending")
ticker := time.NewTicker(200 * time.Millisecond)
defer ticker.Stop()
timeout := time.After(20 * time.Second)
for {
select {
case <-ctx.Done():
return nil, ctx.Err()
case <-timeout:
return nil, fmt.Errorf("timed out waiting for browser-approval state")
case <-ticker.C:
if readState(s.statePath) == "approved" {
writeState(s.statePath, "done")
return &keepassgov1.GetBrowserCredentialResponse{
Id: req.GetId(),
Username: "dannyocean",
Password: "token-1",
Url: s.pageURL,
}, nil
}
}
}
}
func main() {
listenAddr := flag.String("listen", "127.0.0.1:47779", "listen address")
statePath := flag.String("state", "", "path to mutable validation state file")
pageURL := flag.String("page-url", "http://127.0.0.1:18080/login.html", "login page URL returned by the stub")
flag.Parse()
if strings.TrimSpace(*statePath) == "" {
panic("validation state file is required")
}
listener, err := net.Listen("tcp", strings.TrimSpace(*listenAddr))
if err != nil {
panic(err)
}
server := grpc.NewServer()
keepassgov1.RegisterVaultServiceServer(server, &validationServer{
statePath: strings.TrimSpace(*statePath),
pageURL: strings.TrimSpace(*pageURL),
})
if err := server.Serve(listener); err != nil {
panic(err)
}
}
+611
View File
@@ -0,0 +1,611 @@
#!/usr/bin/env python3
import argparse
import base64
import json
import os
import re
import shutil
import socket
import subprocess
import sys
import tempfile
import textwrap
import time
import zipfile
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parents[1]
EXTENSION_SOURCE = REPO_ROOT / "browser" / "extension"
STUB_SERVER = REPO_ROOT / "scripts" / "browser_extension_validation_server.go"
TOKEN = "test-token"
ORIGINAL_HOME = Path(os.environ.get("HOME", ""))
def run(cmd, *, cwd=None, env=None, check=True):
result = subprocess.run(cmd, cwd=cwd, env=env, text=True, capture_output=True)
if check and result.returncode != 0:
raise RuntimeError(
f"command failed ({result.returncode}): {' '.join(cmd)}\n"
f"stdout:\n{result.stdout}\n"
f"stderr:\n{result.stderr}"
)
return result
def ensure_selenium_venv(venv_dir: Path):
python_bin = venv_dir / "bin" / "python"
if not python_bin.exists():
run([sys.executable, "-m", "venv", str(venv_dir)])
run([str(python_bin), "-m", "pip", "install", "selenium"])
return python_bin
def require_binary(name):
path = shutil.which(name)
if not path:
raise RuntimeError(f"required binary {name!r} was not found in PATH")
return path
def find_geckodriver():
direct = shutil.which("geckodriver")
if direct:
return direct
cache_root = ORIGINAL_HOME / ".cache" / "selenium" / "geckodriver"
if cache_root.exists():
candidates = sorted(cache_root.glob("**/geckodriver"))
if candidates:
return str(candidates[-1])
raise RuntimeError("required binary 'geckodriver' was not found in PATH or Selenium cache")
def write_login_fixture(path: Path):
path.write_text(
textwrap.dedent(
"""\
<!doctype html>
<html lang="en">
<body>
<form id="heist-login">
<label>Username <input id="username" type="text" autocomplete="username"></label>
<label>Password <input id="password" type="password" autocomplete="current-password"></label>
<button type="submit">Open Vault</button>
</form>
</body>
</html>
"""
),
encoding="utf-8",
)
def build_bridge(binary_path: Path):
run(["go", "build", "-o", str(binary_path), "./cmd/keepassgo-browser-bridge"], cwd=REPO_ROOT)
def patch_validation_defaults(background_js: Path, grpc_addr: str):
data = background_js.read_text(encoding="utf-8")
data = data.replace('grpcAddress: "",', f'grpcAddress: "{grpc_addr}",', 1)
data = data.replace('bearerToken: ""', f'bearerToken: "{TOKEN}"', 1)
data += textwrap.dedent(
"""
;((api) => {
api.runtime.onMessage.addListener((message, _sender, sendResponse) => {
if (message?.type === "keepassgo-validation-ping") {
sendResponse({ ok: true });
return false;
}
if (message?.type === "keepassgo-validation-status") {
(async () => {
try {
const settings = await loadSettings();
const status = await connectNative({
action: "status",
grpcAddress: settings.grpcAddress,
bearerToken: settings.bearerToken
});
sendResponse({ ok: true, settings, status });
} catch (error) {
sendResponse({ ok: false, error: String(error) });
}
})();
return true;
}
return false;
});
})(globalThis.browser ?? globalThis.chrome);
"""
)
background_js.write_text(data, encoding="utf-8")
def patch_validation_content(content_js: Path):
data = content_js.read_text(encoding="utf-8")
data += textwrap.dedent(
"""
;(() => {
const set = (name, value) => {
document.documentElement.setAttribute(name, String(value));
};
const api = globalThis.browser ?? globalThis.chrome;
set("data-keepassgo-validation-runtime-id", api?.runtime?.id || "");
const username = document.getElementById("username");
const focusTarget = username ? {
role: "username",
formIndex: 0,
fieldIndex: 0,
id: "username",
name: "",
autocomplete: "username"
} : null;
document.documentElement.setAttribute("data-keepassgo-validation-content", "loaded");
try {
if (api?.runtime?.sendMessage) {
Promise.resolve(api.runtime.sendMessage({ type: "keepassgo-validation-ping" }))
.then((response) => {
if (response?.ok) {
set("data-keepassgo-validation-background", "ok");
}
})
.catch((error) => {
set("data-keepassgo-validation-background", String(error));
});
Promise.resolve(api.runtime.sendMessage({ type: "keepassgo-validation-status" }))
.then((response) => {
if (response?.ok) {
set("data-keepassgo-validation-native", JSON.stringify(response.status || {}));
set("data-keepassgo-validation-settings", JSON.stringify(response.settings || {}));
} else {
set("data-keepassgo-validation-native-error", response?.error || "unknown");
}
})
.catch((error) => {
set("data-keepassgo-validation-native-error", String(error));
});
Promise.resolve(api.runtime.sendMessage({
type: "keepassgo-page-ready",
force: true,
pageHasLoginForm: true,
focusTarget,
signature: "validation"
}))
.then((response) => {
set("data-keepassgo-validation-page-ready", JSON.stringify(response || {}));
})
.catch((error) => {
set("data-keepassgo-validation-page-ready-error", String(error));
});
}
} catch (error) {
set("data-keepassgo-validation-background", String(error));
}
})();
"""
)
content_js.write_text(data, encoding="utf-8")
def prepare_chromium_extension(workspace: Path, grpc_addr: str):
ext_dir = workspace / "extension-chromium"
shutil.copytree(EXTENSION_SOURCE, ext_dir)
patch_validation_defaults(ext_dir / "background.js", grpc_addr)
patch_validation_content(ext_dir / "content.js")
key_pem = workspace / "extension-key.pem"
key_b64 = workspace / "extension-key.b64"
run(["openssl", "genrsa", "-out", str(key_pem), "2048"])
der = subprocess.check_output(["openssl", "rsa", "-in", str(key_pem), "-pubout", "-outform", "DER"])
key_b64.write_text(base64.b64encode(der).decode("utf-8"), encoding="utf-8")
manifest = json.loads((ext_dir / "manifest.chromium.json").read_text(encoding="utf-8"))
manifest["key"] = key_b64.read_text(encoding="utf-8").strip()
(ext_dir / "manifest.json").write_text(json.dumps(manifest, indent=2) + "\n", encoding="utf-8")
return ext_dir, key_b64
def prepare_firefox_extension(workspace: Path, grpc_addr: str):
ext_dir = workspace / "extension-firefox"
shutil.copytree(EXTENSION_SOURCE, ext_dir)
patch_validation_defaults(ext_dir / "background.js", grpc_addr)
patch_validation_content(ext_dir / "content.js")
manifest = json.loads((ext_dir / "manifest.firefox.json").read_text(encoding="utf-8"))
(ext_dir / "manifest.json").write_text(json.dumps(manifest, indent=2) + "\n", encoding="utf-8")
xpi_path = workspace / "keepassgo-firefox.xpi"
with zipfile.ZipFile(xpi_path, "w") as zf:
for path in ext_dir.iterdir():
if path.is_file() and path.name != "manifest.firefox.json":
zf.write(path, arcname=path.name)
return xpi_path
def install_chromium_native_host(workspace: Path, bridge_binary: Path, key_b64: Path):
home_dir = workspace / "home"
home_dir.mkdir(parents=True, exist_ok=True)
env = os.environ.copy()
env["HOME"] = str(home_dir)
env["XDG_CONFIG_HOME"] = str(home_dir / ".config")
result = run(
[
str(bridge_binary),
"install-native-host",
"--browser",
"chromium",
"--binary",
str(bridge_binary),
"--extension-key-file",
str(key_b64),
],
env=env,
)
manifest_path = Path(result.stdout.strip())
for mirror in [
home_dir / ".config" / "google-chrome" / "NativeMessagingHosts" / "com.keepassgo.browser.json",
home_dir / ".config" / "chromium-browser" / "NativeMessagingHosts" / "com.keepassgo.browser.json",
home_dir / ".config" / "chromium" / "chromium" / "NativeMessagingHosts" / "com.keepassgo.browser.json",
workspace / "chromium-profile" / "NativeMessagingHosts" / "com.keepassgo.browser.json",
]:
mirror.parent.mkdir(parents=True, exist_ok=True)
shutil.copyfile(manifest_path, mirror)
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
origin = manifest["allowed_origins"][0]
extension_id = re.search(r"chrome-extension://([^/]+)/", origin).group(1)
return extension_id, home_dir
def install_firefox_native_host(workspace: Path, bridge_binary: Path):
home_dir = workspace / "home"
home_dir.mkdir(parents=True, exist_ok=True)
env = os.environ.copy()
env["HOME"] = str(home_dir)
run(
[
str(bridge_binary),
"install-native-host",
"--browser",
"firefox",
"--binary",
str(bridge_binary),
],
env=env,
)
return home_dir
def launch_process(cmd, *, cwd=None, env=None, log_path=None):
handle = open(log_path, "w", encoding="utf-8") if log_path else subprocess.DEVNULL
return subprocess.Popen(cmd, cwd=cwd, env=env, stdout=handle, stderr=handle, text=True)
def wait_for_http(url: str, timeout: float = 10.0):
import urllib.request
deadline = time.time() + timeout
while time.time() < deadline:
try:
with urllib.request.urlopen(url, timeout=1) as response:
if response.status == 200:
return
except Exception:
time.sleep(0.2)
raise RuntimeError(f"timed out waiting for HTTP endpoint {url}")
def wait_for_tcp(host: str, port: int, *, timeout: float = 20.0, process=None, log_path: Path | None = None, name: str = "TCP endpoint"):
deadline = time.time() + timeout
while time.time() < deadline:
if process is not None and process.poll() is not None:
details = ""
if log_path and log_path.exists():
details = f"\nlog:\n{log_path.read_text(encoding='utf-8')}"
raise RuntimeError(f"{name} exited before becoming ready{details}")
try:
with socket.create_connection((host, port), timeout=1):
return
except OSError:
time.sleep(0.2)
details = ""
if log_path and log_path.exists():
details = f"\nlog:\n{log_path.read_text(encoding='utf-8')}"
raise RuntimeError(f"timed out waiting for {name} on {host}:{port}{details}")
def save_artifact(path: Path, content: str):
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(content, encoding="utf-8")
def find_free_port() -> int:
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
sock.bind(("127.0.0.1", 0))
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
return int(sock.getsockname()[1])
def run_chromium_flow(workspace: Path, extension_id: str, login_url: str):
from selenium import webdriver
from selenium.webdriver.chrome.options import Options
from selenium.webdriver.chrome.service import Service
from selenium.webdriver.common.by import By
from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC
ext_dir = workspace / "extension-chromium"
options = Options()
options.binary_location = require_binary("chromium")
options.set_capability("goog:loggingPrefs", {"browser": "ALL"})
for arg in [
f"--user-data-dir={workspace / 'chromium-profile'}",
f"--load-extension={ext_dir}",
f"--disable-extensions-except={ext_dir}",
"--no-first-run",
"--no-default-browser-check",
"--disable-search-engine-choice-screen",
"--disable-gpu",
"--no-sandbox",
"--disable-dev-shm-usage",
]:
options.add_argument(arg)
driver = webdriver.Chrome(service=Service(require_binary("chromedriver")), options=options)
wait = WebDriverWait(driver, 25)
stage = "launch browser"
try:
stage = "open login page"
driver.get(login_url)
wait.until(EC.element_to_be_clickable((By.ID, "username"))).click()
stage = "wait for inline root"
wait.until(lambda d: d.execute_script("return !!document.querySelector('#keepassgo-inline-root')"))
stage = "wait for inline dock"
wait.until(
lambda d: d.execute_script(
"const root=document.querySelector('#keepassgo-inline-root');"
"const dock=root?.shadowRoot?.querySelector('.dock');"
"return !!(dock && getComputedStyle(dock).display !== 'none');"
)
)
stage = "open inline chooser"
driver.execute_script(
"document.querySelector('#keepassgo-inline-root').shadowRoot.querySelector('.trigger').click()"
)
stage = "wait for chooser matches"
wait.until(
lambda d: d.execute_script(
"const root=document.querySelector('#keepassgo-inline-root');"
"return root.shadowRoot.querySelectorAll('.match').length || 0;"
)
)
stage = "request browser fill"
driver.execute_script(
"document.querySelector('#keepassgo-inline-root').shadowRoot.querySelector('.match').click()"
)
stage = "wait for page approval prompt"
wait.until(
lambda d: "Approve or deny"
in d.execute_script(
"const root=document.querySelector('#keepassgo-inline-root');"
"return root.shadowRoot.querySelector('.match-list').textContent;"
)
)
state_path = workspace / "state.txt"
deadline = time.time() + 10
while time.time() < deadline:
if state_path.read_text(encoding="utf-8").strip() == "pending":
break
time.sleep(0.2)
else:
raise RuntimeError("stub server never observed a pending approval state")
stage = "verify popup approval state"
target_tab_id = driver.execute_script(
"const raw = document.documentElement.getAttribute('data-keepassgo-validation-page-ready');"
"return raw ? JSON.parse(raw).tabId : null;"
)
if not target_tab_id:
raise RuntimeError("validation page did not expose a target tab id for popup state checks")
driver.switch_to.new_window("tab")
driver.get(f"chrome-extension://{extension_id}/popup.html?tabId={int(target_tab_id)}")
wait.until(lambda d: "Approval needed" in d.find_element(By.ID, "status-title").text)
stage = "approve fill and wait for completion"
state_path.write_text("approved", encoding="utf-8")
driver.switch_to.window(driver.window_handles[0])
wait.until(lambda d: d.find_element(By.ID, "username").get_attribute("value") == "dannyocean")
wait.until(lambda d: d.find_element(By.ID, "password").get_attribute("value") == "token-1")
return True
except Exception as exc: # noqa: BLE001
artifacts = workspace / "artifacts"
save_artifact(artifacts / "chromium-page.html", driver.page_source)
try:
driver.save_screenshot(str(artifacts / "chromium-page.png"))
except Exception:
pass
try:
save_artifact(artifacts / "chromium-browser.log", json.dumps(driver.get_log("browser"), indent=2))
except Exception:
pass
raise RuntimeError(f"chromium validation failed during {stage}: {type(exc).__name__}: {exc}") from exc
finally:
driver.quit()
def run_firefox_flow(workspace: Path, login_url: str):
from selenium import webdriver
from selenium.webdriver.firefox.options import Options
from selenium.webdriver.firefox.service import Service
from selenium.webdriver.common.by import By
from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC
xpi_path = workspace / "keepassgo-firefox.xpi"
options = Options()
options.binary_location = require_binary("firefox")
options.add_argument("-headless")
service = Service(find_geckodriver())
driver = webdriver.Firefox(service=service, options=options)
wait = WebDriverWait(driver, 25)
stage = "launch firefox"
try:
stage = "install temporary addon"
addon_id = driver.install_addon(str(xpi_path), temporary=True)
if addon_id != "browser@keepassgo.com":
raise RuntimeError(f"unexpected addon id {addon_id!r}")
stage = "open login page"
driver.get(login_url)
wait.until(EC.element_to_be_clickable((By.ID, "username"))).click()
stage = "wait for inline root"
wait.until(lambda d: d.execute_script("return !!document.querySelector('#keepassgo-inline-root')"))
stage = "wait for inline dock"
wait.until(
lambda d: d.execute_script(
"const root=document.querySelector('#keepassgo-inline-root');"
"const dock=root?.shadowRoot?.querySelector('.dock');"
"return !!(dock && getComputedStyle(dock).display !== 'none');"
)
)
stage = "open inline chooser"
driver.execute_script(
"document.querySelector('#keepassgo-inline-root').shadowRoot.querySelector('.trigger').click()"
)
stage = "wait for chooser matches"
wait.until(
lambda d: d.execute_script(
"const root=document.querySelector('#keepassgo-inline-root');"
"return root.shadowRoot.querySelectorAll('.match').length || 0;"
)
)
stage = "request browser fill"
driver.execute_script(
"document.querySelector('#keepassgo-inline-root').shadowRoot.querySelector('.match').click()"
)
stage = "wait for page approval prompt"
wait.until(
lambda d: "Approve or deny"
in d.execute_script(
"const root=document.querySelector('#keepassgo-inline-root');"
"return root.shadowRoot.querySelector('.match-list').textContent;"
)
)
state_path = workspace / "state.txt"
deadline = time.time() + 10
while time.time() < deadline:
if state_path.read_text(encoding="utf-8").strip() == "pending":
break
time.sleep(0.2)
else:
raise RuntimeError("stub server never observed a pending approval state")
stage = "approve fill and wait for completion"
state_path.write_text("approved", encoding="utf-8")
wait.until(lambda d: d.find_element(By.ID, "username").get_attribute("value") == "dannyocean")
wait.until(lambda d: d.find_element(By.ID, "password").get_attribute("value") == "token-1")
return True
except Exception as exc: # noqa: BLE001
artifacts = workspace / "artifacts"
save_artifact(artifacts / "firefox-page.html", driver.page_source)
try:
driver.save_screenshot(str(artifacts / "firefox-page.png"))
except Exception:
pass
raise RuntimeError(f"firefox validation failed during {stage}: {type(exc).__name__}: {exc}") from exc
finally:
driver.quit()
def main():
parser = argparse.ArgumentParser(description="Validate the browser-extension flow with isolated real-browser harnesses.")
parser.add_argument("--browser", choices=["firefox", "chromium", "both"], default="firefox")
parser.add_argument("--keep-workspace", action="store_true")
parser.add_argument("--workspace", help=argparse.SUPPRESS)
args = parser.parse_args()
workspace = Path(args.workspace) if args.workspace else Path(tempfile.mkdtemp(prefix="keepassgo-browser-validate."))
workspace.joinpath("home").mkdir(parents=True, exist_ok=True)
workspace.joinpath("web").mkdir(parents=True, exist_ok=True)
if not args.workspace:
workspace.joinpath("state.txt").write_text("idle", encoding="utf-8")
write_login_fixture(workspace / "web" / "login.html")
python_bin = ensure_selenium_venv(workspace / "venv")
if Path(sys.executable) != python_bin:
cmd = [str(python_bin), str(Path(__file__).resolve()), "--workspace", str(workspace), "--browser", args.browser]
if args.keep_workspace:
cmd.append("--keep-workspace")
raise SystemExit(subprocess.run(cmd, cwd=REPO_ROOT).returncode)
bridge_binary = workspace / "keepassgo-browser-bridge"
build_bridge(bridge_binary)
http_port = find_free_port()
grpc_port = find_free_port()
login_url = f"http://127.0.0.1:{http_port}/login.html"
grpc_addr = f"tcp://127.0.0.1:{grpc_port}"
ext_dir_chromium = xpi_path = None
if args.browser in {"chromium", "both"}:
ext_dir_chromium, key_b64 = prepare_chromium_extension(workspace, grpc_addr)
chromium_id, chromium_home = install_chromium_native_host(workspace, bridge_binary, key_b64)
if args.browser in {"firefox", "both"}:
xpi_path = prepare_firefox_extension(workspace, grpc_addr)
firefox_home = install_firefox_native_host(workspace, bridge_binary)
home_dir = workspace / "home"
env = os.environ.copy()
env["HOME"] = str(home_dir)
env["XDG_CONFIG_HOME"] = str(home_dir / ".config")
env["CHROME_CONFIG_HOME"] = str(home_dir / ".config")
os.environ["HOME"] = str(home_dir)
os.environ["XDG_CONFIG_HOME"] = env["XDG_CONFIG_HOME"]
os.environ["CHROME_CONFIG_HOME"] = env["CHROME_CONFIG_HOME"]
http_server = launch_process(
[sys.executable, "-m", "http.server", str(http_port)],
cwd=workspace / "web",
env=env,
log_path=workspace / "http.log",
)
stub_server = launch_process(
[
"go",
"run",
str(STUB_SERVER),
"--listen",
f"127.0.0.1:{grpc_port}",
"--state",
str(workspace / "state.txt"),
"--page-url",
login_url,
],
cwd=REPO_ROOT,
env=env,
log_path=workspace / "stub.log",
)
try:
wait_for_http(login_url)
wait_for_tcp("127.0.0.1", grpc_port, process=stub_server, log_path=workspace / "stub.log", name="validation gRPC server")
browser_results = []
if args.browser in {"firefox", "both"}:
browser_results.append("firefox")
run_firefox_flow(workspace, login_url)
workspace.joinpath("state.txt").write_text("idle", encoding="utf-8")
if args.browser in {"chromium", "both"}:
browser_results.append("chromium")
run_chromium_flow(workspace, chromium_id, login_url)
print(f"browser validation passed for {', '.join(browser_results)}; workspace: {workspace}", flush=True)
if not args.keep_workspace:
shutil.rmtree(workspace, ignore_errors=True)
except Exception as exc: # noqa: BLE001
print(f"{exc}\nworkspace preserved at {workspace}", file=sys.stderr)
sys.exit(1)
finally:
for process in [stub_server, http_server]:
if process.poll() is None:
process.terminate()
try:
process.wait(timeout=3)
except subprocess.TimeoutExpired:
process.kill()
if __name__ == "__main__":
main()