Compare commits

..

66 Commits

Author SHA1 Message Date
Joe Julian 0538cd2feb Add browser match controls
ci / lint-test (pull_request) Successful in 7m19s
ci / build (pull_request) Successful in 7m13s
2026-04-23 23:10:05 -07:00
Joe Julian 2269944702 Add Firefox extension web-ext targets
ci / lint-test (push) Successful in 6m5s
ci / build (push) Successful in 6m42s
2026-04-23 21:51:41 -07:00
Joe Julian 2ccd5bc337 Require cleanup before starting a new feature 2026-04-23 21:50:02 -07:00
Joe Julian 9a9d9e7447 Add Firefox extension icons and gap review
ci / lint-test (push) Successful in 4m8s
ci / build (push) Failing after 5m40s
2026-04-23 21:42:52 -07:00
Joe Julian 2c065a04a4 Narrow Android autofill chooser results 2026-04-23 21:02:34 -07:00
Joe Julian f82ddf7435 Add browser save and update workflow 2026-04-23 21:00:29 -07:00
Joe Julian 14c9bc72f6 Support Android share-driven credential lookup 2026-04-23 20:51:39 -07:00
Joe Julian 515eb730f0 Broaden Android accessibility autofill fallback 2026-04-23 20:44:32 -07:00
Joe Julian d60a8d2fbf Improve locked vault browser workflow 2026-04-23 20:37:49 -07:00
Joe Julian 4afbc3c933 Add browser search and richer URL matching 2026-04-23 20:36:17 -07:00
Joe Julian c7d35927f3 Add GUI test plan
ci / lint-test (push) Successful in 3m55s
ci / build (push) Successful in 6m14s
2026-04-19 22:01:33 -07:00
joejulian a6340f5c9e Merge pull request 'Fix CI APK JDK selection' (#8) from bugfix/ci-apk-java-selection into main
ci / lint-test (push) Successful in 3m41s
ci / build (push) Successful in 6m11s
2026-04-20 04:30:31 +00:00
Joe Julian 0adf1b8826 Run CI for pull requests
ci / lint-test (pull_request) Successful in 5m56s
ci / build (pull_request) Successful in 5m39s
2026-04-19 21:17:49 -07:00
Joe Julian c517794182 Provision Java 25 directly in CI 2026-04-19 20:37:46 -07:00
Joe Julian b511ab4dc0 Fix CI APK JDK selection 2026-04-19 20:27:14 -07:00
joejulian 7b06388712 Merge pull request 'Add Android autofill chooser and learned app binding' (#7) from feature/android-autofill-chooser into main
ci / lint-test (push) Successful in 3m14s
ci / build (push) Failing after 3m4s
2026-04-20 00:02:55 +00:00
Joe Julian fea1a75cdf Keep release signing secrets out of APK build logs 2026-04-18 22:16:25 -07:00
Joe Julian 0dfaeef7bf Require dedicated release signing for APK builds 2026-04-18 22:00:56 -07:00
Joe Julian 92a7853258 Harden Android shared-vault import intents 2026-04-16 21:33:40 -07:00
Joe Julian 14f22b4ebf Fix Android packaging asset discovery 2026-04-16 21:08:31 -07:00
Joe Julian 4d972bfab0 Simplify Android packaging around gogio 2026-04-16 20:47:51 -07:00
Joe Julian e005a42a3f Point gio-cmd dependency at patched fork 2026-04-16 20:29:27 -07:00
Joe Julian 58d6d510f9 Point gio dependency at patched fork 2026-04-16 18:16:40 -07:00
Joe Julian bb114cee16 Patch gogio at build time for Android snippets 2026-04-13 22:03:00 -07:00
Joe Julian 2431467aa7 Add Android autofill chooser and app binding 2026-04-13 22:02:51 -07:00
Joe Julian c302c29d4f Add autofill app binding helpers 2026-04-13 17:30:33 -07:00
Joe Julian 361d6dbe03 Add failing Android autofill binding tests 2026-04-13 17:26:51 -07:00
joejulian a41e842a65 Merge pull request 'Tighten browser inline overlay qualification' (#6) from bugfix/browser-inline-overlay into main
ci / lint-test (push) Successful in 3m29s
ci / build (push) Successful in 6m6s
2026-04-14 00:24:37 +00:00
Joe Julian 54398837e6 Tighten browser inline overlay qualification 2026-04-13 17:23:41 -07:00
joejulian 989b41735f Merge pull request 'Normalize vault storage root views' (#5) from bugfix/vault-root-view into main
ci / lint-test (push) Successful in 3m25s
ci / build (push) Successful in 6m21s
2026-04-13 16:31:56 +00:00
Joe Julian a88b8a824b Add explicit templates vault view 2026-04-13 08:50:33 -07:00
Joe Julian eccfb886ee Normalize vault storage root on open and create 2026-04-13 07:29:51 -07:00
Joe Julian 6790399e24 Hide physical keepass paths in token and approval UX 2026-04-13 07:18:33 -07:00
Joe Julian 9882d3fc04 Authorize logical root API paths against vault storage 2026-04-13 07:15:16 -07:00
Joe Julian 59cd01f8e7 Use vault views for entry and recycle-bin state 2026-04-13 07:12:32 -07:00
Joe Julian ea30775eb7 Add explicit vault view factories 2026-04-13 07:02:44 -07:00
Joe Julian 0ce25a9712 Add failing vault view behavior tests 2026-04-13 07:00:51 -07:00
Joe Julian 32e6fc6c90 Break vault root bug into commit-sized todo items 2026-04-13 06:59:55 -07:00
Joe Julian e8a48fb7aa Track vault root view bugfix 2026-04-13 06:58:11 -07:00
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
87 changed files with 12397 additions and 1250 deletions
+9 -3
View File
@@ -45,8 +45,8 @@ Use this skill together with the installed `android-emulator-debug` skill. That
## Build Workflow
1. Verify the JDK/SDK paths match the known working environment.
2. Build with `make apk`.
3. If `make apk` fails, inspect the effective `JAVA_HOME`, `ANDROID_SDK_ROOT`, and `ANDROID_NDK_ROOT` before changing code.
2. Build with `make apk` for debug validation, or `make apk-release` when validating production signing behavior.
3. If the build fails, inspect the effective `JAVA_HOME`, `ANDROID_SDK_ROOT`, and `ANDROID_NDK_ROOT` before changing code.
4. If the problem is Android-only, avoid desktop-only conclusions from `go test ./...`.
Typical local build:
@@ -55,6 +55,12 @@ Typical local build:
JAVA_HOME=/usr/lib/jvm/java-25-openjdk make apk
```
Typical local release build:
```sh
JAVA_HOME=/usr/lib/jvm/java-25-openjdk make apk-release
```
## Emulator Workflow
1. Reuse an existing emulator session if one is already running.
@@ -79,7 +85,7 @@ adb shell dumpsys window | rg 'mCurrentFocus|mFocusedApp'
## Validation Checklist
- APK builds successfully with `make apk`.
- APK builds successfully with the intended target: `make apk` for debug validation or `make apk-release` for release-signing validation.
- App launches to `org.julianfamily.keepassgo/org.gioui.GioActivity`.
- Screenshot shows the expected screen, not just a black frame.
- `logcat` shows no app crash or Android runtime fatal error.
+7 -1
View File
@@ -52,11 +52,17 @@ The installed package version must correspond to the committed source, not a dir
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
JAVA_HOME=/usr/lib/jvm/java-25-openjdk make apk-release
```
If that JDK is unavailable on the current host, use the working replacement already established for the machine and say so in the closeout.
- `ship it` must use the dedicated release keystore flow, not Gio's implicit debug or temporary signing path.
- The default local release-signing paths are:
`~/.config/keepassgo/android-release.keystore`
`~/.config/keepassgo/android-release.pass`
- If those files are unavailable, stop and fix signing instead of shipping a differently signed APK.
### 4. Zip The APK
- Create the ZIP under the globally required temporary secret-safe directory.
+61 -5
View File
@@ -8,6 +8,9 @@ on:
- "v*"
- "release-*"
- "[0-9]+.[0-9]+.[0-9]+*"
pull_request:
branches:
- main
permissions:
contents: write
@@ -16,7 +19,6 @@ env:
GO_VERSION: "1.26.1"
ANDROID_SDK_ROOT: /opt/android-sdk
ANDROID_NDK_ROOT: /opt/android-sdk/ndk
JAVA_HOME: /usr/lib/jvm/java-21-openjdk-amd64
DIST_DIR: dist
jobs:
@@ -31,6 +33,17 @@ jobs:
with:
go-version: ${{ env.GO_VERSION }}
- name: Setup Java
uses: actions/setup-java@v4
with:
distribution: temurin
java-version: "25"
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: "22"
- name: Install native build dependencies
shell: bash
run: |
@@ -39,6 +52,7 @@ jobs:
apt-get update
apt-get install -y --no-install-recommends \
zsh \
python3 \
pkg-config \
libx11-dev \
libx11-xcb-dev \
@@ -50,6 +64,12 @@ jobs:
libxcursor-dev \
libxfixes-dev
- name: Install web-ext
shell: bash
run: |
set -euo pipefail
npm install -g web-ext
- name: Lint
shell: bash
run: |
@@ -66,6 +86,12 @@ jobs:
trap 'rm -rf -- "$state_dir"' EXIT
KEEPASSGO_STATE_DIR="$state_dir" go test -tags nox11,nowayland,novulkan ./...
- name: Firefox extension lint
shell: bash
run: |
set -euo pipefail
make browser-extension-firefox-lint
build:
needs: lint-test
runs-on: keepassgo-android
@@ -78,6 +104,17 @@ jobs:
with:
go-version: ${{ env.GO_VERSION }}
- name: Setup Java
uses: actions/setup-java@v4
with:
distribution: temurin
java-version: "25"
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: "22"
- name: Install native build dependencies
shell: bash
run: |
@@ -86,6 +123,7 @@ jobs:
apt-get update
apt-get install -y --no-install-recommends \
zsh \
python3 \
pkg-config \
libx11-dev \
libx11-xcb-dev \
@@ -97,6 +135,12 @@ jobs:
libxcursor-dev \
libxfixes-dev
- name: Install web-ext
shell: bash
run: |
set -euo pipefail
npm install -g web-ext
- name: Prepare dist directory
shell: bash
run: |
@@ -135,13 +179,23 @@ jobs:
shell: bash
run: |
set -euo pipefail
signkey_path="$(mktemp)"
trap 'rm -f -- "$signkey_path"' EXIT
mkdir -p build/ci-signing
signkey_path="$(pwd)/build/ci-signing/android-release.keystore"
signpass_path="$(pwd)/build/ci-signing/android-release.pass"
trap 'rm -f -- "$signkey_path" "$signpass_path"' EXIT
printf '%s' '${{ secrets.APK_SIGNKEY_B64 }}' | base64 -d > "$signkey_path"
printf '%s' '${{ secrets.APK_SIGNPASS }}' > "$signpass_path"
export APP_VERSION="$(git describe --tags --always --dirty)"
make apk SIGNKEY="$signkey_path" SIGNPASS='${{ secrets.APK_SIGNPASS }}'
make apk-release RELEASE_SIGNKEY="$signkey_path" RELEASE_SIGNPASS_FILE="$signpass_path"
cp build/keepassgo.apk "${DIST_DIR}/keepassgo.apk"
- name: Build Firefox extension
shell: bash
run: |
set -euo pipefail
make browser-extension-firefox-build
cp build/browser-extension/*.zip "${DIST_DIR}/"
- name: Upload CI artifacts
uses: christopherhx/gitea-upload-artifact@v4
env:
@@ -154,6 +208,7 @@ jobs:
dist/keepassgo-windows-amd64.exe
dist/keepassgo-windows-arm64.exe
dist/keepassgo.apk
dist/*.zip
retention-days: 30
- name: Publish release artifacts
@@ -176,4 +231,5 @@ jobs:
"${DIST_DIR}/keepassgo-linux-amd64" \
"${DIST_DIR}/keepassgo-windows-amd64.exe" \
"${DIST_DIR}/keepassgo-windows-arm64.exe" \
"${DIST_DIR}/keepassgo.apk"
"${DIST_DIR}/keepassgo.apk" \
"${DIST_DIR}"/*.zip
+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
+6 -1
View File
@@ -135,6 +135,11 @@ These features are product requirements, not “nice to have” ideas.
## Delivery Discipline
- Treat bug fixes as the highest-priority items in `TODO.md`.
- Do not start a new feature while unrelated tracked or untracked local changes
remain in the repo.
- If previous work leaves unrelated uncommitted changes behind, stop and ask
the user before continuing with the next feature.
- Do not treat this product as complete until the stated requirements in this file are actually satisfied.
- Do not stop at a “good checkpoint” or “meaningful tranche” when required product capabilities are still missing.
- Continue iterating in test-first slices:
@@ -176,7 +181,7 @@ These features are product requirements, not “nice to have” ideas.
local `ANDROID_NDK_ROOT=/opt/android-ndk`,
CI `ANDROID_NDK_ROOT=/opt/android-sdk/ndk`,
local `JAVA_HOME=/usr/lib/jvm/java-25-openjdk`,
CI `JAVA_HOME=/usr/lib/jvm/java-21-openjdk-amd64`.
CI `JAVA_HOME` provided by `actions/setup-java` with Temurin 25.
- Remember the known Android runtime regression:
`gioui.org v0.9.0` produced a black screen on the `KeepassGoAPI35` emulator, while `gioui.org v0.8.0` rendered correctly. Treat Gio upgrades on Android as regression-sensitive and verify them on-device or in the emulator.
- When validating an APK in the emulator, prefer the known KeePassGO setup:
+36 -6
View File
@@ -6,17 +6,43 @@ Build the APK with:
make apk
```
Build the release-signed APK with:
```sh
make apk-release
```
`make apk` uses a local Java 25 install when `JAVA_HOME` points to one.
If the host does not have a working Java 25 install, it falls back to the
repo-managed Docker image in `packaging/docker/android-apk/`, which also builds
with Java 25. CI provisions Java 25 directly in the build job so release builds
use that same local path instead of nested Docker.
`make apk` remains a developer build path and may use Gio's default debug or
ephemeral signing behavior if no explicit signing key is provided.
`make apk-release` is the production-signing path and fails unless a dedicated
release keystore and password file are present.
Environment:
- `ANDROID_SDK_ROOT` defaults to `/opt/android-sdk`.
- `ANDROID_NDK_ROOT` defaults to `/opt/android-ndk`.
- `JAVA_HOME` defaults to `/usr/lib/jvm/java-25-openjdk`.
- `APK_BUILD_IMAGE` overrides the Docker image name used by `make apk-container`.
- `APP_ID` overrides the Android application id.
- `APP_VERSION` overrides the version shown inside KeePassGO itself.
- `APK_OUT` overrides the output path.
- `APK_VERSION` overrides the packaged app version.
- `ANDROID_MIN_SDK` overrides the minimum supported Android SDK.
- `ANDROID_TARGET_SDK` overrides the target Android SDK.
- `SIGNPASS_FILE` provides the signing password by file instead of a command-line argument.
- `RELEASE_SIGNKEY` overrides the release keystore path used by `make apk-release`.
- `RELEASE_SIGNPASS_FILE` overrides the password file path used by `make apk-release`.
Default release-signing paths:
- `~/.config/keepassgo/android-release.keystore`
- `~/.config/keepassgo/android-release.pass`
Installed machine prerequisites expected by this repo:
@@ -24,24 +50,28 @@ Installed machine prerequisites expected by this repo:
- `android-sdk-build-tools`
- `android-platform-35`
- `android-sdk-platform-tools`
- a working JDK install
- a working Java 25 JDK install for `make apk-local`, or Docker for `make apk`
The repo tracks `gogio` as a Go tool, so the build runs through:
The repo tracks `gogio` as a Go tool, and the local build runs through:
```sh
go tool gogio -target android ./cmd/keepassgo ...
```
The release target wraps `make apk` and injects explicit signing credentials so
local release builds and CI use the same stable key without echoing the release
password in build logs.
The Android build uses the branded icon asset at:
- `internal/assets/keepassgo-icon.png`
Note:
- Gio's Android doc currently references Java 1.8, but the Android build-tools
installed on this machine (`d8` from build-tools 37) do not run on Java 8.
- In this environment, KeePassGO's APK build requires a newer JDK runtime on
`PATH`, which is why the repo defaults `JAVA_HOME` to `/usr/lib/jvm/java-25-openjdk`.
- KeePassGO's documented Android build uses Java 25 locally and in CI.
- If that host setup is unavailable, `make apk` falls back to the Docker image
so the build still runs under Java 25 instead of encoding a newer host JDK as
a requirement.
- Android runtime testing on the `KeepassGoAPI35` emulator showed a black-screen
regression with `gioui.org v0.9.0` while a stock Gio example and KeePassGO both
rendered correctly with `gioui.org v0.8.0` on the same emulator and SDK/JDK
+93 -4
View File
@@ -2,6 +2,7 @@ ANDROID_SDK_ROOT ?= /opt/android-sdk
ANDROID_NDK_ROOT ?= /opt/android-ndk
JAVA_HOME ?= /usr/lib/jvm/java-25-openjdk
PATH := $(JAVA_HOME)/bin:$(ANDROID_SDK_ROOT)/cmdline-tools/latest/bin:$(ANDROID_SDK_ROOT)/platform-tools:$(PATH)
APK_BUILD_IMAGE ?= keepassgo/android-apk-build:java25
APP_ID ?= org.julianfamily.keepassgo
APK_OUT ?= build/keepassgo.apk
APK_VERSION ?= 0.1.0.1
@@ -11,11 +12,17 @@ ANDROID_MIN_SDK ?= 28
ANDROID_TARGET_SDK ?= 35
SIGNKEY ?=
SIGNPASS ?=
SIGNPASS_FILE ?=
RELEASE_SIGNKEY ?= $(HOME)/.config/keepassgo/android-release.keystore
RELEASE_SIGNPASS_FILE ?= $(HOME)/.config/keepassgo/android-release.pass
ARCH_PKG_DIR ?= packaging/archlinux/keepassgo-git
ARCH_PKG_TMPL ?= $(ARCH_PKG_DIR)/PKGBUILD.tmpl
ARCH_PKGBUILD ?= $(ARCH_PKG_DIR)/PKGBUILD
ARCH_PKGVER ?= $(shell printf 'r%s.%s' "$$(git rev-list --count HEAD 2>/dev/null || echo 0)" "$$(git rev-parse --short HEAD 2>/dev/null || echo dev)")
ARCH_REPO_DIR ?= $(CURDIR)
WEB_EXT ?= web-ext
FIREFOX_EXTENSION_DIR ?= build/firefox-extension
FIREFOX_EXTENSION_ARTIFACT_DIR ?= build/browser-extension
GOGIO_SIGN_FLAGS :=
ifneq ($(strip $(SIGNKEY)),)
@@ -25,8 +32,31 @@ ifneq ($(strip $(SIGNPASS)),)
GOGIO_SIGN_FLAGS += -signpass $(SIGNPASS)
endif
.PHONY: apk archlinux-pkgbuild
apk: android/keepassgo-android.jar
CONTAINER_SIGNKEY_MOUNT :=
CONTAINER_SIGNPASSFILE_MOUNT :=
CONTAINER_SIGN_ARGS :=
ifneq ($(strip $(SIGNKEY)),)
CONTAINER_SIGNKEY_MOUNT += -v "$(dir $(abspath $(SIGNKEY))):$(dir $(abspath $(SIGNKEY))):ro"
CONTAINER_SIGN_ARGS += SIGNKEY="$(abspath $(SIGNKEY))"
endif
ifneq ($(strip $(SIGNPASS)),)
CONTAINER_SIGN_ARGS += SIGNPASS="$(SIGNPASS)"
endif
ifneq ($(strip $(SIGNPASS_FILE)),)
CONTAINER_SIGNPASSFILE_MOUNT += -v "$(dir $(abspath $(SIGNPASS_FILE))):$(dir $(abspath $(SIGNPASS_FILE))):ro"
CONTAINER_SIGN_ARGS += SIGNPASS_FILE="$(abspath $(SIGNPASS_FILE))"
endif
.PHONY: apk apk-local apk-release apk-container apk-container-image archlinux-pkgbuild browser-bridge browser-extension-validate browser-extension-firefox-dir browser-extension-firefox-lint browser-extension-firefox-build browser-extension-firefox-run browser-extension-firefox-sign
apk:
@if [ -x "$(JAVA_HOME)/bin/java" ] && "$(JAVA_HOME)/bin/java" -version 2>&1 | grep -q 'version "25'; then \
$(MAKE) apk-local JAVA_HOME="$(JAVA_HOME)"; \
else \
echo "Using Dockerized Java 25 Android build because JAVA_HOME is not a working Java 25 install."; \
$(MAKE) apk-container; \
fi
apk-local: 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; }
@test -d "$(ANDROID_NDK_ROOT)" || { echo "ANDROID_NDK_ROOT must point to an Android NDK install"; exit 1; }
@@ -34,6 +64,12 @@ apk: android/keepassgo-android.jar
@test -d "$(ANDROID_SDK_ROOT)/platforms/android-$(ANDROID_TARGET_SDK)" || { echo "Android platform android-$(ANDROID_TARGET_SDK) is missing"; exit 1; }
@test -d "$(ANDROID_SDK_ROOT)/build-tools" || { echo "Android build-tools are missing"; exit 1; }
@mkdir -p "$(dir $(APK_OUT))"
@set -eu; \
if [ -n "$(SIGNPASS_FILE)" ]; then \
test -f "$(SIGNPASS_FILE)" || { echo "SIGNPASS_FILE does not exist: $(SIGNPASS_FILE)"; exit 1; }; \
export GOGIO_SIGNPASS="$$(tr -d '\r\n' < "$(SIGNPASS_FILE)")"; \
test -n "$$GOGIO_SIGNPASS" || { echo "SIGNPASS_FILE is empty: $(SIGNPASS_FILE)"; exit 1; }; \
fi; \
ANDROID_HOME="$(ANDROID_SDK_ROOT)" \
ANDROID_SDK_ROOT="$(ANDROID_SDK_ROOT)" \
ANDROID_NDK_ROOT="$(ANDROID_NDK_ROOT)" \
@@ -50,12 +86,39 @@ apk: android/keepassgo-android.jar
-icon internal/assets/keepassgo-icon.png \
./cmd/keepassgo
apk-release:
@test -f "$(RELEASE_SIGNKEY)" || { echo "Release signing key not found at $(RELEASE_SIGNKEY)"; exit 1; }
@test -f "$(RELEASE_SIGNPASS_FILE)" || { echo "Release signing password file not found at $(RELEASE_SIGNPASS_FILE)"; exit 1; }
@$(MAKE) apk SIGNKEY="$(abspath $(RELEASE_SIGNKEY))" SIGNPASS_FILE="$(abspath $(RELEASE_SIGNPASS_FILE))" JAVA_HOME="$(JAVA_HOME)"
apk-container: apk-container-image
@command -v docker >/dev/null 2>&1 || { echo "docker is required for apk-container"; exit 1; }
@test -d "$(ANDROID_SDK_ROOT)" || { echo "ANDROID_SDK_ROOT must point to an Android SDK install"; exit 1; }
@test -d "$(ANDROID_NDK_ROOT)" || { echo "ANDROID_NDK_ROOT must point to an Android NDK install"; exit 1; }
docker run --rm \
-u "$$(id -u):$$(id -g)" \
-v "$(CURDIR):$(CURDIR)" \
-w "$(CURDIR)" \
-v "$(ANDROID_SDK_ROOT):$(ANDROID_SDK_ROOT)" \
-v "$(ANDROID_NDK_ROOT):$(ANDROID_NDK_ROOT)" \
$(CONTAINER_SIGNKEY_MOUNT) \
$(CONTAINER_SIGNPASSFILE_MOUNT) \
-e ANDROID_SDK_ROOT="$(ANDROID_SDK_ROOT)" \
-e ANDROID_NDK_ROOT="$(ANDROID_NDK_ROOT)" \
-e JAVA_HOME=/opt/java/openjdk \
$(APK_BUILD_IMAGE) \
make apk-local JAVA_HOME=/opt/java/openjdk $(CONTAINER_SIGN_ARGS)
apk-container-image:
@command -v docker >/dev/null 2>&1 || { echo "docker is required for apk-container-image"; exit 1; }
docker build --load -t $(APK_BUILD_IMAGE) packaging/docker/android-apk
android/keepassgo-android.jar: $(shell find androidsrc -type f | sort)
@test -x "$(JAVA_HOME)/bin/javac" || { echo "JAVA_HOME must point to a working JDK install"; exit 1; }
@test -f "$(ANDROID_SDK_ROOT)/platforms/android-$(ANDROID_TARGET_SDK)/android.jar" || { echo "Android platform android-$(ANDROID_TARGET_SDK) is missing"; exit 1; }
@mkdir -p android
@zsh -lc 'tmpdir=$$(mktemp -d); \
trap '\''python3 -c "import shutil,sys; shutil.rmtree(sys.argv[1], ignore_errors=True)" "$$tmpdir"'\'' EXIT; \
@sh -ec 'tmpdir=$$(mktemp -d); \
trap "rm -rf $$tmpdir" EXIT; \
"$(JAVA_HOME)/bin/javac" \
-classpath "$(ANDROID_SDK_ROOT)/platforms/android-$(ANDROID_TARGET_SDK)/android.jar" \
-d "$$tmpdir" \
@@ -68,3 +131,29 @@ 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-firefox-dir:
@mkdir -p "$(dir $(FIREFOX_EXTENSION_DIR))"
@python3 scripts/prepare_firefox_extension.py "$(FIREFOX_EXTENSION_DIR)"
browser-extension-firefox-lint: browser-extension-firefox-dir
$(WEB_EXT) lint --source-dir "$(FIREFOX_EXTENSION_DIR)"
browser-extension-firefox-build: browser-extension-firefox-dir
@mkdir -p "$(FIREFOX_EXTENSION_ARTIFACT_DIR)"
$(WEB_EXT) build --source-dir "$(FIREFOX_EXTENSION_DIR)" --artifacts-dir "$(FIREFOX_EXTENSION_ARTIFACT_DIR)"
browser-extension-firefox-run: browser-extension-firefox-dir
$(WEB_EXT) run --source-dir "$(FIREFOX_EXTENSION_DIR)"
browser-extension-firefox-sign: browser-extension-firefox-dir
$(WEB_EXT) sign --source-dir "$(FIREFOX_EXTENSION_DIR)"
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),)
+26 -2
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
@@ -89,12 +90,35 @@ go get -tool gioui.org/cmd/gogio@latest
Package:
```bash
go tool gogio -target android -icon internal/assets/keepassgo-icon.png ./cmd/keepassgo
make apk
```
You will need the Android SDK and NDK installed and configured for real device or release packaging.
`make apk` prefers a local Java 25 install at `JAVA_HOME`. If that is not
available, it falls back to the repo-managed Docker build image, which also
uses Java 25. CI provisions Java 25 directly in the build job so release
packaging follows that same local path. You still need the Android SDK and NDK
installed and configured for real device or release packaging.
Release package:
```bash
make apk-release
```
`make apk-release` is the production-signing path. It requires a dedicated
release keystore at `~/.config/keepassgo/android-release.keystore` and a
password file at `~/.config/keepassgo/android-release.pass`, unless you
override `RELEASE_SIGNKEY` and `RELEASE_SIGNPASS_FILE`.
## Automation
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).
+99 -242
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:
@@ -125,6 +130,98 @@ These are important, but they should likely move behind a dedicated settings gea
- Accessibility preferences:
future display-density, contrast, reduced-motion, or keyboard-focus tuning should live under settings.
## Upstream Gap Review
This section tracks explicit feature gaps against the source-level behavior of:
- KeePass 2.57.1
- KeePassHttp
- Keepass2Android
These are not speculative enhancements. They are parity gaps relative to the
stated product requirement to cover the practical feature surface of those
upstream tools where it fits KeePassGO's security model.
### Stage 1
- Android autofill parity/completeness:
close the remaining gaps in Android autofill behavior, including broader
page/app detection coverage, stronger approval and visibility UX, and more
reliable fill behavior across real-world apps and browsers.
- Android fallback fill workflows:
provide a non-autofill fallback comparable in usefulness to KP2A's keyboard
and share-driven workflows for apps and browsers that do not cooperate with
platform autofill.
- Browser extension save/update:
add the browser-side save/update-credential workflow after successful form
submission, not only lookup and fill.
- Search and matching controls:
browser/API result behavior does not yet expose KeePassHttp-style controls
such as best-match-only, scheme matching, and sort preferences as a finished
product surface.
- Unlock-request workflow:
KeePassHttp has an explicit locked-database browser flow; KeePassGO still
needs a polished browser-visible locked/unlock request experience.
- Android share/intents:
browser/app share-driven lookup and open flows comparable to KP2A are not
implemented as a full user workflow.
### Stage 2
- OTP/TOTP:
implement real OTP/TOTP support, including storage conventions compatible
with common KeePass ecosystems and usable display/copy/fill workflows.
- TOTP product surface:
KP2A exposes TOTP directly in entry and list UX; KeePassGO does not.
- Browser-returned field breadth:
KeePassHttp can return string fields for browser consumers; KeePassGO does
not yet have a finished policy and browser UX for rich field return.
- Placeholder and field-reference parity:
KeePass-style placeholder expansion, field references, and related command
and URL override behavior are not implemented as a product surface.
- Offline/work-offline flow:
KP2A has explicit offline/cache-oriented remote-file workflows that are more
mature than KeePassGO's current user-facing remote behavior.
### Stage 3
- Desktop automation:
implement desktop login automation comparable in practical capability to
KeePass auto-type, or replace it with a demonstrably superior workflow that
covers global invocation, selected-entry invocation, window targeting, and
field sequencing.
- Trigger system:
KeePass-style event/condition/action triggers are not implemented.
- Import/export breadth:
KDBX load/save exists, but KeePass-style breadth for CSV/XML/HTML and other
exchange formats is still missing.
- Multi-database lookup:
KeePassHttp can search across all opened databases when configured; KeePassGO
does not yet have an equivalent multi-vault lookup model.
- Remote backend breadth:
KP2A supports far more remote/file backends than KeePassGO currently does;
KeePassGO is still effectively WebDAV-first.
- Plugin/extensibility model:
KeePassGO has integrations, but not a first-class plugin model comparable to
KeePass.
- Plugin ecosystem replacements:
QR transfer, keyboard transport, and related mobile integration equivalents
do not exist in KeePassGO.
- Emergency and recovery utilities:
emergency-sheet/key-file-backup style flows are not implemented.
### Immediate Product Questions
- Desktop automation:
decide whether KeePassGO will implement true auto-type, or a different
security model that still satisfies the practical workflows KeePass users
expect.
- OTP model:
decide the canonical KeePassGO representation for TOTP/HOTP so desktop,
Android, gRPC, and browser workflows can all target the same semantics.
- Remote breadth:
decide which non-WebDAV backends are in product scope after WebDAV.
### Exit Criteria
- The main workflow screens prioritize opening, browsing, copying, editing, and synchronizing credentials.
@@ -132,195 +229,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 +297,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:
@@ -466,7 +361,7 @@ Exit criteria:
- Tests cover clear/reset transitions.
- `go test ./...` passes.
### Segment 10: Template CRUD UI
### Segment 10 (stage 3): Template CRUD UI
Scope:
- Create template.
@@ -607,33 +502,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 +519,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 +532,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.
+19
View File
@@ -22,6 +22,10 @@
android:name="android.accessibilityservice"
android:resource="@xml/keepassgo_accessibility_service" />
</service>
<activity
android:name="org.julianfamily.keepassgo.KeePassGOAutofillPickerActivity"
android:exported="false"
android:label="Search KeePassGO" />
<provider
android:name="org.julianfamily.keepassgo.SharedVaultProvider"
android:authorities="org.julianfamily.keepassgo.share"
@@ -31,6 +35,11 @@
android:name="org.julianfamily.keepassgo.SharedVaultImportActivity"
android:exported="true"
android:theme="@android:style/Theme.Translucent.NoTitleBar">
<intent-filter>
<action android:name="android.intent.action.SEND" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="text/plain" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.SEND" />
<category android:name="android.intent.category.DEFAULT" />
@@ -41,6 +50,16 @@
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="http" />
<data android:scheme="https" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="application/octet-stream" />
<data android:mimeType="application/x-keepass2" />
<data android:mimeType="application/vnd.keepass" />
<data android:scheme="content" android:pathPattern=".*\\.kdbx" />
<data android:scheme="file" android:pathPattern=".*\\.kdbx" />
</intent-filter>
@@ -0,0 +1,116 @@
package org.julianfamily.keepassgo;
import android.content.Context;
import android.util.JsonReader;
import android.util.JsonWriter;
import android.util.Log;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStreamReader;
import java.io.OutputStreamWriter;
import java.nio.charset.StandardCharsets;
import java.time.Instant;
import java.util.LinkedHashMap;
import java.util.Map;
final class AutofillBindingStore {
private static final String TAG = "KeePassGOAutofill";
private AutofillBindingStore() {
}
static String entryIDForTarget(Context context, String rawTarget) {
Bindings bindings = read(context);
return bindings.apps.getOrDefault(normalize(rawTarget), "");
}
static void rememberBinding(Context context, String rawTarget, String entryID) {
String target = normalize(rawTarget);
if (target.isEmpty() || entryID == null || entryID.trim().isEmpty()) {
return;
}
Bindings bindings = read(context);
bindings.updatedAt = Instant.now().toString();
bindings.apps.put(target, entryID.trim());
write(context, bindings);
}
private static Bindings read(Context context) {
File path = path(context);
if (!path.isFile()) {
return new Bindings();
}
try (JsonReader reader = new JsonReader(new InputStreamReader(new FileInputStream(path), StandardCharsets.UTF_8))) {
Bindings bindings = new Bindings();
reader.beginObject();
while (reader.hasNext()) {
String name = reader.nextName();
if ("updatedAt".equals(name)) {
bindings.updatedAt = nextString(reader);
continue;
}
if ("apps".equals(name)) {
reader.beginObject();
while (reader.hasNext()) {
bindings.apps.put(normalize(reader.nextName()), nextString(reader));
}
reader.endObject();
continue;
}
reader.skipValue();
}
reader.endObject();
return bindings;
} catch (IOException err) {
Log.e(TAG, "failed to read autofill bindings", err);
return new Bindings();
}
}
private static void write(Context context, Bindings bindings) {
File path = path(context);
File parent = path.getParentFile();
if (parent != null && !parent.exists() && !parent.mkdirs()) {
Log.e(TAG, "failed to create autofill binding directory " + parent.getAbsolutePath());
return;
}
try (JsonWriter writer = new JsonWriter(new OutputStreamWriter(new FileOutputStream(path, false), StandardCharsets.UTF_8))) {
writer.setIndent(" ");
writer.beginObject();
writer.name("updatedAt").value(bindings.updatedAt);
writer.name("apps");
writer.beginObject();
for (Map.Entry<String, String> entry : bindings.apps.entrySet()) {
writer.name(entry.getKey()).value(entry.getValue());
}
writer.endObject();
writer.endObject();
} catch (IOException err) {
Log.e(TAG, "failed to write autofill bindings", err);
}
}
private static String nextString(JsonReader reader) throws IOException {
if (reader.peek() == android.util.JsonToken.NULL) {
reader.nextNull();
return "";
}
return reader.nextString();
}
private static File path(Context context) {
return new File(new File(context.getFilesDir(), "keepassgo"), "autofill-bindings.json");
}
private static String normalize(String rawTarget) {
return rawTarget == null ? "" : rawTarget.trim();
}
private static final class Bindings {
String updatedAt = "";
final Map<String, String> apps = new LinkedHashMap<>();
}
}
@@ -10,6 +10,7 @@ import java.io.IOException;
import java.io.InputStreamReader;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import java.util.Locale;
@@ -20,41 +21,79 @@ final class AutofillCacheStore {
}
static Entry findBestMatch(Context context, String webDomain) {
File cacheFile = findCacheFile(context);
if (cacheFile == null) {
Log.i(TAG, "autofill cache file not found");
return null;
}
List<Entry> entries;
try {
entries = readEntries(cacheFile);
} catch (IOException err) {
Log.e(TAG, "failed to read autofill cache", err);
return null;
}
List<Entry> entries = readEntries(context);
if (entries.isEmpty()) {
return null;
}
NormalizedTarget target = normalizeURL(webDomain);
if (target.host.isEmpty()) {
return fromMatcherEntry(AutofillTargetMatcher.findBestMatch(toMatcherEntries(entries), webDomain));
}
static Entry findByID(Context context, String entryID) {
if (entryID == null || entryID.trim().isEmpty()) {
return null;
}
List<Entry> exactHost = new ArrayList<>();
List<Entry> parentHost = new ArrayList<>();
for (Entry entry : readEntries(context)) {
if (entryID.equals(entry.id)) {
return entry;
}
}
return null;
}
static List<Entry> chooserCandidates(Context context, String rawTarget) {
List<Entry> entries = readEntries(context);
if (entries.isEmpty()) {
return entries;
}
Entry direct = fromMatcherEntry(AutofillTargetMatcher.findBestMatch(toMatcherEntries(entries), rawTarget));
if (direct != null) {
List<Entry> resolved = new ArrayList<>();
resolved.add(direct);
return resolved;
}
entries.sort(Comparator
.comparing((Entry entry) -> entry.title.toLowerCase(Locale.US))
.thenComparing(entry -> String.join("/", entry.path).toLowerCase(Locale.US))
.thenComparing(entry -> entry.id));
return entries;
}
private static List<AutofillTargetMatcher.Entry> toMatcherEntries(List<Entry> entries) {
List<AutofillTargetMatcher.Entry> converted = new ArrayList<>(entries.size());
for (Entry entry : entries) {
if (entryMatchesHost(entry, target.host)) {
exactHost.add(entry);
continue;
}
if (entryMatchesParentHost(entry, target.host)) {
parentHost.add(entry);
}
converted.add(new AutofillTargetMatcher.Entry(
entry.id,
entry.title,
entry.username,
entry.password,
entry.host,
entry.url,
entry.targets,
entry.path
));
}
Entry matched = chooseEntry(target, exactHost);
if (matched != null) {
return matched;
return converted;
}
private static Entry fromMatcherEntry(AutofillTargetMatcher.Entry entry) {
if (entry == null) {
return null;
}
return new Entry(entry.id, entry.title, entry.username, entry.password, entry.host, entry.url, entry.targets, entry.path);
}
private static List<Entry> readEntries(Context context) {
File cacheFile = findCacheFile(context);
if (cacheFile == null) {
Log.i(TAG, "autofill cache file not found");
return new ArrayList<>();
}
try {
return readEntries(cacheFile);
} catch (IOException err) {
Log.e(TAG, "failed to read autofill cache", err);
return new ArrayList<>();
}
return chooseEntry(target, parentHost);
}
private static File findCacheFile(Context context) {
@@ -103,16 +142,21 @@ final class AutofillCacheStore {
}
private static Entry readEntry(JsonReader reader) throws IOException {
String id = "";
String title = "";
String username = "";
String password = "";
String host = "";
String url = "";
List<String> targets = new ArrayList<>();
List<String> path = new ArrayList<>();
reader.beginObject();
while (reader.hasNext()) {
String name = reader.nextName();
switch (name) {
case "id":
id = nextString(reader);
break;
case "title":
title = nextString(reader);
break;
@@ -135,13 +179,20 @@ final class AutofillCacheStore {
}
reader.endArray();
break;
case "path":
reader.beginArray();
while (reader.hasNext()) {
path.add(nextString(reader));
}
reader.endArray();
break;
default:
reader.skipValue();
break;
}
}
reader.endObject();
return new Entry(title, username, password, host, url, targets);
return new Entry(id, title, username, password, host, url, targets, path);
}
private static String nextString(JsonReader reader) throws IOException {
@@ -153,182 +204,28 @@ final class AutofillCacheStore {
}
private static String normalizeHost(String raw) {
return normalizeURL(raw).host;
}
private static NormalizedTarget normalizeURL(String raw) {
if (raw == null) {
return new NormalizedTarget("", "", "");
}
String value = raw.trim().toLowerCase(Locale.US);
if (value.startsWith("http://")) {
value = value.substring("http://".length());
} else if (value.startsWith("https://")) {
value = value.substring("https://".length());
}
int slash = value.indexOf('/');
if (slash >= 0) {
value = value.substring(0, slash);
}
int colon = value.indexOf(':');
if (colon >= 0) {
value = value.substring(0, colon);
}
String host = value;
String path = "/";
int schemeSep = raw.indexOf("://");
String original = raw.trim();
if (schemeSep < 0) {
original = "https://" + original;
}
try {
java.net.URI uri = java.net.URI.create(original);
if (uri.getHost() != null) {
host = uri.getHost().toLowerCase(Locale.US);
}
path = cleanPath(uri.getPath());
} catch (IllegalArgumentException ignored) {
path = "/";
}
return new NormalizedTarget(host, path, host + path);
}
private static String cleanPath(String raw) {
if (raw == null || raw.trim().isEmpty() || "/".equals(raw.trim())) {
return "/";
}
String value = raw.trim();
while (value.endsWith("/") && value.length() > 1) {
value = value.substring(0, value.length() - 1);
}
if (!value.startsWith("/")) {
value = "/" + value;
}
return value;
}
private static Entry chooseEntry(NormalizedTarget target, List<Entry> entries) {
if (entries.isEmpty()) {
return null;
}
if (entries.size() == 1) {
return entries.get(0);
}
List<Entry> exact = new ArrayList<>();
List<Entry> prefix = new ArrayList<>();
int bestPrefixLen = -1;
for (Entry entry : entries) {
MatchQuality quality = bestTargetMatch(entry, target);
if (quality.exact) {
exact.add(entry);
continue;
}
if (quality.prefixLength <= 0) {
continue;
}
if (quality.prefixLength > bestPrefixLen) {
prefix.clear();
prefix.add(entry);
bestPrefixLen = quality.prefixLength;
} else if (quality.prefixLength == bestPrefixLen) {
prefix.add(entry);
}
}
if (exact.size() == 1) {
return exact.get(0);
}
if (exact.size() > 1 || prefix.isEmpty()) {
return null;
}
return prefix.size() == 1 ? prefix.get(0) : null;
}
private static boolean entryMatchesHost(Entry entry, String host) {
for (NormalizedTarget target : entryTargets(entry)) {
if (target.host.equals(host)) {
return true;
}
}
return false;
}
private static boolean entryMatchesParentHost(Entry entry, String host) {
for (NormalizedTarget target : entryTargets(entry)) {
if (!target.host.isEmpty() && host.endsWith("." + target.host)) {
return true;
}
}
return false;
}
private static List<NormalizedTarget> entryTargets(Entry entry) {
List<String> rawTargets = entry.targets;
if (rawTargets.isEmpty()) {
rawTargets = new ArrayList<>();
rawTargets.add(entry.url);
}
List<NormalizedTarget> targets = new ArrayList<>();
for (String rawTarget : rawTargets) {
NormalizedTarget target = normalizeURL(rawTarget);
if (!target.host.isEmpty()) {
targets.add(target);
}
}
return targets;
}
private static MatchQuality bestTargetMatch(Entry entry, NormalizedTarget target) {
int bestPrefixLen = -1;
for (NormalizedTarget entryTarget : entryTargets(entry)) {
if (entryTarget.url.equals(target.url)) {
return new MatchQuality(true, 0);
}
if (!"/".equals(entryTarget.path) && target.path.startsWith(entryTarget.path)) {
bestPrefixLen = Math.max(bestPrefixLen, entryTarget.path.length());
}
}
return new MatchQuality(false, bestPrefixLen);
return AutofillTargetMatcher.normalize(raw).host;
}
static final class Entry {
final String id;
final String title;
final String username;
final String password;
final String host;
final String url;
final List<String> targets;
final List<String> path;
Entry(String title, String username, String password, String host, String url, List<String> targets) {
Entry(String id, String title, String username, String password, String host, String url, List<String> targets, List<String> path) {
this.id = id;
this.title = title;
this.username = username;
this.password = password;
this.host = host;
this.url = url;
this.targets = new ArrayList<>(targets);
}
}
private static final class MatchQuality {
final boolean exact;
final int prefixLength;
MatchQuality(boolean exact, int prefixLength) {
this.exact = exact;
this.prefixLength = prefixLength;
}
}
private static final class NormalizedTarget {
final String host;
final String path;
final String url;
NormalizedTarget(String host, String path, String url) {
this.host = host;
this.path = path;
this.url = url;
this.path = new ArrayList<>(path);
}
}
}
@@ -0,0 +1,24 @@
package org.julianfamily.keepassgo;
final class AutofillFallbackTarget {
private static final String APP_SCHEME = "androidapp://";
private AutofillFallbackTarget() {
}
static String resolve(String packageName, String webDomain) {
String domain = trim(webDomain);
if (!domain.isEmpty()) {
return domain;
}
String pkg = trim(packageName);
if (pkg.isEmpty()) {
return "";
}
return APP_SCHEME + pkg;
}
private static String trim(String value) {
return value == null ? "" : value.trim();
}
}
@@ -0,0 +1,260 @@
package org.julianfamily.keepassgo;
import java.net.URI;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import java.util.Locale;
final class AutofillTargetMatcher {
private AutofillTargetMatcher() {
}
static Entry findBestMatch(List<Entry> entries, String rawTarget) {
if (entries == null || entries.isEmpty()) {
return null;
}
NormalizedTarget target = normalize(rawTarget);
if (target.host.isEmpty()) {
return null;
}
List<Entry> exactHost = new ArrayList<>();
List<Entry> parentHost = new ArrayList<>();
for (Entry entry : entries) {
if (entryMatchesHost(entry, target.host)) {
exactHost.add(entry);
continue;
}
if (entryMatchesParentHost(entry, target.host)) {
parentHost.add(entry);
}
}
Entry matched = chooseEntry(target, exactHost);
if (matched != null) {
return matched;
}
return chooseEntry(target, parentHost);
}
static List<Entry> chooserCandidates(List<Entry> entries, String rawTarget) {
if (entries == null || entries.isEmpty()) {
return new ArrayList<>();
}
Entry direct = findBestMatch(entries, rawTarget);
if (direct != null) {
List<Entry> resolved = new ArrayList<>();
resolved.add(direct);
return resolved;
}
NormalizedTarget target = normalize(rawTarget);
List<Entry> exactHost = new ArrayList<>();
List<Entry> parentHost = new ArrayList<>();
for (Entry entry : entries) {
if (entryMatchesHost(entry, target.host)) {
exactHost.add(entry);
continue;
}
if (entryMatchesParentHost(entry, target.host)) {
parentHost.add(entry);
}
}
if (!exactHost.isEmpty()) {
return sortEntries(exactHost);
}
if (!parentHost.isEmpty()) {
return sortEntries(parentHost);
}
return sortEntries(entries);
}
static NormalizedTarget normalize(String raw) {
if (raw == null) {
return new NormalizedTarget("", "", "");
}
String trimmed = raw.trim();
if (trimmed.isEmpty()) {
return new NormalizedTarget("", "", "");
}
String original = trimmed;
String value = trimmed.toLowerCase(Locale.US);
if (value.startsWith("http://")) {
value = value.substring("http://".length());
} else if (value.startsWith("https://")) {
value = value.substring("https://".length());
}
int slash = value.indexOf('/');
if (slash >= 0) {
value = value.substring(0, slash);
}
int colon = value.indexOf(':');
if (colon >= 0) {
value = value.substring(0, colon);
}
String host = value;
String path = "/";
int schemeSep = original.indexOf("://");
if (schemeSep < 0) {
original = "https://" + original;
}
try {
URI uri = URI.create(original);
if (uri.getHost() != null) {
host = uri.getHost().toLowerCase(Locale.US);
}
path = cleanPath(uri.getPath());
} catch (IllegalArgumentException ignored) {
path = "/";
}
return new NormalizedTarget(host, path, host + path);
}
private static String cleanPath(String raw) {
if (raw == null || raw.trim().isEmpty() || "/".equals(raw.trim())) {
return "/";
}
String value = raw.trim();
while (value.endsWith("/") && value.length() > 1) {
value = value.substring(0, value.length() - 1);
}
if (!value.startsWith("/")) {
value = "/" + value;
}
return value;
}
private static Entry chooseEntry(NormalizedTarget target, List<Entry> entries) {
if (entries.isEmpty()) {
return null;
}
if (entries.size() == 1) {
return entries.get(0);
}
List<Entry> exact = new ArrayList<>();
List<Entry> prefix = new ArrayList<>();
int bestPrefixLen = -1;
for (Entry entry : entries) {
MatchQuality quality = bestTargetMatch(entry, target);
if (quality.exact) {
exact.add(entry);
continue;
}
if (quality.prefixLength <= 0) {
continue;
}
if (quality.prefixLength > bestPrefixLen) {
prefix.clear();
prefix.add(entry);
bestPrefixLen = quality.prefixLength;
} else if (quality.prefixLength == bestPrefixLen) {
prefix.add(entry);
}
}
if (exact.size() == 1) {
return exact.get(0);
}
if (exact.size() > 1 || prefix.isEmpty()) {
return null;
}
return prefix.size() == 1 ? prefix.get(0) : null;
}
private static boolean entryMatchesHost(Entry entry, String host) {
for (NormalizedTarget target : entryTargets(entry)) {
if (target.host.equals(host)) {
return true;
}
}
return false;
}
private static boolean entryMatchesParentHost(Entry entry, String host) {
for (NormalizedTarget target : entryTargets(entry)) {
if (!target.host.isEmpty() && host.endsWith("." + target.host)) {
return true;
}
}
return false;
}
private static List<NormalizedTarget> entryTargets(Entry entry) {
List<String> rawTargets = entry.targets;
if (rawTargets.isEmpty()) {
rawTargets = new ArrayList<>();
rawTargets.add(entry.url);
}
List<NormalizedTarget> targets = new ArrayList<>();
for (String rawTarget : rawTargets) {
NormalizedTarget target = normalize(rawTarget);
if (!target.host.isEmpty()) {
targets.add(target);
}
}
return targets;
}
private static MatchQuality bestTargetMatch(Entry entry, NormalizedTarget target) {
int bestPrefixLen = -1;
for (NormalizedTarget entryTarget : entryTargets(entry)) {
if (entryTarget.url.equals(target.url)) {
return new MatchQuality(true, 0);
}
if (!"/".equals(entryTarget.path) && target.path.startsWith(entryTarget.path)) {
bestPrefixLen = Math.max(bestPrefixLen, entryTarget.path.length());
}
}
return new MatchQuality(false, bestPrefixLen);
}
private static List<Entry> sortEntries(List<Entry> entries) {
List<Entry> sorted = new ArrayList<>(entries);
sorted.sort(Comparator
.comparing((Entry entry) -> entry.title.toLowerCase(Locale.US))
.thenComparing(entry -> String.join("/", entry.path).toLowerCase(Locale.US))
.thenComparing(entry -> entry.id));
return sorted;
}
static final class NormalizedTarget {
final String host;
final String path;
final String url;
NormalizedTarget(String host, String path, String url) {
this.host = host;
this.path = path;
this.url = url;
}
}
static final class Entry {
final String id;
final String title;
final String username;
final String password;
final String host;
final String url;
final List<String> targets;
final List<String> path;
Entry(String id, String title, String username, String password, String host, String url, List<String> targets, List<String> path) {
this.id = id;
this.title = title;
this.username = username;
this.password = password;
this.host = host;
this.url = url;
this.targets = new ArrayList<>(targets);
this.path = new ArrayList<>(path);
}
}
private static final class MatchQuality {
final boolean exact;
final int prefixLength;
MatchQuality(boolean exact, int prefixLength) {
this.exact = exact;
this.prefixLength = prefixLength;
}
}
}
@@ -12,7 +12,6 @@ import java.util.List;
public final class KeePassGOAccessibilityService extends AccessibilityService {
private static final String TAG = "KeePassGOA11y";
private String lastFilledSignature = "";
@Override
@@ -22,22 +21,17 @@ public final class KeePassGOAccessibilityService extends AccessibilityService {
if (root == null) {
return;
}
CharSequence packageName = root.getPackageName();
if (packageName == null || !"com.android.chrome".contentEquals(packageName)) {
return;
}
ChromeForm form = inspectChrome(root);
ChromeForm form = inspectWindow(root);
if (form == null || form.passwordField == null) {
return;
}
AutofillCacheStore.Entry entry = AutofillCacheStore.findBestMatch(this, form.url);
AutofillCacheStore.Entry entry = AutofillCacheStore.findBestMatch(this, form.matchTarget);
if (entry == null) {
Log.i(TAG, "no accessibility-fill match for " + form.url);
Log.i(TAG, "no accessibility-fill match for " + form.matchTarget);
return;
}
String signature = form.url + "|" + entry.username + "|" + nodeKey(form.passwordField);
String signature = form.matchTarget + "|" + entry.username + "|" + nodeKey(form.passwordField);
if (signature.equals(lastFilledSignature)) {
return;
}
@@ -49,7 +43,7 @@ public final class KeePassGOAccessibilityService extends AccessibilityService {
filled |= setNodeText(form.passwordField, entry.password);
if (filled) {
lastFilledSignature = signature;
Log.i(TAG, "filled login form for " + form.url + " with " + entry.username);
Log.i(TAG, "filled login form for " + form.matchTarget + " with " + entry.username);
}
} catch (Exception err) {
Log.e(TAG, "accessibility fill failed", err);
@@ -61,11 +55,11 @@ public final class KeePassGOAccessibilityService extends AccessibilityService {
Log.i(TAG, "accessibility service interrupted");
}
private static ChromeForm inspectChrome(AccessibilityNodeInfo root) {
private static ChromeForm inspectWindow(AccessibilityNodeInfo root) {
List<AccessibilityNodeInfo> editables = new ArrayList<>();
ChromeForm form = new ChromeForm();
walk(root, editables, form);
if (form.url.isEmpty()) {
if (form.matchTarget.isEmpty()) {
return null;
}
if (form.passwordField == null) {
@@ -111,7 +105,13 @@ public final class KeePassGOAccessibilityService extends AccessibilityService {
if (viewID != null && viewID.toString().endsWith(":id/url_bar")) {
CharSequence text = node.getText();
if (text != null) {
form.url = text.toString().trim();
form.webDomain = text.toString().trim();
}
}
if (form.packageName.isEmpty()) {
CharSequence packageName = node.getPackageName();
if (packageName != null) {
form.packageName = packageName.toString().trim();
}
}
if (node.isEditable()) {
@@ -128,6 +128,7 @@ public final class KeePassGOAccessibilityService extends AccessibilityService {
walk(child, editables, form);
}
}
form.matchTarget = AutofillFallbackTarget.resolve(form.packageName, form.webDomain);
}
private static boolean isPasswordNode(AccessibilityNodeInfo node) {
@@ -188,7 +189,9 @@ public final class KeePassGOAccessibilityService extends AccessibilityService {
}
private static final class ChromeForm {
String url = "";
String packageName = "";
String webDomain = "";
String matchTarget = "";
AccessibilityNodeInfo usernameField;
AccessibilityNodeInfo passwordField;
}
@@ -0,0 +1,182 @@
package org.julianfamily.keepassgo;
import android.app.Activity;
import android.content.Intent;
import android.os.Bundle;
import android.text.Editable;
import android.text.TextWatcher;
import android.util.TypedValue;
import android.view.Gravity;
import android.view.View;
import android.view.autofill.AutofillId;
import android.view.autofill.AutofillManager;
import android.view.autofill.AutofillValue;
import android.widget.AdapterView;
import android.widget.ArrayAdapter;
import android.widget.EditText;
import android.widget.LinearLayout;
import android.widget.ListView;
import android.widget.TextView;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import android.service.autofill.Dataset;
public final class KeePassGOAutofillPickerActivity extends Activity {
static final String EXTRA_TARGET = "org.julianfamily.keepassgo.extra.AUTOFILL_TARGET";
static final String EXTRA_PACKAGE_NAME = "org.julianfamily.keepassgo.extra.AUTOFILL_PACKAGE";
static final String EXTRA_USERNAME_ID = "org.julianfamily.keepassgo.extra.USERNAME_ID";
static final String EXTRA_PASSWORD_ID = "org.julianfamily.keepassgo.extra.PASSWORD_ID";
private final List<AutofillCacheStore.Entry> allEntries = new ArrayList<>();
private final List<AutofillCacheStore.Entry> visibleEntries = new ArrayList<>();
private ArrayAdapter<String> adapter;
private String matchTarget = "";
private String packageName = "";
private AutofillId usernameID;
private AutofillId passwordID;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
Intent intent = getIntent();
matchTarget = intent.getStringExtra(EXTRA_TARGET);
packageName = intent.getStringExtra(EXTRA_PACKAGE_NAME);
usernameID = intent.getParcelableExtra(EXTRA_USERNAME_ID, AutofillId.class);
passwordID = intent.getParcelableExtra(EXTRA_PASSWORD_ID, AutofillId.class);
LinearLayout root = new LinearLayout(this);
root.setOrientation(LinearLayout.VERTICAL);
int padding = dp(16);
root.setPadding(padding, padding, padding, padding);
TextView title = new TextView(this);
title.setTextSize(TypedValue.COMPLEX_UNIT_SP, 20);
title.setGravity(Gravity.START);
title.setPadding(0, 0, 0, dp(8));
title.setText(packageName == null || packageName.trim().isEmpty() ? "Search KeePassGO" : "Search KeePassGO for " + packageName);
root.addView(title, new LinearLayout.LayoutParams(
LinearLayout.LayoutParams.MATCH_PARENT,
LinearLayout.LayoutParams.WRAP_CONTENT
));
EditText search = new EditText(this);
search.setHint("Search entries");
root.addView(search, new LinearLayout.LayoutParams(
LinearLayout.LayoutParams.MATCH_PARENT,
LinearLayout.LayoutParams.WRAP_CONTENT
));
allEntries.clear();
allEntries.addAll(AutofillCacheStore.chooserCandidates(this, matchTarget));
visibleEntries.clear();
visibleEntries.addAll(allEntries);
adapter = new ArrayAdapter<>(this, android.R.layout.simple_list_item_1, labelsFor(visibleEntries));
ListView list = new ListView(this);
list.setAdapter(adapter);
list.setOnItemClickListener(this::onEntrySelected);
TextView empty = new TextView(this);
empty.setPadding(0, dp(16), 0, 0);
empty.setText("No KeePassGO entries are available for autofill.");
empty.setVisibility(allEntries.isEmpty() ? View.VISIBLE : View.GONE);
list.setVisibility(allEntries.isEmpty() ? View.GONE : View.VISIBLE);
root.addView(empty, new LinearLayout.LayoutParams(
LinearLayout.LayoutParams.MATCH_PARENT,
LinearLayout.LayoutParams.WRAP_CONTENT
));
LinearLayout.LayoutParams listParams = new LinearLayout.LayoutParams(
LinearLayout.LayoutParams.MATCH_PARENT,
0
);
listParams.weight = 1f;
listParams.topMargin = dp(12);
root.addView(list, listParams);
search.addTextChangedListener(new TextWatcher() {
@Override
public void beforeTextChanged(CharSequence s, int start, int count, int after) {
}
@Override
public void onTextChanged(CharSequence s, int start, int before, int count) {
filterEntries(s == null ? "" : s.toString());
}
@Override
public void afterTextChanged(Editable s) {
}
});
setContentView(root);
}
private void filterEntries(String rawQuery) {
String query = rawQuery == null ? "" : rawQuery.trim().toLowerCase(Locale.US);
visibleEntries.clear();
if (query.isEmpty()) {
visibleEntries.addAll(allEntries);
} else {
for (AutofillCacheStore.Entry entry : allEntries) {
if (entryLabel(entry).toLowerCase(Locale.US).contains(query)) {
visibleEntries.add(entry);
}
}
}
adapter.clear();
adapter.addAll(labelsFor(visibleEntries));
adapter.notifyDataSetChanged();
}
private void onEntrySelected(AdapterView<?> parent, View view, int position, long id) {
if (position < 0 || position >= visibleEntries.size() || passwordID == null) {
setResult(Activity.RESULT_CANCELED);
finish();
return;
}
AutofillCacheStore.Entry entry = visibleEntries.get(position);
if (matchTarget != null && matchTarget.startsWith("androidapp://")) {
AutofillBindingStore.rememberBinding(this, matchTarget, entry.id);
}
Dataset.Builder dataset = new Dataset.Builder();
dataset.setId(entry.id);
if (usernameID != null && entry.username != null && !entry.username.isEmpty()) {
dataset.setValue(usernameID, AutofillValue.forText(entry.username));
}
dataset.setValue(passwordID, AutofillValue.forText(entry.password));
Intent result = new Intent();
result.putExtra(AutofillManager.EXTRA_AUTHENTICATION_RESULT, dataset.build());
setResult(Activity.RESULT_OK, result);
finish();
}
private static List<String> labelsFor(List<AutofillCacheStore.Entry> entries) {
List<String> labels = new ArrayList<>(entries.size());
for (AutofillCacheStore.Entry entry : entries) {
labels.add(entryLabel(entry));
}
return labels;
}
private static String entryLabel(AutofillCacheStore.Entry entry) {
StringBuilder label = new StringBuilder();
label.append(entry.title == null || entry.title.trim().isEmpty() ? "Untitled entry" : entry.title.trim());
if (entry.username != null && !entry.username.trim().isEmpty()) {
label.append(" (").append(entry.username.trim()).append(")");
}
if (entry.path != null && !entry.path.isEmpty()) {
label.append("").append(String.join(" / ", entry.path));
}
return label.toString();
}
private int dp(int value) {
return Math.round(value * getResources().getDisplayMetrics().density);
}
}
@@ -1,6 +1,8 @@
package org.julianfamily.keepassgo;
import android.app.PendingIntent;
import android.app.assist.AssistStructure;
import android.content.Intent;
import android.os.CancellationSignal;
import android.service.autofill.AutofillService;
import android.service.autofill.Dataset;
@@ -66,29 +68,21 @@ public final class KeePassGOAutofillService extends AutofillService {
return;
}
AutofillCacheStore.Entry entry = AutofillCacheStore.findBestMatch(this, target.matchTarget);
AutofillCacheStore.Entry entry = findBoundOrBestMatch(target.matchTarget);
if (entry == null) {
Log.i(TAG, "no autofill cache match");
callback.onSuccess(null);
FillResponse chooser = chooserResponse(target, fields);
if (chooser == null) {
Log.i(TAG, "no autofill cache match");
callback.onSuccess(null);
return;
}
Log.i(TAG, "returning chooser dataset for " + target.matchTarget);
callback.onSuccess(chooser);
return;
}
Log.i(TAG, "matched entry title=" + entry.title + " user=" + entry.username + " host=" + entry.host);
RemoteViews presentation = new RemoteViews(getPackageName(), android.R.layout.simple_list_item_1);
presentation.setTextViewText(
android.R.id.text1,
entry.title + " (" + entry.username + ")"
);
Dataset.Builder dataset = new Dataset.Builder(presentation);
if (fields.usernameId != null) {
dataset.setValue(fields.usernameId, AutofillValue.forText(entry.username));
}
dataset.setValue(fields.passwordId, AutofillValue.forText(entry.password));
FillResponse response = new FillResponse.Builder()
.addDataset(dataset.build())
.build();
FillResponse response = directFillResponse(entry, fields);
Log.i(TAG, "returning dataset");
callback.onSuccess(response);
} catch (Exception err) {
@@ -103,6 +97,72 @@ public final class KeePassGOAutofillService extends AutofillService {
callback.onSuccess();
}
private AutofillCacheStore.Entry findBoundOrBestMatch(String matchTarget) {
String entryID = AutofillBindingStore.entryIDForTarget(this, matchTarget);
if (!entryID.isEmpty()) {
AutofillCacheStore.Entry bound = AutofillCacheStore.findByID(this, entryID);
if (bound != null) {
return bound;
}
}
return AutofillCacheStore.findBestMatch(this, matchTarget);
}
private FillResponse directFillResponse(AutofillCacheStore.Entry entry, ParsedFields fields) {
RemoteViews presentation = new RemoteViews(getPackageName(), android.R.layout.simple_list_item_1);
presentation.setTextViewText(
android.R.id.text1,
entry.title + " (" + entry.username + ")"
);
Dataset.Builder dataset = new Dataset.Builder(presentation);
dataset.setId(entry.id);
if (fields.usernameId != null) {
dataset.setValue(fields.usernameId, AutofillValue.forText(entry.username));
}
dataset.setValue(fields.passwordId, AutofillValue.forText(entry.password));
return new FillResponse.Builder()
.addDataset(dataset.build())
.build();
}
private FillResponse chooserResponse(ParsedTarget target, ParsedFields fields) {
if (fields.passwordId == null) {
return null;
}
List<AutofillCacheStore.Entry> candidates = AutofillCacheStore.chooserCandidates(this, target.matchTarget);
if (candidates.isEmpty()) {
return null;
}
RemoteViews presentation = new RemoteViews(getPackageName(), android.R.layout.simple_list_item_1);
presentation.setTextViewText(android.R.id.text1, "Search KeePassGO");
Intent intent = new Intent(this, KeePassGOAutofillPickerActivity.class);
intent.putExtra(KeePassGOAutofillPickerActivity.EXTRA_TARGET, target.matchTarget);
intent.putExtra(KeePassGOAutofillPickerActivity.EXTRA_PACKAGE_NAME, target.packageName);
intent.putExtra(KeePassGOAutofillPickerActivity.EXTRA_USERNAME_ID, fields.usernameId);
intent.putExtra(KeePassGOAutofillPickerActivity.EXTRA_PASSWORD_ID, fields.passwordId);
PendingIntent pendingIntent = PendingIntent.getActivity(
this,
target.matchTarget.hashCode(),
intent,
PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_MUTABLE
);
AutofillId[] ids;
if (fields.usernameId != null) {
ids = new AutofillId[]{fields.usernameId, fields.passwordId};
} else {
ids = new AutofillId[]{fields.passwordId};
}
return new FillResponse.Builder()
.setAuthentication(ids, pendingIntent.getIntentSender(), presentation)
.build();
}
private static ParsedTarget parseWindow(AssistStructure structure, ParsedFields fields) {
String domain = "";
final int windowCount = structure.getWindowNodeCount();
@@ -1,6 +1,7 @@
package org.julianfamily.keepassgo;
import android.app.Activity;
import android.content.ClipData;
import android.content.Intent;
import android.database.Cursor;
import android.net.Uri;
@@ -10,13 +11,18 @@ import android.util.Log;
import java.io.File;
import java.io.FileOutputStream;
import java.io.FileInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
public final class SharedVaultImportActivity extends Activity {
private static final String TAG = "KeePassGOImport";
private static final String DEFAULT_NAME = "shared-vault.kdbx";
private static final String PENDING_SHARED_VAULT = "pending-shared-vault.kdbx";
private static final String PENDING_SHARED_VAULT_NAME = "pending-shared-vault-name.txt";
private static final String PENDING_SHARED_LOOKUP = "pending-shared-lookup.txt";
@Override
protected void onCreate(Bundle state) {
@@ -36,6 +42,17 @@ public final class SharedVaultImportActivity extends Activity {
}
private void handleIntent(Intent intent) {
logIntent(intent);
String sharedLookup = resolveSharedLookup(intent);
if (!sharedLookup.isEmpty()) {
try {
persistPendingLookup(sharedLookup);
Log.i(TAG, "queued shared lookup target");
} catch (IOException | RuntimeException err) {
Log.e(TAG, "failed to queue shared lookup target", err);
}
return;
}
Uri uri = resolveSharedUri(intent);
if (uri == null) {
Log.i(TAG, "no shared vault URI on intent");
@@ -55,21 +72,63 @@ public final class SharedVaultImportActivity extends Activity {
}
String action = intent.getAction();
if (Intent.ACTION_SEND.equals(action)) {
return intent.getParcelableExtra(Intent.EXTRA_STREAM);
Uri extraStream = intent.getParcelableExtra(Intent.EXTRA_STREAM);
if (extraStream != null) {
return extraStream;
}
}
if (Intent.ACTION_SEND_MULTIPLE.equals(action)) {
ArrayList<Uri> streams = intent.getParcelableArrayListExtra(Intent.EXTRA_STREAM);
if (streams != null && !streams.isEmpty()) {
return streams.get(0);
}
}
if (Intent.ACTION_VIEW.equals(action)) {
return intent.getData();
Uri data = intent.getData();
if (data != null) {
return data;
}
}
ClipData clipData = intent.getClipData();
if (clipData != null && clipData.getItemCount() > 0) {
Uri clipUri = clipData.getItemAt(0).getUri();
if (clipUri != null) {
return clipUri;
}
}
return null;
}
private String resolveSharedLookup(Intent intent) {
if (intent == null) {
return "";
}
String action = intent.getAction();
if (Intent.ACTION_SEND.equals(action) && "text/plain".equalsIgnoreCase(intent.getType())) {
CharSequence extraText = intent.getCharSequenceExtra(Intent.EXTRA_TEXT);
if (extraText != null) {
return extraText.toString().trim();
}
}
if (Intent.ACTION_VIEW.equals(action)) {
Uri data = intent.getData();
if (data != null) {
String scheme = data.getScheme();
if ("http".equalsIgnoreCase(scheme) || "https".equalsIgnoreCase(scheme)) {
return data.toString();
}
}
}
return "";
}
private void persistPendingImport(Uri uri) throws IOException {
File dir = new File(getFilesDir(), "keepassgo");
if (!dir.exists() && !dir.mkdirs()) {
throw new IOException("failed to create " + dir.getAbsolutePath());
}
File pendingFile = new File(dir, "pending-shared-vault.kdbx");
try (InputStream in = getContentResolver().openInputStream(uri)) {
File pendingFile = new File(dir, PENDING_SHARED_VAULT);
try (InputStream in = openSharedInputStream(uri)) {
if (in == null) {
throw new IOException("failed to open shared vault stream");
}
@@ -82,12 +141,34 @@ public final class SharedVaultImportActivity extends Activity {
}
}
File nameFile = new File(dir, "pending-shared-vault-name.txt");
File nameFile = new File(dir, PENDING_SHARED_VAULT_NAME);
try (FileOutputStream out = new FileOutputStream(nameFile, false)) {
out.write(resolveDisplayName(uri).getBytes(StandardCharsets.UTF_8));
}
}
private void persistPendingLookup(String lookup) throws IOException {
File dir = new File(getFilesDir(), "keepassgo");
if (!dir.exists() && !dir.mkdirs()) {
throw new IOException("failed to create " + dir.getAbsolutePath());
}
File pendingFile = new File(dir, PENDING_SHARED_LOOKUP);
try (FileOutputStream out = new FileOutputStream(pendingFile, false)) {
out.write(lookup.getBytes(StandardCharsets.UTF_8));
}
}
private InputStream openSharedInputStream(Uri uri) throws IOException {
if ("file".equalsIgnoreCase(uri.getScheme())) {
String path = uri.getPath();
if (path == null || path.trim().isEmpty()) {
throw new IOException("file URI is missing a path");
}
return new FileInputStream(new File(path));
}
return getContentResolver().openInputStream(uri);
}
private String resolveDisplayName(Uri uri) {
String displayName = queryDisplayName(uri);
if (!displayName.isEmpty()) {
@@ -123,6 +204,20 @@ public final class SharedVaultImportActivity extends Activity {
return "";
}
private void logIntent(Intent intent) {
if (intent == null) {
return;
}
Log.i(TAG, "intent action=" + intent.getAction()
+ " type=" + intent.getType()
+ " data=" + intent.getData()
+ " flags=0x" + Integer.toHexString(intent.getFlags()));
ClipData clipData = intent.getClipData();
if (clipData != null) {
Log.i(TAG, "intent clip items=" + clipData.getItemCount());
}
}
private void launchMainActivity() {
Intent launch = new Intent();
launch.setClassName(this, "org.gioui.GioActivity");
@@ -0,0 +1,153 @@
package org.julianfamily.keepassgo;
import java.util.Arrays;
import java.util.ArrayList;
import java.util.List;
public final class AutofillCacheStoreBehaviorTest {
public static void main(String[] args) {
testFindBestMatchUsesAndroidAppTargets();
testChooserCandidatesCollapseToExactAndroidAppMatch();
testChooserCandidatesStayScopedToExactHostMatches();
testChooserCandidatesStayScopedToParentHostMatches();
}
private static void testFindBestMatchUsesAndroidAppTargets() {
List<AutofillTargetMatcher.Entry> entries = new ArrayList<>();
entries.add(new AutofillTargetMatcher.Entry(
"blink-entry",
"Blink",
"linuscaldwell",
"bellagio-stack",
"account.blinknetwork.com",
"https://account.blinknetwork.com",
Arrays.asList("https://account.blinknetwork.com", "androidapp://com.blinknetwork.mobile2"),
Arrays.asList("Crew", "Apps")
));
AutofillTargetMatcher.Entry got = AutofillTargetMatcher.findBestMatch(entries, "androidapp://com.blinknetwork.mobile2");
if (got == null || !"blink-entry".equals(got.id)) {
throw new AssertionError("findBestMatch(entries, androidapp target) = " + describe(got) + ", want blink-entry");
}
}
private static void testChooserCandidatesCollapseToExactAndroidAppMatch() {
List<AutofillTargetMatcher.Entry> entries = new ArrayList<>();
entries.add(new AutofillTargetMatcher.Entry(
"blink-entry",
"Blink",
"linuscaldwell",
"bellagio-stack",
"account.blinknetwork.com",
"https://account.blinknetwork.com",
Arrays.asList("https://account.blinknetwork.com", "androidapp://com.blinknetwork.mobile2"),
Arrays.asList("Crew", "Apps")
));
entries.add(new AutofillTargetMatcher.Entry(
"night-fox-entry",
"Night Fox",
"nightfox",
"vault-code",
"gitlab.com",
"https://gitlab.com",
Arrays.asList("https://gitlab.com"),
Arrays.asList("Crew", "Internet")
));
List<AutofillTargetMatcher.Entry> got = AutofillTargetMatcher.chooserCandidates(entries, "androidapp://com.blinknetwork.mobile2");
if (got.size() != 1 || !"blink-entry".equals(got.get(0).id)) {
throw new AssertionError("chooserCandidates(entries, androidapp target) = " + describe(got) + ", want [blink-entry]");
}
}
private static void testChooserCandidatesStayScopedToExactHostMatches() {
List<AutofillTargetMatcher.Entry> entries = new ArrayList<>();
entries.add(new AutofillTargetMatcher.Entry(
"bellagio-primary",
"Bellagio Primary",
"dannyocean",
"vault-code",
"bellagio.example.invalid",
"https://bellagio.example.invalid/login",
Arrays.asList("https://bellagio.example.invalid/login"),
Arrays.asList("Crew", "Internet")
));
entries.add(new AutofillTargetMatcher.Entry(
"bellagio-backup",
"Bellagio Backup",
"rustyryan",
"backup-code",
"bellagio.example.invalid",
"https://bellagio.example.invalid/admin",
Arrays.asList("https://bellagio.example.invalid/admin"),
Arrays.asList("Crew", "Internet")
));
entries.add(new AutofillTargetMatcher.Entry(
"night-fox-entry",
"Night Fox",
"nightfox",
"vault-code",
"gitlab.com",
"https://gitlab.com",
Arrays.asList("https://gitlab.com"),
Arrays.asList("Crew", "Internet")
));
List<AutofillTargetMatcher.Entry> got = AutofillTargetMatcher.chooserCandidates(entries, "https://bellagio.example.invalid/security");
if (got.size() != 2 || !containsIDs(got, "bellagio-primary", "bellagio-backup")) {
throw new AssertionError("chooserCandidates(entries, exact host) = " + describe(got) + ", want only Bellagio entries");
}
}
private static void testChooserCandidatesStayScopedToParentHostMatches() {
List<AutofillTargetMatcher.Entry> entries = new ArrayList<>();
entries.add(new AutofillTargetMatcher.Entry(
"bellagio-parent",
"Bellagio Parent",
"linuscaldwell",
"chip-stack",
"example.invalid",
"https://example.invalid/login",
Arrays.asList("https://example.invalid/login"),
Arrays.asList("Crew", "Internet")
));
entries.add(new AutofillTargetMatcher.Entry(
"night-fox-entry",
"Night Fox",
"nightfox",
"vault-code",
"gitlab.com",
"https://gitlab.com",
Arrays.asList("https://gitlab.com"),
Arrays.asList("Crew", "Internet")
));
List<AutofillTargetMatcher.Entry> got = AutofillTargetMatcher.chooserCandidates(entries, "https://bellagio.example.invalid/security");
if (got.size() != 1 || !"bellagio-parent".equals(got.get(0).id)) {
throw new AssertionError("chooserCandidates(entries, parent host) = " + describe(got) + ", want [bellagio-parent]");
}
}
private static String describe(AutofillTargetMatcher.Entry entry) {
if (entry == null) {
return "null";
}
return entry.id;
}
private static String describe(List<AutofillTargetMatcher.Entry> entries) {
List<String> ids = new ArrayList<>();
for (AutofillTargetMatcher.Entry entry : entries) {
ids.add(entry.id);
}
return ids.toString();
}
private static boolean containsIDs(List<AutofillTargetMatcher.Entry> entries, String... wantIDs) {
List<String> ids = new ArrayList<>();
for (AutofillTargetMatcher.Entry entry : entries) {
ids.add(entry.id);
}
return ids.containsAll(Arrays.asList(wantIDs)) && ids.size() == wantIDs.length;
}
}
@@ -0,0 +1,30 @@
package org.julianfamily.keepassgo;
public final class AutofillFallbackTargetBehaviorTest {
public static void main(String[] args) {
testPrefersWebDomainWhenPresent();
testFallsBackToAndroidAppPackage();
testEmptyWhenNeitherSignalExists();
}
private static void testPrefersWebDomainWhenPresent() {
String got = AutofillFallbackTarget.resolve("com.android.chrome", "gitlab.com");
if (!"gitlab.com".equals(got)) {
throw new AssertionError("resolve(chrome, gitlab.com) = " + got + ", want gitlab.com");
}
}
private static void testFallsBackToAndroidAppPackage() {
String got = AutofillFallbackTarget.resolve("com.blinknetwork.mobile2", "");
if (!"androidapp://com.blinknetwork.mobile2".equals(got)) {
throw new AssertionError("resolve(package-only) = " + got + ", want androidapp://com.blinknetwork.mobile2");
}
}
private static void testEmptyWhenNeitherSignalExists() {
String got = AutofillFallbackTarget.resolve("", "");
if (!"".equals(got)) {
throw new AssertionError("resolve(empty) = " + got + ", want empty");
}
}
}
+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.
File diff suppressed because it is too large Load Diff
+176
View File
@@ -0,0 +1,176 @@
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("shouldContinueWatchingState keeps polling locked login pages", () => {
assert.equal(background.shouldContinueWatchingState({
pageHasLoginForm: true,
pendingFill: false,
status: { locked: true }
}), true);
assert.equal(background.shouldContinueWatchingState({
pageHasLoginForm: true,
pendingFill: true,
status: { locked: false }
}), true);
assert.equal(background.shouldContinueWatchingState({
pageHasLoginForm: true,
pendingFill: false,
status: { locked: false }
}), false);
});
test("default settings include a blank bearer token that can be overridden by harness patching", () => {
assert.equal(background.defaultSettings.bearerToken, "");
assert.equal(background.defaultSettings.bestMatchOnly, false);
assert.equal(background.defaultSettings.requireSchemeMatch, false);
assert.equal(background.defaultSettings.sortResults, "quality");
});
test("savePlanForObservedLogin prefers updating an exact username match", () => {
const plan = background.savePlanForObservedLogin({
username: "dannyocean",
password: "bellagio-safe",
url: "https://vault.example.invalid/login"
}, [
{
id: "vault-console",
title: "Vault Console",
username: "dannyocean",
url: "vault.example.invalid",
path: ["Crew", "Internet"]
},
{
id: "bellagio-backup",
title: "Bellagio Backup",
username: "rustyryan",
url: "vault.example.invalid",
path: ["Crew", "Internet"]
}
]);
assert.deepEqual(plan, {
mode: "update",
entryId: "vault-console",
title: "Vault Console",
path: ["Crew", "Internet"],
username: "dannyocean",
password: "bellagio-safe",
url: "https://vault.example.invalid/login"
});
});
test("savePlanForObservedLogin falls back to saving into the current page group", () => {
const plan = background.savePlanForObservedLogin({
username: "linuscaldwell",
password: "yellow-chip",
url: "https://vault.example.invalid/login"
}, [
{
id: "vault-console",
title: "Vault Console",
username: "dannyocean",
url: "vault.example.invalid",
path: ["Crew", "Internet"]
}
]);
assert.deepEqual(plan, {
mode: "save",
entryId: "",
title: "vault.example.invalid",
path: ["Crew", "Internet"],
username: "linuscaldwell",
password: "yellow-chip",
url: "https://vault.example.invalid/login"
});
});
test("applyMatchControls keeps only the strongest quality band when best-match-only is enabled", () => {
const filtered = background.applyMatchControls([
{ id: "livingston", title: "Livingston Dell", quality: "exact" },
{ id: "rusty", title: "Rusty Ryan", quality: "host" },
{ id: "linus", title: "Linus Caldwell", quality: "scheme" }
], {
bestMatchOnly: true,
requireSchemeMatch: false,
sortResults: "quality"
}, "https://vault.example.invalid/login");
assert.deepEqual(filtered.map((match) => match.id), ["livingston"]);
});
test("applyMatchControls removes explicit scheme mismatches but keeps scheme-less matches", () => {
const filtered = background.applyMatchControls([
{ id: "yen", title: "The Amazing Yen", url: "https://vault.example.invalid/login", quality: "exact" },
{ id: "saul", title: "Saul Bloom", url: "http://vault.example.invalid/login", quality: "exact" },
{ id: "basher", title: "Basher Tarr", url: "vault.example.invalid", quality: "host" }
], {
bestMatchOnly: false,
requireSchemeMatch: true,
sortResults: "quality"
}, "https://vault.example.invalid/login");
assert.deepEqual(filtered.map((match) => match.id), ["yen", "basher"]);
});
test("applyMatchControls sorts by path when requested", () => {
const filtered = background.applyMatchControls([
{ id: "linus", title: "Linus Caldwell", path: ["Crew", "Inside"] },
{ id: "rusty", title: "Rusty Ryan", path: ["Crew", "Casino"] },
{ id: "danny", title: "Danny Ocean", path: ["Crew"] }
], {
bestMatchOnly: false,
requireSchemeMatch: false,
sortResults: "path"
}, "https://vault.example.invalid/login");
assert.deepEqual(filtered.map((match) => match.id), ["danny", "rusty", "linus"]);
});
+899
View File
@@ -0,0 +1,899 @@
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";
case "username":
return "username";
default:
return "";
}
}
function lowerJoined(values) {
return values
.map((value) => String(value || "").trim().toLowerCase())
.filter(Boolean)
.join(" ");
}
function fieldHintText(input) {
if (!input || typeof input !== "object") {
return "";
}
const labels = input.labels ? Array.from(input.labels).map((label) => label.textContent || "") : [];
return lowerJoined([
input.getAttribute?.("type"),
input.getAttribute?.("name"),
input.getAttribute?.("id"),
input.autocomplete,
input.getAttribute?.("autocomplete"),
input.getAttribute?.("placeholder"),
input.getAttribute?.("aria-label"),
...labels
]);
}
function textLikeInputType(type) {
switch (String(type || "").toLowerCase()) {
case "":
case "text":
case "email":
case "tel":
case "number":
return true;
default:
return false;
}
}
function hintMatches(text, patterns) {
return patterns.some((pattern) => pattern.test(text));
}
function scopeHintText(scope) {
const isDocumentScope = typeof document !== "undefined" && scope === document;
if ((!scope || typeof scope !== "object") || (!isDocumentScope && typeof scope.querySelectorAll !== "function" && typeof scope.getAttribute !== "function")) {
return "";
}
const attrText = isDocumentScope ? "" : lowerJoined([
scope.getAttribute?.("id"),
scope.getAttribute?.("name"),
scope.getAttribute?.("class"),
scope.getAttribute?.("action"),
scope.getAttribute?.("aria-label")
]);
const headingText = lowerJoined(Array.from(scope.querySelectorAll?.("button, h1, h2, h3, h4, legend, label, [role='button']") || [])
.slice(0, 8)
.map((element) => element.textContent || ""));
return lowerJoined([attrText, headingText]);
}
function hasAuthFlowSignals(usernameInput, scope) {
if (usernameInput) {
return true;
}
return hintMatches(scopeHintText(scope), authScopePatterns);
}
const usernameHintPatterns = [
/\buser(name|id)?\b/,
/\blog[\s_-]?in\b/,
/\bsign[\s_-]?in\b/,
/\bemail\b/,
/\be-mail\b/,
/\baccount\b/,
/\bmember\b/,
/\bidentifier\b/
];
const nonLoginHintPatterns = [
/\bsearch\b/,
/\bquery\b/,
/\bfilter\b/,
/\bcomment\b/,
/\bmessage\b/,
/\bcontact\b/,
/\bcity\b/,
/\bstate\b/,
/\bpostal\b/,
/\bzip\b/,
/\bcoupon\b/,
/\bpromo\b/,
/\bnewsletter\b/,
/\bsubscribe\b/
];
const authScopePatterns = [
/\blog[\s_-]?in\b/,
/\bsign[\s_-]?in\b/,
/\bauth\b/,
/\bpassword\b/,
/\bpasscode\b/,
/\b2fa\b/,
/\btwo[\s-]?factor\b/,
/\bverify\b/,
/\baccount\b/
];
function describeFieldRole(input) {
const type = String(input?.getAttribute?.("type") || "").toLowerCase();
if (type === "password") {
return "password";
}
if (!textLikeInputType(type)) {
return "";
}
const hints = fieldHintText(input);
if (!hints) {
return "";
}
if (hintMatches(hints, nonLoginHintPatterns)) {
return "";
}
if (hintMatches(hints, usernameHintPatterns)) {
return "username";
}
return "";
}
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 authFlowCandidate(anchorInput) {
const scope = (typeof HTMLFormElement !== "undefined" && anchorInput?.form instanceof HTMLFormElement ? anchorInput.form : document);
const scopeInputs = resolveFormInputs(anchorInput);
const passwordInput = scopeInputs.find(isPasswordCandidate) || null;
if (!passwordInput) {
return null;
}
const associated = associatedFieldsForAnchor(anchorInput || passwordInput);
if (!hasAuthFlowSignals(associated.usernameInput, scope)) {
return null;
}
return {
usernameInput: associated.usernameInput,
passwordInput,
anchorInput: anchorInput || passwordInput,
scope
};
}
function loginCandidates() {
const candidates = [];
for (const passwordInput of visibleInputs(document).filter(isPasswordCandidate)) {
const candidate = authFlowCandidate(passwordInput);
if (!candidate) {
continue;
}
if (candidates.some((existing) => existing.passwordInput === candidate.passwordInput)) {
continue;
}
candidates.push(candidate);
}
return candidates;
}
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 (typeof HTMLInputElement === "undefined" || !(input instanceof HTMLInputElement)) {
return null;
}
const normalizedRole = normalizeRole(role || describeFieldRole(input));
const form = typeof HTMLFormElement !== "undefined" && 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);
if (!normalizedRole) {
return null;
}
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 explicitRole = describeFieldRole(activeUsable);
const activeTargets = activeUsable ? authFlowCandidate(activeUsable) : null;
const candidates = loginCandidates();
const chosen = activeTargets || candidates[0] || null;
const anchorInput = activeUsable || chosen?.passwordInput || chosen?.usernameInput || null;
const focusRole = explicitRole || describeFieldRole(anchorInput);
const focusTarget = anchorInput ? buildFieldDescriptor(anchorInput, focusRole) : null;
const roles = candidates.map((candidate) => {
const passwordDescriptor = buildFieldDescriptor(candidate.passwordInput, "password");
const usernameDescriptor = candidate.usernameInput ? buildFieldDescriptor(candidate.usernameInput, "username") : null;
return `${passwordDescriptor.formIndex}:${passwordDescriptor.fieldIndex}:password:${usernameDescriptor ? `${usernameDescriptor.formIndex}:${usernameDescriptor.fieldIndex}` : "-"}`;
});
return {
pageHasLoginForm: Boolean(chosen),
usernameInput: chosen?.usernameInput || null,
passwordInput: chosen?.passwordInput || null,
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 submittedCredential(candidate, rawURL) {
if (!candidate?.passwordInput) {
return null;
}
const password = String(candidate.passwordInput.value || "").trim();
if (!password) {
return null;
}
return {
title: domainLabel(rawURL),
username: String(candidate.usernameInput?.value || "").trim(),
password,
url: String(rawURL || "").trim()
};
}
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, idleHidden) {
if (suppressed || idleHidden || !hasTarget) {
return false;
}
return Boolean(
state?.pageHasLoginForm &&
(
state?.pendingFill ||
(state?.configured && state?.success && state?.status?.locked) ||
(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,
fieldHintText,
scopeHintText,
hasAuthFlowSignals,
authFlowCandidate,
submittedCredential
};
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 inlineIdleHidden = false;
let refreshTimer = null;
let idleHideTimer = 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 clearIdleHideTimer() {
if (idleHideTimer !== null) {
clearTimeout(idleHideTimer);
idleHideTimer = null;
}
}
function refreshInlineLifetime(shouldShow) {
clearIdleHideTimer();
if (!shouldShow || chooserOpen || pageState.pendingFill) {
return;
}
idleHideTimer = window.setTimeout(() => {
inlineIdleHidden = true;
hideDock();
}, 15000);
}
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, inlineIdleHidden);
if (!shouldShow) {
clearIdleHideTimer();
hideDock();
return;
}
ensureRootMounted();
dock.style.display = "block";
trigger.dataset.tone = pageState.pendingFill || pageState.status?.locked ? "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 if (pageState.status?.locked) {
meta.textContent = "Unlock KeePassGO";
panelCopy.textContent = "Unlock KeePassGO to turn this field back into live login suggestions.";
} 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();
refreshInlineLifetime(shouldShow);
}
function reportFieldState(force) {
const scan = scanLoginFields();
const nextTarget = JSON.stringify(scan.focusTarget || null);
if (scan.signature !== lastReportedSignature || nextTarget !== lastReportedTarget) {
inlineIdleHidden = false;
}
pageState = {
...pageState,
pageHasLoginForm: scan.pageHasLoginForm,
focusTarget: scan.focusTarget
};
renderInlineState();
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("submit", (event) => {
const form = event.target instanceof HTMLFormElement ? event.target : null;
if (!form) {
return;
}
const passwordInput = visibleInputs(form).find(isPasswordCandidate) || null;
const candidate = passwordInput ? authFlowCandidate(passwordInput) : null;
const observed = submittedCredential(candidate, window.location.href);
if (!observed) {
return;
}
void runtimeSend({
type: "keepassgo-observed-login",
observed
}).catch(() => null);
}, 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);
}
+136
View File
@@ -0,0 +1,136 @@
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("describeFieldRole only treats explicit account fields as usernames", () => {
const loginField = {
autocomplete: "username",
labels: [],
getAttribute(name) {
const attrs = {
type: "email",
id: "crew-email",
name: "email",
placeholder: "Email address",
"aria-label": "Email address"
};
return attrs[name] || "";
}
};
const searchField = {
autocomplete: "",
labels: [],
getAttribute(name) {
const attrs = {
type: "text",
id: "site-search",
name: "query",
placeholder: "Search casino news",
"aria-label": "Search"
};
return attrs[name] || "";
}
};
assert.equal(content.describeFieldRole(loginField), "username");
assert.equal(content.describeFieldRole(searchField), "");
});
test("hasAuthFlowSignals rejects generic password scopes and accepts sign-in scopes", () => {
const genericScope = {
getAttribute() {
return "";
},
querySelectorAll() {
return [{ textContent: "Confirm shipment" }];
}
};
const signInScope = {
getAttribute(name) {
const attrs = {
id: "signin-panel",
name: "signin",
action: "/session"
};
return attrs[name] || "";
},
querySelectorAll() {
return [{ textContent: "Sign in to the Bellagio vault" }];
}
};
assert.equal(content.hasAuthFlowSignals(null, genericScope), false);
assert.equal(content.hasAuthFlowSignals(null, signInScope), true);
assert.equal(content.hasAuthFlowSignals({ id: "danny-ocean" }, genericScope), true);
});
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), false);
});
test("shouldShowInlineOverlay stays visible for locked login pages", () => {
const state = {
pageHasLoginForm: true,
configured: true,
success: true,
status: { locked: true },
matches: [],
pendingFill: false
};
assert.equal(content.shouldShowInlineOverlay(state, true, false, false), true);
});
test("shouldShowInlineOverlay hides the page overlay after idle expiry", () => {
const state = {
pageHasLoginForm: true,
configured: true,
success: true,
status: { locked: false },
matches: [{ id: "rusty-ryan" }],
pendingFill: false
};
assert.equal(content.shouldShowInlineOverlay(state, true, false, false), true);
assert.equal(content.shouldShowInlineOverlay(state, true, false, true), false);
});
test("submittedCredential captures the pending browser save payload from a login candidate", () => {
const candidate = {
usernameInput: { value: "linuscaldwell" },
passwordInput: { value: "yellow-chip" }
};
assert.deepEqual(content.submittedCredential(candidate, "https://bellagio.example.invalid/login"), {
title: "bellagio.example.invalid",
username: "linuscaldwell",
password: "yellow-chip",
url: "https://bellagio.example.invalid/login"
});
});
Binary file not shown.

After

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

+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"
}
]
}
+55
View File
@@ -0,0 +1,55 @@
{
"manifest_version": 2,
"name": "KeePassGO Browser",
"version": "0.1.0",
"description": "Fill credentials from KeePassGO on sign-in pages.",
"icons": {
"16": "icons/icon-16.png",
"32": "icons/icon-32.png",
"48": "icons/icon-48.png",
"96": "icons/icon-96.png",
"128": "icons/icon-128.png"
},
"permissions": [
"activeTab",
"nativeMessaging",
"storage",
"tabs",
"http://*/*",
"https://*/*"
],
"background": {
"scripts": ["background.js"]
},
"browser_action": {
"default_title": "KeePassGO Browser",
"default_icon": {
"16": "icons/icon-16.png",
"32": "icons/icon-32.png"
},
"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",
"data_collection_permissions": {
"required": ["authenticationInfo", "websiteActivity"]
},
"strict_min_version": "140.0"
},
"gecko_android": {
"strict_min_version": "142.0"
}
}
}
+49
View File
@@ -0,0 +1,49 @@
<!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>
<fieldset>
<legend>Browser Matching</legend>
<label class="checkbox-row">
<input id="best-match-only" name="best-match-only" type="checkbox">
<span>Best match only</span>
</label>
<label class="checkbox-row">
<input id="require-scheme-match" name="require-scheme-match" type="checkbox">
<span>Require current scheme</span>
</label>
<label>
<span>Sort results</span>
<select id="sort-results" name="sort-results">
<option value="quality">KeePassGO match quality</option>
<option value="title">Title</option>
<option value="path">Path</option>
</select>
</label>
</fieldset>
<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>
+55
View File
@@ -0,0 +1,55 @@
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 || "";
document.getElementById("best-match-only").checked = Boolean(response.settings.bestMatchOnly);
document.getElementById("require-scheme-match").checked = Boolean(response.settings.requireSchemeMatch);
document.getElementById("sort-results").value = response.settings.sortResults || "quality";
}
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,
bestMatchOnly: document.getElementById("best-match-only").checked,
requireSchemeMatch: document.getElementById("require-scheme-match").checked,
sortResults: document.getElementById("sort-results").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();
+45
View File
@@ -0,0 +1,45 @@
<!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 id="save-card" class="save-card" hidden>
<div>
<h2>Save Submitted Login</h2>
<p id="save-message" class="subtle">KeePassGO can save this login.</p>
</div>
<button id="save-action" type="button">Save Login</button>
</section>
<section>
<h2>Matches</h2>
<div id="matches" class="match-list"></div>
</section>
<section class="search-section">
<h2>Search Vault</h2>
<form id="search-form" class="search-form">
<input id="search-query" type="search" placeholder="Search entries" autocomplete="off">
<button type="submit">Search</button>
</form>
<div id="search-results" class="match-list"></div>
</section>
</main>
<script src="popup.js"></script>
</body>
</html>
+299
View File
@@ -0,0 +1,299 @@
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 saveCardLabel(pendingSave) {
return pendingSave?.mode === "update"
? `Update ${pendingSave.title || "Login"}`
: "Save Login";
}
function renderMatchList(root, matches, options = {}) {
const targetTabID = popupTabID();
const emptyMessage = options.emptyMessage || "No matching entries.";
root.textContent = "";
if (!Array.isArray(matches) || matches.length === 0) {
const empty = document.createElement("p");
empty.className = "subtle";
empty.textContent = emptyMessage;
root.appendChild(empty);
return;
}
for (const match of 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;
try {
if (typeof options.onSelect === "function") {
await options.onSelect(match, targetTabID);
} else {
setStatus("Approval may be required", "KeePassGO will prompt if this token needs approval before fill.", "warning");
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(options.onSelect ? "Save failed" : "Fill failed", error instanceof Error ? error.message : String(error), "error");
} finally {
row.disabled = false;
}
});
root.appendChild(row);
}
}
function renderMatches(state) {
const emptyMessage = state.pageHasLoginForm
? "No matching entries for this page."
: "No login fields detected on this page.";
const root = document.getElementById("matches");
if (state.pendingSave) {
renderMatchList(root, state.matches, {
emptyMessage,
onSelect: async (match, targetTabID) => {
const result = await runtimeSend({
type: "keepassgo-save-login",
tabId: targetTabID,
selectedMatch: {
id: match.id,
title: match.title,
path: Array.isArray(match.path) ? match.path : []
}
});
if (!result?.success) {
throw new Error(result?.error || "Save failed.");
}
setStatus("Saved", `${state.pendingSave.title || "Login"} is now in KeePassGO.`, "ready");
document.getElementById("save-card").hidden = true;
}
});
return;
}
renderMatchList(root, state.matches, { emptyMessage });
}
function renderSearchResults(results, query) {
const root = document.getElementById("search-results");
if (!query) {
root.textContent = "";
const hint = document.createElement("p");
hint.className = "subtle";
hint.textContent = "Search all entries you can access with this token.";
root.appendChild(hint);
return;
}
renderMatchList(root, results, {
emptyMessage: `No entries matched "${query}".`
});
}
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 renderPendingSave(state) {
const card = document.getElementById("save-card");
const message = document.getElementById("save-message");
const action = document.getElementById("save-action");
const pendingSave = state.pendingSave;
if (!pendingSave) {
card.hidden = true;
action.onclick = null;
return;
}
card.hidden = false;
action.textContent = saveCardLabel(pendingSave);
if (pendingSave.mode === "update") {
message.textContent = `KeePassGO can update ${pendingSave.title || "this login"} with the submitted password.`;
} else if (Array.isArray(pendingSave.path) && pendingSave.path.length > 0) {
message.textContent = `KeePassGO can save this login in ${pendingSave.path.join(" / ")}. Search the vault to choose a different group if needed.`;
} else {
message.textContent = "Search the vault below to choose a group for this submitted login.";
}
action.disabled = pendingSave.mode !== "update" && (!Array.isArray(pendingSave.path) || pendingSave.path.length === 0);
action.onclick = async () => {
action.disabled = true;
try {
const result = await runtimeSend({
type: "keepassgo-save-login",
tabId: popupTabID()
});
if (!result?.success) {
throw new Error(result?.error || "Save failed.");
}
setStatus("Saved", `${pendingSave.title || "Login"} is now in KeePassGO.`, "ready");
card.hidden = true;
} catch (error) {
setStatus("Save failed", error instanceof Error ? error.message : String(error), "error");
} finally {
action.disabled = false;
}
};
}
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 searchVault(event) {
event.preventDefault();
const query = document.getElementById("search-query").value.trim();
const resultsRoot = document.getElementById("search-results");
if (!query) {
renderSearchResults([], "");
return;
}
resultsRoot.textContent = "";
const loading = document.createElement("p");
loading.className = "subtle";
loading.textContent = "Searching KeePassGO…";
resultsRoot.appendChild(loading);
try {
const response = await runtimeSend({
type: "keepassgo-search-logins",
query
});
if (!response?.success) {
throw new Error(response?.error || "Search failed.");
}
renderSearchResults(Array.isArray(response.results) ? response.results : [], query);
} catch (error) {
renderSearchResults([], query);
setStatus("Search failed", error instanceof Error ? error.message : String(error), "error");
}
}
async function main() {
try {
document.getElementById("search-form").addEventListener("submit", searchVault);
renderSearchResults([], "");
const state = await runtimeSend({
type: "keepassgo-popup-state",
force: true,
tabId: popupTabID()
});
document.getElementById("page-host").textContent = hostFromURL(state.pageUrl || "");
renderPageHint(state);
renderPendingSave(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 (state.pendingSave) {
setStatus("Save submitted login", state.pendingSave.mode === "update" ? `Update ${state.pendingSave.title || "this login"} or pick a different target below.` : "Save this submitted login or search below to choose a target entry.", "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();
+229
View File
@@ -0,0 +1,229 @@
: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;
}
.search-section {
margin-top: 16px;
}
.save-card {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 12px 14px;
margin: 0 0 16px;
border: 1px solid #c5dccf;
border-radius: 12px;
background: var(--accent-soft);
}
.search-form {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 8px;
margin-bottom: 10px;
}
.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,
select {
width: 100%;
padding: 10px 12px;
border: 1px solid var(--line);
border-radius: 10px;
background: #fff;
color: var(--ink);
font: inherit;
}
fieldset {
margin: 0;
padding: 12px;
border: 1px solid var(--line);
border-radius: 12px;
display: grid;
gap: 12px;
}
legend {
padding: 0 6px;
color: var(--ink-soft);
font-size: 12px;
text-transform: uppercase;
letter-spacing: 0.08em;
}
.checkbox-row {
display: flex;
align-items: center;
gap: 10px;
}
.checkbox-row input {
width: auto;
}
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)
}
+78
View File
@@ -0,0 +1,78 @@
# Android Autofill
## App Target Matching
User story:
- When an entry carries an Android-specific target such as
`androidapp://com.blinknetwork.mobile2`, KeePassGO should treat that as a
first-class autofill target on Android.
- If an exact app target exists, Android autofill should resolve that entry
directly instead of falling back to a generic chooser for the whole cache.
Expected behavior:
- `AndroidApp*` custom fields exported into the autofill cache must match the
Android package target used by the autofill and accessibility services.
- The Android-side matcher must normalize `androidapp://...` targets the same
way the Go cache builder does.
- The chooser path should still collapse to a single direct result when there
is one exact app-target match.
## Accessibility Fallback
User story:
- When Android accessibility fallback is needed, KeePassGO should not be
limited to Chrome-only URL bar parsing.
- Apps with stable package identities should still be fillable when an entry
carries a matching `AndroidApp*` target.
Expected behavior:
- Accessibility fallback derives its match target from the web domain when one
is available.
- If no web domain is available, accessibility fallback uses the active app
package as `androidapp://<package>`.
- The fallback path can therefore fill supported apps that never expose a
browser-style URL bar.
## Share-Driven Lookup
User story:
- When Android shares a login URL or a text snippet containing a login URL into
KeePassGO, the app should open into a credential lookup flow instead of only
supporting shared `.kdbx` imports.
- If the vault is already open, the shared target should immediately narrow the
entries view.
- If the vault is not open yet, the shared target should survive startup and
apply as soon as the vault is unlocked.
Expected behavior:
- Android share intents can queue a pending lookup target in addition to shared
vault file imports.
- KeePassGO normalizes the shared value into a search query that users can
immediately act on.
- The pending lookup is consumed once and does not keep reappearing on later
launches.
## Chooser Relevance
User story:
- When Android autofill cannot resolve a single direct match, KeePassGO should
still keep the chooser focused on entries that are relevant to the current
site or app.
- The picker should not fall back to an alphabetized dump of unrelated vault
entries when KeePassGO already knows the current host or package target.
Expected behavior:
- If multiple entries exactly match the current web host or Android app target,
the chooser shows only those relevant entries.
- If there are no exact matches but there are parent-host matches, the chooser
shows only those related entries.
- KeePassGO falls back to the full chooser list only when it has no related
host or app-target candidates at all.
+208
View File
@@ -0,0 +1,208 @@
# 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.
## Search And Matching
User story:
- When a page has no obvious match, the popup still lets the user search the
vault without leaving the browser.
- Search results must stay scoped to what the current API token can actually
access.
- Browser matching must treat common KeePass data conventions as real browser
targets, not just the primary `URL` field.
- Users need explicit control over how aggressive browser matching should be so
they can prefer narrow page suggestions or broader candidate lists without
reconfiguring KeePassGO itself.
Expected behavior:
- The popup exposes a `Search Vault` field that queries KeePassGO directly.
- Search results use the same fill path as page matches.
- Search never leaks entries outside the token's authorized group scope.
- A browser match can come from:
- the primary `URL` field
- scheme-less host values such as `gitlab.com`
- custom URL fields such as `URL1`, `URL2`, and similar KeePass-style URL
slots
- The extension settings page exposes browser match controls for:
- `Best match only`
- `Require current scheme`
- `Sort results`
- `Best match only` limits page suggestions to the strongest quality band
returned by KeePassGO instead of showing every candidate for the host.
- `Require current scheme` hides `http` credentials on `https` pages and
vice versa when an entry stores an explicit scheme, while still allowing
scheme-less host entries to match either page.
- `Sort results` affects both page suggestions and popup search results so the
user can prefer:
- KeePassGO match quality first
- title order
- path order
## Locked Vault Workflow
User story:
- When the current page has a login form but KeePassGO is locked, the browser
must still make that state visible on the page and in the popup.
- Unlocking KeePassGO should not require the user to reopen the popup multiple
times or reload the page before the extension becomes usable again.
Expected behavior:
- The popup shows a locked-state message instead of silently falling back to
"no matches."
- The inline page affordance stays visible on login forms while KeePassGO is
locked and tells the user to unlock the vault.
- After the vault is unlocked, the extension rechecks the page automatically
and turns the locked affordance back into live matches without requiring a
page reload.
## Save And Update Workflow
User story:
- After the user submits a login form, the browser extension should help store
that credential instead of forcing the user back into KeePassGO manually.
- If KeePassGO already has a matching entry for that site and username, the
popup should offer an update.
- If the user is creating a new login, the popup should let the user save it
into a relevant vault group without leaving the browser.
Expected behavior:
- Submitted login forms queue a pending browser save/update state for the
active tab.
- The popup shows that pending save/update state prominently instead of hiding
it behind page matches alone.
- When KeePassGO finds an exact browser match for the submitted username and
site, the popup offers an `Update` action for that entry.
- When there is no exact entry match, the popup offers a `Save` action using a
relevant group path from the current page matches or a user-selected search
result.
- The browser save/update action writes through KeePassGO's existing secure
gRPC mutation API and stays scoped to the browser token's allowed groups.
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
+207
View File
@@ -0,0 +1,207 @@
# GUI Test Plan
This document splits GUI validation into human-owned and agent-owned coverage.
The intent is that, together, both passes exercise every reachable GUI area
without relying on one side to judge both functional correctness and real
usability.
## Scope
- Desktop GUI
- Android GUI
- Shared workflow surfaces:
lifecycle, unlock, list/detail/editor, settings, sync, API tokens, API audit,
templates, recycle bin, About, and platform-specific integrations
## Ownership Model
- Human testing covers visual correctness, ergonomics, discoverability, focus,
scrolling, tap targets, keyboard feel, and platform integration behavior.
- Agent testing covers deterministic automation:
build, install, launch, focus, test suite, lint, and release-path validation.
## Android Plan
### Coverage Matrix
| Area | Human | Agent |
|---|---|---|
| App launch and first render | Verify real rendering, layout, readability, no black screen | Verify emulator online, app installed, app focused |
| Lifecycle screen | Exercise create/open/open-remote forms visually | Cover lifecycle logic through Go tests and release APK build |
| Unlock flow | Verify password/key-file affordances, focus, error messaging | Covered by automated tests |
| Vault list and navigation | Verify scrolling, selection, section switching, phone layout reachability | Covered by automated tests for list state/search/section behavior |
| Entry detail | Verify readability, action placement, tap targets | Covered by automated tests for state transitions and copy/generate actions |
| Entry editor | Verify field editing usability and save affordance | Covered by automated tests for save/update behavior |
| Template views | Verify template list/editor reachability and copy | Covered by automated tests |
| Recycle bin | Verify deleted-entry browsing/search UX | Covered by automated tests |
| Attachments UI | Verify list/add/remove affordances visually | Covered by automated tests for attachment summaries and save paths |
| Search | Verify placeholder, live filtering feel, clear behavior | Covered by automated tests for search semantics |
| Settings | Verify toggles and summaries are understandable | Covered by automated tests for persistence |
| Remote sync setup/dialog | Verify sheet/dialog layout, field order, source/direction controls | Covered by automated tests and release build |
| Saved remote binding UI | Verify summaries are understandable and discoverable | Covered by automated tests |
| API tokens / API audit | Verify navigation and dense-detail readability | Covered by automated tests |
| About / informational screens | Verify copy, scroll, layout | Covered by automated tests for search disable state and section state |
| Android-only share/import/file picker | Verify system integration actually works | Agent only verifies package/build/focus, not picker UX |
| Android autofill entry point | Verify Android mechanism appears and is usable | Agent only verifies app/package/focus |
### Human Steps
1. Start from the currently installed `org.julianfamily.keepassgo` app on the
running emulator or device.
2. Verify launch lands on a rendered screen, not a black frame, blank frame, or
frozen frame.
3. On the lifecycle screen, inspect every visible action:
`Create vault`, `Open vault`, `Open remote vault`, unlock-related controls,
recent vaults, and any sync summaries.
4. Create a throwaway vault or open a demo vault and verify the full unlock path
is visually clear.
5. After unlock, visit each top-level section you can reach in the Android UI:
vault/group list, entry detail, entry editor, templates, recycle bin,
settings, API tokens, API audit, About.
6. In the main vault UI, verify:
group navigation, entry selection, search, back behavior, scrolling, and any
phone-only toggles or drawers.
7. Open an entry and exercise every visible entry action:
copy username, copy password, copy URL, reveal/hide password, generate
password, save.
8. Exercise one template flow:
open template list, inspect template detail/editor, save or cancel.
9. Exercise recycle-bin browsing and search.
10. Open settings and inspect every visible toggle or summary card. Change at
least one benign toggle and verify it sticks after leaving and returning.
11. Open the sync UI and inspect:
remote setup, saved-binding summary, direction/source choices,
confirm/cancel behavior, and text summaries.
12. Exercise one Android-native integration:
shared-vault import, file picker open, or current-vault share.
13. If Android autofill is in scope for current testing, open a target app/site
and confirm the Android autofill entry point is offered and usable.
14. Record failures with:
exact screen, exact control, expected behavior, actual behavior, and whether
it is Android-only or likely shared with desktop.
### Agent Steps Executed
1. Verified emulator availability with `adb devices -l`.
2. Verified emulator Android version with
`adb shell getprop ro.build.version.release`.
3. Verified app package installed with
`adb shell pm list packages org.julianfamily.keepassgo`.
4. Verified app focus with
`adb shell dumpsys window | rg 'mCurrentFocus|mFocusedApp'`.
5. Ran `go test ./...`.
6. Ran `go tool golangci-lint run ./...`.
7. Verified release APK path through release workflow `v0.6.0`:
PR run `60` succeeded,
post-merge `main` run `61` succeeded,
tag run `62` succeeded.
### Agent Results
- Emulator present: yes
- Android version: `15`
- App installed: yes
- App focused:
`org.julianfamily.keepassgo/org.gioui.GioActivity`
- APK release build path: passed in release CI
- Remaining Android risk:
real visual usability and system-integration behavior
## Desktop Plan
### Coverage Matrix
| Area | Human | Agent |
|---|---|---|
| App launch and window presentation | Verify startup polish, focus, resize behavior, DPI/readability | Verify desktop binaries built in release CI |
| Lifecycle screen | Verify desktop layout density, affordance clarity, recent vault usability | Covered by automated tests |
| Unlock flow | Verify keyboard-first behavior, focus order, errors, lock/unlock loop | Covered by automated tests plus human keyboard feel |
| Group browser and list pane | Verify density, selection clarity, scrolling, split behavior | Covered by automated tests for state/search |
| Entry detail pane | Verify copy actions, reveal/hide, path context, readability | Covered by automated tests |
| Entry editor | Verify field order, keyboard flow, save/cancel usability | Covered by automated tests |
| Templates | Verify templates section is reachable and understandable | Covered by automated tests |
| Recycle bin | Verify browsing, searching, deletion or recovery UX | Covered by automated tests |
| Search | Verify keyboard search flow, placeholder correctness, result context | Covered by automated tests |
| Vault save/save-as/lock | Verify menus, buttons, and shortcuts feel correct | Covered by automated tests and release builds |
| Settings | Verify desktop layout, summaries, toggles, persistence feel | Covered by automated tests |
| Remote sync UI | Verify dialog layout, wording, discoverability, advanced options | Covered by automated tests |
| API tokens | Verify dense-detail presentation and policy editor usability | Covered by automated tests |
| API audit | Verify search/filter/readability | Covered by automated tests |
| Browser-extension-adjacent desktop UX | Verify visible status/help text and extension workflow discoverability | Agent validated release/build path; human should judge usability |
| About and docs entry points | Verify copy and layout | Covered partly by tests; human judges presentation |
### Human Steps
1. Launch KeePassGO on desktop from the shipped binary or normal desktop entry.
2. Inspect the lifecycle/open screen at normal window size and at a narrower
width.
3. Exercise create, open, save-as, lock, unlock, and reopen on a throwaway
vault or demo vault.
4. Use keyboard-first operation for at least one complete pass:
tab order, enter or escape expectations, search focus, unlock focus, and
editor save flow.
5. After unlocking, visit every reachable primary section:
vault list, entry detail, editor, templates, recycle bin, settings,
API tokens, API audit, About.
6. In the vault browser, verify:
nested groups, path context, scrolling, selection state, and search results
with path context.
7. Open an entry and exercise all visible actions:
copy username, password, URL, reveal/hide password, password generation,
and save.
8. Exercise at least one mutation flow:
create entry, edit entry, move/delete entry, view recycle bin, and recover
if available.
9. Open settings and inspect all visible summaries and toggles.
10. Open remote sync UI and inspect every visible mode:
setup, saved binding, advanced sync, source or direction choices.
11. Open API Tokens and API Audit even if you do not issue a real token, just
to assess navigation and readability.
12. If you use browser integration, verify the desktop-side flow is still
understandable from the product UI and extension behavior.
13. Record failures with:
screen, control, expected behavior, actual behavior, and whether it is
presentation-only or functional.
### Agent Steps Executed
1. Verified clean repo state on `main`.
2. Ran `go test ./...`.
3. Ran `go tool golangci-lint run ./...`.
4. Verified post-merge `main` CI run `61` succeeded.
5. Verified release tag run `62` succeeded.
6. Verified release `v0.6.0` published.
7. Verified release artifacts include:
`keepassgo-linux-amd64`,
`keepassgo-windows-amd64.exe`,
`keepassgo-windows-arm64.exe`,
and `keepassgo.apk`.
### Agent Results
- Automated logic/state coverage: pass
- Desktop build coverage: pass
- Release publication: pass
- Remaining desktop risk:
interaction quality, keyboard feel, dense-layout readability, and workflow
discovery
## Reporting Template
Use this format when reporting findings from the human pass:
```md
## Android
- Screen:
- Action:
- Expected:
- Actual:
- Severity:
## Desktop
- Screen:
- Action:
- Expected:
- Actual:
- Severity:
```
+5 -2
View File
@@ -2,8 +2,12 @@ module git.julianfamily.org/keepassgo
go 1.26
replace gioui.org => git.julianfamily.org/joejulian/gio-patched v0.9.1-0.20260416220049-9bfa6bc1c2dc
replace gioui.org/cmd => git.julianfamily.org/joejulian/gio-cmd-patched v0.9.1-0.20260417040456-1762d36ddecc
require (
gioui.org v0.8.0
gioui.org v0.9.0
gioui.org/x v0.8.0
github.com/atotto/clipboard v0.1.4
github.com/tobischo/gokeepasslib/v3 v3.6.2
@@ -193,7 +197,6 @@ require (
go.uber.org/multierr v1.6.0 // indirect
go.uber.org/zap v1.24.0 // indirect
golang.org/x/crypto v0.48.0 // indirect
golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 // indirect
golang.org/x/exp/typeparams v0.0.0-20250210185358-939b2ce775ac // indirect
golang.org/x/image v0.37.0 // indirect
golang.org/x/mod v0.33.0 // indirect
+18 -18
View File
@@ -37,15 +37,15 @@ cloud.google.com/go/storage v1.10.0/go.mod h1:FLPqc6j+Ki4BU591ie1oL6qBQGu2Bl/tZ9
dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU=
eliasnaur.com/font v0.0.0-20230308162249-dd43949cb42d h1:ARo7NCVvN2NdhLlJE9xAbKweuI9L6UgfTbYb0YwPacY=
eliasnaur.com/font v0.0.0-20230308162249-dd43949cb42d/go.mod h1:OYVuxibdk9OSLX8vAqydtRPP87PyTFcT9uH3MlEGBQA=
gioui.org v0.8.0 h1:QV5p5JvsmSmGiIXVYOKn6d9YDliTfjtLlVf5J+BZ9Pg=
gioui.org v0.8.0/go.mod h1:vEMmpxMOd/iwJhXvGVIzWEbxMWhnMQ9aByOGQdlQ8rc=
gioui.org/cmd v0.8.0 h1:oy5qOlc1UXcglc5HBCMZQELiIzQ2obhT98mw+SuWafQ=
gioui.org/cmd v0.8.0/go.mod h1:wKLAyAgRR25VMYFzGX2Ecia0m0Td562wDcZ3LaPHPTI=
gioui.org/cpu v0.0.0-20210808092351-bfe733dd3334/go.mod h1:A8M0Cn5o+vY5LTMlnRoK3O5kG+rH0kWfJjeKd9QpBmQ=
gioui.org/shader v1.0.8 h1:6ks0o/A+b0ne7RzEqRZK5f4Gboz2CfG+mVliciy6+qA=
gioui.org/shader v1.0.8/go.mod h1:mWdiME581d/kV7/iEhLmUgUK5iZ09XR5XpduXzbePVM=
gioui.org/x v0.8.0 h1:RhIlQNOFKKn8D8FeaKKaXCo7vB3x+fq4VcD10HW/YpA=
gioui.org/x v0.8.0/go.mod h1:aXtQb+kyqoUOjDl5/uMqAopjzVzMkeHBbMQOGT5KnSE=
git.julianfamily.org/joejulian/gio-cmd-patched v0.9.1-0.20260417040456-1762d36ddecc h1:jyfCTx9wk/uLaEMkdKsg491C/kjfbG2EKAVTORhZxHo=
git.julianfamily.org/joejulian/gio-cmd-patched v0.9.1-0.20260417040456-1762d36ddecc/go.mod h1:RBQfFU8JCgMjQ2wKU9DG3zMC38TnY97E5MKoBGhGl3s=
git.julianfamily.org/joejulian/gio-patched v0.9.1-0.20260416220049-9bfa6bc1c2dc h1:p2AaZUAXa/ExPybNyeB05+GjTSZGA9lCfDpWz49IT5Y=
git.julianfamily.org/joejulian/gio-patched v0.9.1-0.20260416220049-9bfa6bc1c2dc/go.mod h1:BdI7mF5DCa3kxlo3G93XHL7khtZnk1gu4335pExk8gs=
git.wow.st/gmp/jni v0.0.0-20210610011705-34026c7e22d0 h1:bGG/g4ypjrCJoSvFrP5hafr9PPB5aw8SjcOWWila7ZI=
git.wow.st/gmp/jni v0.0.0-20210610011705-34026c7e22d0/go.mod h1:+axXBRUTIDlCeE73IKeD/os7LoEnTKdkp8/gQOFjqyo=
github.com/4meepo/tagalign v1.4.2 h1:0hcLHPGMjDyM1gHG58cS73aQF8J4TdVR96TZViorO9E=
@@ -132,10 +132,12 @@ github.com/charithe/durationcheck v0.0.10 h1:wgw73BiocdBDQPik+zcEoBG/ob8uyBHf2iy
github.com/charithe/durationcheck v0.0.10/go.mod h1:bCWXb7gYRysD1CU3C+u4ceO49LoGOY1C1L6uouGNreQ=
github.com/chavacava/garif v0.1.0 h1:2JHa3hbYf5D9dsgseMKAmc/MZ109otzgNFk5s87H9Pc=
github.com/chavacava/garif v0.1.0/go.mod h1:XMyYCkEL58DF0oyW4qDjjnPWONs2HBqYKI+UIPD+Gww=
github.com/chromedp/cdproto v0.0.0-20191114225735-6626966fbae4 h1:QD3KxSJ59L2lxG6MXBjNHxiQO2RmxTQ3XcK+wO44WOg=
github.com/chromedp/cdproto v0.0.0-20191114225735-6626966fbae4/go.mod h1:PfAWWKJqjlGFYJEidUM6aVIWPr0EpobeyVWEEmplX7g=
github.com/chromedp/chromedp v0.5.2 h1:W8xBXQuUnd2dZK0SN/lyVwsQM7KgW+kY5HGnntms194=
github.com/chromedp/chromedp v0.5.2/go.mod h1:rsTo/xRo23KZZwFmWk2Ui79rBaVRRATCjLzNQlOFSiA=
github.com/chromedp/cdproto v0.0.0-20250429231605-6ed5b53462d4 h1:UZdrvid2JFwnvPlUSEFlE794XZL4Jmrj8fuxfcLECJE=
github.com/chromedp/cdproto v0.0.0-20250429231605-6ed5b53462d4/go.mod h1:NItd7aLkcfOA/dcMXvl8p1u+lQqioRMq/SqDp71Pb/k=
github.com/chromedp/chromedp v0.13.6 h1:xlNunMyzS5bu3r/QKrb3fzX6ow3WBQ6oao+J65PGZxk=
github.com/chromedp/chromedp v0.13.6/go.mod h1:h8GPP6ZtLMLsU8zFbTcb7ZDGCvCy8j/vRoFmRltQx9A=
github.com/chromedp/sysutil v1.1.0 h1:PUFNv5EcprjqXZD9nJb9b/c9ibAbxiYo4exNWZyipwM=
github.com/chromedp/sysutil v1.1.0/go.mod h1:WiThHUdltqCNKGc4gaU50XgYjwjYIhKWoHGPTUfWTJ8=
github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI=
github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI=
github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU=
@@ -180,6 +182,8 @@ github.com/go-critic/go-critic v0.12.0/go.mod h1:DpE0P6OVc6JzVYzmM5gq5jMU31zLr4a
github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU=
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
github.com/go-json-experiment/json v0.0.0-20250417205406-170dfdcf87d1 h1:+VexzzkMLb1tnvpuQdGT/DicIRW7MN8ozsXqBMgp0Hk=
github.com/go-json-experiment/json v0.0.0-20250417205406-170dfdcf87d1/go.mod h1:TiCD2a1pcmjd7YnhGH0f/zKNcCD06B029pHhzV23c2M=
github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as=
github.com/go-kit/kit v0.9.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as=
github.com/go-kit/log v0.1.0/go.mod h1:zbhenjAZHb184qTLMA9ZjW7ThYL0H2mk7Q6pNt4vbaY=
@@ -224,12 +228,12 @@ github.com/go-xmlfmt/xmlfmt v1.1.3 h1:t8Ey3Uy7jDSEisW2K3somuMKIpzktkWptA0iFCnRUW
github.com/go-xmlfmt/xmlfmt v1.1.3/go.mod h1:aUCEOzzezBEjDBbFBoSiya/gduyIiWYRP6CnSFIV8AM=
github.com/gobwas/glob v0.2.3 h1:A4xDbljILXROh+kObIiy5kIaPYD8e96x1tgBhUI5J+Y=
github.com/gobwas/glob v0.2.3/go.mod h1:d3Ez4x06l9bZtSvzIay5+Yzi0fmZzPgnTbPcKjJAkT8=
github.com/gobwas/httphead v0.0.0-20180130184737-2c6c146eadee h1:s+21KNqlpePfkah2I+gwHF8xmJWRjooY+5248k6m4A0=
github.com/gobwas/httphead v0.0.0-20180130184737-2c6c146eadee/go.mod h1:L0fX3K22YWvt/FAX9NnzrNzcI4wNYi9Yku4O0LKYflo=
github.com/gobwas/pool v0.2.0 h1:QEmUOlnSjWtnpRGHF3SauEiOsy82Cup83Vf2LcMlnc8=
github.com/gobwas/pool v0.2.0/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw=
github.com/gobwas/ws v1.0.2 h1:CoAavW/wd/kulfZmSIBt6p24n4j7tHgNVCjsfHVNUbo=
github.com/gobwas/ws v1.0.2/go.mod h1:szmBTxLgaFppYjEmNtny/v3w89xOydFnnZMcgRRu/EM=
github.com/gobwas/httphead v0.1.0 h1:exrUm0f4YX0L7EBwZHuCF4GDp8aJfVeBrlLQrs6NqWU=
github.com/gobwas/httphead v0.1.0/go.mod h1:O/RXo79gxV8G+RqlR/otEwx4Q36zl9rqC5u12GKvMCM=
github.com/gobwas/pool v0.2.1 h1:xfeeEhW7pwmX8nuLVlqbzVc7udMDrwetjEv+TZIz1og=
github.com/gobwas/pool v0.2.1/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw=
github.com/gobwas/ws v1.4.0 h1:CTaoG1tojrh4ucGPcoJFiAQUAsEWekEWvLy7GsVNqGs=
github.com/gobwas/ws v1.4.0/go.mod h1:G3gNqMNtPppf5XUz7O4shetPpcZ1VJ7zt18dlUeakrc=
github.com/godbus/dbus/v5 v5.0.6 h1:mkgN1ofwASrYnJ5W6U/BxG15eXXXjirgZc7CLqkcaro=
github.com/godbus/dbus/v5 v5.0.6/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/gofrs/flock v0.12.1 h1:MTLVXXHf8ekldpJk3AKicLij9MdwOWkZ+a/jHHZby9E=
@@ -370,8 +374,6 @@ github.com/kisielk/errcheck v1.9.0/go.mod h1:kQxWMMVZgIkDq7U8xtG/n2juOjbLgZtedi0
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
github.com/kkHAIKE/contextcheck v1.1.6 h1:7HIyRcnyzxL9Lz06NGhiKvenXq7Zw6Q0UQu/ttjfJCE=
github.com/kkHAIKE/contextcheck v1.1.6/go.mod h1:3dDbMRNBFaq8HFXWC1JyvDSPm43CmE6IuHam8Wr0rkg=
github.com/knq/sysutil v0.0.0-20191005231841-15668db23d08 h1:V0an7KRw92wmJysvFvtqtKMAPmvS5O0jtB0nYo6t+gs=
github.com/knq/sysutil v0.0.0-20191005231841-15668db23d08/go.mod h1:dFWs1zEqDjFtnBXsd1vPOZaLsESovai349994nHx3e0=
github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
github.com/konsorten/go-windows-terminal-sequences v1.0.3/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc=
@@ -404,8 +406,6 @@ github.com/macabu/inamedparam v0.1.3 h1:2tk/phHkMlEL/1GNe/Yf6kkR/hkcUdAEY3L0hjYV
github.com/macabu/inamedparam v0.1.3/go.mod h1:93FLICAIk/quk7eaPPQvbzihUdn/QkGDwIZEoLtpH6I=
github.com/magiconair/properties v1.8.6 h1:5ibWZ6iY0NctNGWo87LalDlEZ6R41TqbbDamhfG/Qzo=
github.com/magiconair/properties v1.8.6/go.mod h1:y3VJvCyxH9uVvJTWEGAELF3aiYNyPKd5NZ3oSwXrF60=
github.com/mailru/easyjson v0.7.0 h1:aizVhC/NAAcKWb+5QsU1iNOZb4Yws5UO2I+aIprQITM=
github.com/mailru/easyjson v0.7.0/go.mod h1:KAzv3t3aY1NaHWoQz1+4F1ccyAH66Jk7yos7ldAVICs=
github.com/maratori/testableexamples v1.0.0 h1:dU5alXRrD8WKSjOUnmJZuzdxWOEQ57+7s93SLMxb2vI=
github.com/maratori/testableexamples v1.0.0/go.mod h1:4rhjL1n20TUTT4vdh3RDqSizKLyXp7K2u6HgraZCGzE=
github.com/maratori/testpackage v1.1.1 h1:S58XVV5AD7HADMmD0fNnziNHqKvSdDuEKdPD1rNTU04=
+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)
}
}
+655 -130
View File
File diff suppressed because it is too large Load Diff
+773 -2
View File
@@ -5,8 +5,10 @@ import (
"context"
"crypto/sha256"
"encoding/hex"
"errors"
"net"
"os"
"slices"
"testing"
"time"
@@ -99,6 +101,630 @@ 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 TestVaultServiceFindsBrowserLoginsForCustomURLFields(t *testing.T) {
t.Parallel()
client, _, cleanup := newTestClientForModel(t, vault.Model{
Entries: []vault.Entry{
{
ID: "night-fox-gitlab",
Title: "Night Fox GitLab",
Username: "nightfox",
Password: "vault-code",
Path: []string{"Root", "Internet"},
Fields: map[string]string{
"URL1": "gitlab.com",
},
},
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.ResourceGroup, Path: []string{"Root"}}},
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationCopyPassword, 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 != "night-fox-gitlab" {
t.Fatalf("FindBrowserLogins().Matches[0].Id = %q, want night-fox-gitlab", resp.Matches[0].Id)
}
credential, err := client.GetBrowserCredential(tokenContext(defaultTestTokenSecret), &keepassgov1.GetBrowserCredentialRequest{
Id: "night-fox-gitlab",
PageUrl: "https://gitlab.com/users/sign_in",
})
if err != nil {
t.Fatalf("GetBrowserCredential() error = %v", err)
}
if credential.GetId() != "night-fox-gitlab" {
t.Fatalf("GetBrowserCredential().Id = %q, want night-fox-gitlab", credential.GetId())
}
}
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{"Root", "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 TestVaultServiceApprovalRequestsUseLogicalRootPathForPhysicalVault(t *testing.T) {
t.Parallel()
model := 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),
},
Groups: [][]string{
{"keepass"},
{"keepass", "Joe"},
{"keepass", "Joe", "codex"},
},
}
client, _, service, cleanup := newTestHarnessForModel(t, model)
defer cleanup()
service.approvals = apiapproval.NewBroker(time.Minute)
respCh := make(chan *keepassgov1.ListEntriesResponse, 1)
errCh := make(chan error, 1)
go func() {
resp, err := client.ListEntries(tokenContext(defaultTestTokenSecret), &keepassgov1.ListEntriesRequest{
Path: []string{"Joe", "codex"},
})
respCh <- resp
errCh <- err
}()
pending := waitForServerPendingApproval(t, service, 1)[0]
if got := pending.Resource.Path; !slices.Equal(got, []string{"Root", "Joe", "codex"}) {
t.Fatalf("pending.Resource.Path = %v, want [Root Joe codex]", got)
}
if _, _, err := service.ResolveApproval(pending.ID, apiapproval.OutcomeAllowOnce); err != nil {
t.Fatalf("ResolveApproval(allow once) error = %v", err)
}
if err := <-errCh; err != nil {
t.Fatalf("ListEntries() error = %v", err)
}
resp := <-respCh
if len(resp.GetEntries()) != 1 || resp.GetEntries()[0].GetId() != "codex-nextcloud" {
t.Fatalf("ListEntries().Entries = %#v, want codex-nextcloud after approval", resp.GetEntries())
}
}
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{"Root", "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{"Root", "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{"Root"}}},
),
},
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{"Root"}}},
),
},
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()
@@ -626,6 +1252,51 @@ func TestVaultServiceListsEntriesForAuthorizedClients(t *testing.T) {
}
}
func TestVaultServiceSearchesEntriesWithinAuthorizedScope(t *testing.T) {
t.Parallel()
client, _, cleanup := newTestClientForModel(t, vault.Model{
Entries: []vault.Entry{
{
ID: "turk-codex",
Title: "Turk Codex GitLab",
Username: "basher",
Password: "chip-stack",
URL: "https://gitlab.com",
Path: []string{"keepass", "Joe", "codex"},
},
{
ID: "rusty-internet",
Title: "Rusty Internet GitLab",
Username: "rusty",
Password: "bellagio-stack",
URL: "https://gitlab.com",
Path: []string{"keepass", "Joe", "Internet"},
},
testAPITokenEntry(t,
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationListEntries, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root", "Joe", "codex"}}},
),
},
})
defer cleanup()
resp, err := client.ListEntries(tokenContext(defaultTestTokenSecret), &keepassgov1.ListEntriesRequest{
Query: "GitLab",
})
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].Id; got != "turk-codex" {
t.Fatalf("ListEntries().Entries[0].Id = %q, want turk-codex", got)
}
if got := resp.Entries[0].Path; !slices.Equal(got, []string{"Joe", "codex"}) {
t.Fatalf("ListEntries().Entries[0].Path = %v, want [Joe codex]", got)
}
}
func TestVaultServiceListsCreatesAndRenamesGroupsForAuthorizedClients(t *testing.T) {
t.Parallel()
@@ -802,6 +1473,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{"Root", "Joe", "codex"}}},
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationListEntries, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root", "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 +1855,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 +1896,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 +1939,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 {
+385 -141
View File
@@ -8,8 +8,10 @@ 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/vaultview"
"git.julianfamily.org/keepassgo/internal/webdav"
)
@@ -29,6 +31,9 @@ const (
SectionAbout Section = "about"
)
const entriesRootLabel = "Root"
const templatesRootLabel = "Templates"
type CurrentSession interface {
Current() (vault.Model, error)
}
@@ -54,6 +59,15 @@ type SaveableSession interface {
Save() error
}
type AutoSaveableSession interface {
SaveableSession
HasSaveTarget() bool
}
type RemoteAwareSession interface {
IsRemote() bool
}
type SynchronizableSession interface {
CurrentSession
Synchronize() error
@@ -88,6 +102,10 @@ type RemoteOpenableSession interface {
OpenRemote(webdav.Client, string, vault.MasterKey) error
}
type WarningSession interface {
ConsumeWarning() string
}
type SecurityConfigurableSession interface {
ConfigureSecurity(vault.SecuritySettings) error
SecuritySettings() vault.SecuritySettings
@@ -101,6 +119,8 @@ type ApprovalManager interface {
type State struct {
Session CurrentSession
Approvals ApprovalManager
AuditLog *apiaudit.Log
AutoSaveRemote bool
Section Section
CurrentPath []string
SearchQuery string
@@ -180,115 +200,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 +341,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) {
@@ -350,7 +383,7 @@ func (s *State) VisibleEntries() ([]vault.Entry, error) {
}
if s.Section == SectionEntries {
return entriesInPath(model.Entries, s.CurrentPath), nil
return entriesInPath(entries, logicalEntriesPathForModel(model, s.CurrentPath)), nil
}
if s.Section == SectionRecycleBin || len(s.CurrentPath) == 0 {
return entries, nil
@@ -370,13 +403,13 @@ func (s *State) ChildGroups() ([]string, error) {
}
if s.Section != SectionEntries {
if s.Section == SectionTemplates && len(s.CurrentPath) == 0 {
return childGroups(s.entriesForSection(model), []string{"Templates"}), nil
if s.Section == SectionTemplates {
return vaultview.VaultTemplates(model).ChildGroups(templatesViewPath(s.CurrentPath)), nil
}
return childGroups(s.entriesForSection(model), s.CurrentPath), nil
}
return model.ChildGroups(s.CurrentPath), nil
return vaultview.VaultRoot(model).ChildGroups(entriesViewPathForModel(model, s.CurrentPath)), nil
}
func (s *State) SelectVisibleIndex(index int) error {
@@ -420,13 +453,13 @@ func (s *State) currentModel() (vault.Model, error) {
func (s *State) entriesForSection(model vault.Model) []vault.Entry {
switch s.Section {
case SectionTemplates:
return slices.Clone(model.Templates)
return logicalTemplateEntries(vaultview.VaultTemplates(model).EntriesUnderPath(nil))
case SectionRecycleBin:
return slices.Clone(model.RecycleBin)
return logicalEntries(vaultview.VaultRecycleBin(model).EntriesUnderPath(nil))
case SectionAPITokens, SectionAPIAudit, SectionAbout:
return nil
default:
return slices.Clone(model.Entries)
return logicalEntries(vaultview.VaultRoot(model).EntriesUnderPath(nil))
}
}
@@ -434,11 +467,11 @@ func (s State) SearchPathContext(entry vault.Entry) string {
path := slices.Clone(entry.Path)
switch s.Section {
case SectionTemplates:
if len(path) == 0 || path[0] != "Templates" {
path = append([]string{"Templates"}, path...)
}
path = logicalTemplatePath(path)
case SectionRecycleBin:
path = append([]string{"Recycle Bin"}, path...)
path = append([]string{"Recycle Bin"}, logicalEntriesPath(path)...)
case SectionEntries:
path = logicalEntriesPath(path)
}
return strings.Join(path, " / ")
}
@@ -495,6 +528,163 @@ func filterEntries(entries []vault.Entry, query string) []vault.Entry {
return out
}
func logicalEntriesPathForModel(model vault.Model, path []string) []string {
if len(path) == 0 {
return []string{entriesRootLabel}
}
if path[0] == entriesRootLabel {
return append([]string(nil), path...)
}
if usesPhysicalEntriesRoot(model) && path[0] == vaultview.KeepassRoot {
path = path[1:]
}
return append([]string{entriesRootLabel}, append([]string(nil), path...)...)
}
func logicalEntriesPath(path []string) []string {
if len(path) == 0 {
return []string{entriesRootLabel}
}
if path[0] == entriesRootLabel {
return append([]string(nil), path...)
}
if path[0] == vaultview.KeepassRoot {
path = path[1:]
}
return append([]string{entriesRootLabel}, append([]string(nil), path...)...)
}
func logicalTemplatePath(path []string) []string {
if len(path) == 0 {
return []string{templatesRootLabel}
}
if path[0] == templatesRootLabel {
return append([]string(nil), path...)
}
return append([]string{templatesRootLabel}, append([]string(nil), path...)...)
}
func templatesViewPath(path []string) []string {
if len(path) == 0 {
return nil
}
if path[0] == templatesRootLabel {
return append([]string(nil), path[1:]...)
}
return append([]string(nil), path...)
}
func entriesViewPathForModel(model vault.Model, path []string) []string {
if len(path) == 0 {
return nil
}
switch {
case usesPhysicalEntriesRoot(model) && path[0] == entriesRootLabel:
return append([]string(nil), path[1:]...)
case usesLogicalEntriesRoot(model):
return append([]string(nil), path...)
case path[0] == entriesRootLabel:
return append([]string(nil), path[1:]...)
default:
return append([]string(nil), path...)
}
}
func logicalEntry(entry vault.Entry) vault.Entry {
entry.Path = logicalEntriesPath(entry.Path)
for i := range entry.History {
entry.History[i] = logicalEntry(entry.History[i])
}
return entry
}
func logicalEntries(entries []vault.Entry) []vault.Entry {
if len(entries) == 0 {
return nil
}
out := make([]vault.Entry, len(entries))
for i := range entries {
out[i] = logicalEntry(entries[i])
}
return out
}
func logicalTemplateEntry(entry vault.Entry) vault.Entry {
entry.Path = logicalTemplatePath(entry.Path)
for i := range entry.History {
entry.History[i] = logicalTemplateEntry(entry.History[i])
}
return entry
}
func logicalTemplateEntries(entries []vault.Entry) []vault.Entry {
if len(entries) == 0 {
return nil
}
out := make([]vault.Entry, len(entries))
for i := range entries {
out[i] = logicalTemplateEntry(entries[i])
}
return out
}
func entryForModel(model vault.Model, entry vault.Entry) vault.Entry {
entry.Path = entriesViewPathForModel(model, entry.Path)
for i := range entry.History {
entry.History[i] = entryForModel(model, entry.History[i])
}
return entry
}
func templateEntryForModel(entry vault.Entry) vault.Entry {
entry.Path = templatesViewPath(entry.Path)
for i := range entry.History {
entry.History[i] = templateEntryForModel(entry.History[i])
}
return entry
}
func usesPhysicalEntriesRoot(model vault.Model) bool {
if len(model.Entries) == 0 && len(model.Groups) == 0 && len(model.RecycleBin) == 0 {
return true
}
for _, group := range model.Groups {
if len(group) > 0 && group[0] == vaultview.KeepassRoot {
return true
}
}
for _, entry := range model.Entries {
if len(entry.Path) > 0 && entry.Path[0] == vaultview.KeepassRoot {
return true
}
}
for _, entry := range model.RecycleBin {
if len(entry.Path) > 0 && entry.Path[0] == vaultview.KeepassRoot {
return true
}
}
return false
}
func usesLogicalEntriesRoot(model vault.Model) bool {
for _, group := range model.Groups {
if len(group) > 0 && group[0] == entriesRootLabel {
return true
}
}
for _, entry := range model.Entries {
if len(entry.Path) > 0 && entry.Path[0] == entriesRootLabel {
return true
}
}
for _, entry := range model.RecycleBin {
if len(entry.Path) > 0 && entry.Path[0] == entriesRootLabel {
return true
}
}
return false
}
func childGroups(entries []vault.Entry, path []string) []string {
seen := map[string]bool{}
var groups []string
@@ -519,6 +709,33 @@ func childGroups(entries []vault.Entry, path []string) []string {
return groups
}
func sectionGroupView(model vault.Model, section Section) vaultview.View {
switch section {
case SectionTemplates:
return vaultview.VaultTemplates(model)
default:
return vaultview.VaultRoot(model)
}
}
func sectionGroupViewPath(model vault.Model, section Section, path []string) []string {
switch section {
case SectionTemplates:
return templatesViewPath(path)
default:
return entriesViewPathForModel(model, path)
}
}
func sectionGroupLogicalPath(model vault.Model, section Section, path []string) []string {
switch section {
case SectionTemplates:
return logicalTemplatePath(path)
default:
return logicalEntriesPathForModel(model, path)
}
}
func (s *State) DeleteSelectedEntry() error {
session, ok := s.Session.(MutableSession)
if !ok {
@@ -536,8 +753,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 +772,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 {
@@ -571,11 +786,10 @@ func (s *State) UpsertEntry(entry vault.Entry) error {
return err
}
model.UpsertEntry(entry)
model.UpsertEntry(vaultview.VaultRoot(model).ToPhysicalEntry(entryForModel(model, entry)))
session.Replace(model)
s.SelectedEntryID = entry.ID
s.Dirty = true
return nil
return s.markDirtyAndAutoSave()
}
func (s *State) UpsertTemplate(entry vault.Entry) error {
@@ -589,11 +803,10 @@ func (s *State) UpsertTemplate(entry vault.Entry) error {
return err
}
model.UpsertTemplate(entry)
model.UpsertTemplate(vaultview.VaultTemplates(model).ToPhysicalEntry(templateEntryForModel(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) {
@@ -607,15 +820,17 @@ func (s *State) InstantiateTemplate(templateID string, overrides vault.Entry) (v
return vault.Entry{}, err
}
entry, err := model.InstantiateTemplate(templateID, overrides)
entry, err := model.InstantiateTemplate(templateID, vaultview.VaultRoot(model).ToPhysicalEntry(entryForModel(model, overrides)))
if err != nil {
return vault.Entry{}, err
}
session.Replace(model)
s.SelectedEntryID = entry.ID
s.Dirty = true
return entry, nil
if err := s.markDirtyAndAutoSave(); err != nil {
return vault.Entry{}, err
}
return logicalEntry(entry), nil
}
func (s *State) DeleteTemplate(id string) error {
@@ -637,8 +852,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 +873,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 +895,7 @@ func (s *State) RestoreSelectedEntryVersion(historyIndex int) error {
}
session.Replace(model)
s.Dirty = true
return nil
return s.markDirtyAndAutoSave()
}
func (s *State) Lock() error {
@@ -703,7 +918,13 @@ func (s *State) Unlock(key vault.MasterKey) error {
return fmt.Errorf("session is not lockable")
}
return session.Unlock(key)
if err := session.Unlock(key); err != nil {
return err
}
if warningSession, ok := s.Session.(WarningSession); ok {
s.StatusMessage = warningSession.ConsumeWarning()
}
return nil
}
func (s *State) ChangeMasterKey(key vault.MasterKey) error {
@@ -716,8 +937,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 +964,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 {
@@ -847,6 +1086,9 @@ func (s *State) OpenVault(path string, key vault.MasterKey) error {
s.CurrentPath = nil
s.SelectedEntryID = ""
s.Dirty = false
if warningSession, ok := s.Session.(WarningSession); ok {
s.StatusMessage = warningSession.ConsumeWarning()
}
return nil
}
@@ -877,6 +1119,9 @@ func (s *State) OpenRemoteVault(client webdav.Client, path string, key vault.Mas
s.CurrentPath = nil
s.SelectedEntryID = ""
s.Dirty = false
if warningSession, ok := s.Session.(WarningSession); ok {
s.StatusMessage = warningSession.ConsumeWarning()
}
return nil
}
@@ -916,7 +1161,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 +1183,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 {
@@ -951,10 +1197,10 @@ func (s *State) CreateGroup(name string) error {
return err
}
model.CreateGroup(s.CurrentPath, name)
view := sectionGroupView(model, s.Section)
model.CreateGroup(view.ToPhysicalPath(sectionGroupViewPath(model, s.Section, s.CurrentPath)), name)
session.Replace(model)
s.Dirty = true
return nil
return s.markDirtyAndAutoSave()
}
func (s *State) MoveCurrentGroup(parent []string) error {
@@ -966,16 +1212,18 @@ func (s *State) MoveCurrentGroup(parent []string) error {
if err != nil {
return err
}
current := append([]string(nil), s.CurrentPath...)
if err := model.MoveGroup(current, parent); err != nil {
view := sectionGroupView(model, s.Section)
current := sectionGroupLogicalPath(model, s.Section, s.CurrentPath)
currentViewPath := sectionGroupViewPath(model, s.Section, current)
parentViewPath := sectionGroupViewPath(model, s.Section, parent)
if err := model.MoveGroup(view.ToPhysicalPath(currentViewPath), view.ToPhysicalPath(parentViewPath)); err != nil {
return err
}
session.Replace(model)
if len(current) > 0 {
s.CurrentPath = append(append([]string(nil), parent...), current[len(current)-1])
if len(currentViewPath) > 0 {
s.CurrentPath = sectionGroupLogicalPath(model, s.Section, append(append([]string(nil), parentViewPath...), currentViewPath[len(currentViewPath)-1]))
}
s.Dirty = true
return nil
return s.markDirtyAndAutoSave()
}
func (s *State) RenameCurrentGroup(newName string) error {
@@ -989,7 +1237,8 @@ func (s *State) RenameCurrentGroup(newName string) error {
return err
}
if err := model.RenameGroup(s.CurrentPath, newName); err != nil {
view := sectionGroupView(model, s.Section)
if err := model.RenameGroup(view.ToPhysicalPath(sectionGroupViewPath(model, s.Section, s.CurrentPath)), newName); err != nil {
return err
}
@@ -997,8 +1246,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 {
@@ -1012,13 +1260,12 @@ func (s *State) MoveSelectedEntry(path []string) error {
return err
}
if err := model.MoveEntry(s.SelectedEntryID, path); err != nil {
if err := model.MoveEntry(s.SelectedEntryID, vaultview.VaultRoot(model).ToPhysicalPath(entriesViewPathForModel(model, path))); err != nil {
return err
}
session.Replace(model)
s.Dirty = true
return nil
return s.markDirtyAndAutoSave()
}
func (s *State) DeleteCurrentGroup() error {
@@ -1032,7 +1279,8 @@ func (s *State) DeleteCurrentGroup() error {
return err
}
if err := model.DeleteGroup(s.CurrentPath); err != nil {
view := sectionGroupView(model, s.Section)
if err := model.DeleteGroup(view.ToPhysicalPath(sectionGroupViewPath(model, s.Section, s.CurrentPath))); err != nil {
return err
}
@@ -1041,8 +1289,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 +1315,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 +1341,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 +1370,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
+269 -6
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"
@@ -26,7 +27,7 @@ func TestVisibleEntriesFollowsCurrentPathWithoutSearch(t *testing.T) {
},
},
},
CurrentPath: []string{"Crew", "Internet"},
CurrentPath: []string{"Root", "Crew", "Internet"},
}
got, err := state.VisibleEntries()
@@ -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) {
@@ -476,6 +583,75 @@ func TestSearchPathContextIncludesSectionRoots(t *testing.T) {
}
}
func TestVisibleEntriesUseLogicalVaultRootForPhysicalKeepassModel(t *testing.T) {
t.Parallel()
state := State{
Session: stubSession{
model: vault.Model{
Entries: []vault.Entry{
{ID: "bellagio", Title: "Bellagio", Path: []string{"keepass", "Crew", "Internet"}},
{ID: "vault-console", Title: "Vault Console", Path: []string{"keepass", "Crew", "Internet"}},
{ID: "surveillance-console", Title: "Surveillance Console", Path: []string{"keepass", "Crew", "Security Office"}},
},
Groups: [][]string{
{"keepass"},
{"keepass", "Crew"},
{"keepass", "Crew", "Internet"},
{"keepass", "Crew", "Security Office"},
},
},
},
CurrentPath: []string{"Crew", "Internet"},
}
got, err := state.VisibleEntries()
if err != nil {
t.Fatalf("VisibleEntries() error = %v", err)
}
titles := make([]string, 0, len(got))
for _, entry := range got {
titles = append(titles, entry.Title)
}
if !slices.Equal(titles, []string{"Bellagio", "Vault Console"}) {
t.Fatalf("VisibleEntries() titles = %v, want [Bellagio Vault Console]", titles)
}
if !slices.Equal(got[0].Path, []string{"Root", "Crew", "Internet"}) {
t.Fatalf("VisibleEntries()[0].Path = %v, want [Root Crew Internet]", got[0].Path)
}
}
func TestChildGroupsUseLogicalVaultRootForPhysicalKeepassModel(t *testing.T) {
t.Parallel()
state := State{
Session: stubSession{
model: vault.Model{
Entries: []vault.Entry{
{ID: "bellagio", Title: "Bellagio", Path: []string{"keepass", "Crew", "Internet"}},
{ID: "surveillance-console", Title: "Surveillance Console", Path: []string{"keepass", "Crew", "Security Office"}},
},
Groups: [][]string{
{"keepass"},
{"keepass", "Crew"},
{"keepass", "Crew", "Internet"},
{"keepass", "Crew", "Security Office"},
},
},
},
}
got, err := state.ChildGroups()
if err != nil {
t.Fatalf("ChildGroups() error = %v", err)
}
if !slices.Equal(got, []string{"Crew"}) {
t.Fatalf("ChildGroups() = %v, want [Crew]", got)
}
}
func TestChildGroupsUsesCurrentModelAndCurrentPath(t *testing.T) {
t.Parallel()
@@ -1460,6 +1636,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()
@@ -1473,11 +1703,11 @@ func TestCreateGroupSupportsNestedGroupPath(t *testing.T) {
t.Fatalf("CreateGroup() error = %v", err)
}
if got := session.model.ChildGroups([]string{"Root"}); !slices.Equal(got, []string{"Infrastructure"}) {
t.Fatalf("ChildGroups(Root) = %v, want [Infrastructure]", got)
if got := session.model.ChildGroups([]string{"keepass"}); !slices.Equal(got, []string{"Infrastructure"}) {
t.Fatalf("ChildGroups(keepass) = %v, want [Infrastructure]", got)
}
if got := session.model.ChildGroups([]string{"Root", "Infrastructure"}); !slices.Equal(got, []string{"Prod"}) {
t.Fatalf("ChildGroups(Root/Infrastructure) = %v, want [Prod]", got)
if got := session.model.ChildGroups([]string{"keepass", "Infrastructure"}); !slices.Equal(got, []string{"Prod"}) {
t.Fatalf("ChildGroups(keepass/Infrastructure) = %v, want [Prod]", got)
}
}
@@ -1796,6 +2026,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
+18 -1
View File
@@ -16,12 +16,15 @@ 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,
}
}
@@ -68,7 +71,7 @@ func AuditEventSearchTerms(event apiaudit.Event) string {
event.ClientName,
string(event.Operation),
AuditOperationLabel(event.Operation),
strings.Join(event.Resource.Path, " / "),
FormatResourcePath(event.Resource.Path),
event.Resource.EntryID,
event.Message,
}
@@ -88,3 +91,17 @@ func AuditEventSearchTerms(event apiaudit.Event) string {
}
return strings.ToLower(strings.Join(parts, " "))
}
func DisplayResourcePath(path []string) []string {
if len(path) == 0 {
return nil
}
if path[0] == "keepass" {
return append([]string{"Root"}, append([]string(nil), path[1:]...)...)
}
return append([]string(nil), path...)
}
func FormatResourcePath(path []string) string {
return strings.Join(DisplayResourcePath(path), " / ")
}
+130 -14
View File
@@ -129,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 = ""
@@ -143,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
}
@@ -154,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))
}
@@ -250,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,
@@ -270,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)
}
@@ -290,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(apiui.FormatResourcePath(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 {
@@ -357,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
@@ -389,7 +476,7 @@ func policyRuleParts(rule apitokens.PolicyRule) (string, string, string) {
if rule.Resource.Kind == apitokens.ResourceEntry {
resource = "Entry: " + rule.Resource.EntryID
} else if len(rule.Resource.Path) > 0 {
resource = strings.Join(rule.Resource.Path, " / ")
resource = apiui.FormatResourcePath(rule.Resource.Path)
}
return effect, operation, resource
}
@@ -749,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{
@@ -918,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")
}),
@@ -951,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)
}),
@@ -1014,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")
})
}),
)
}),
)
})
@@ -1095,5 +1211,5 @@ func formatAuditResource(resource apitokens.Resource) string {
if len(resource.Path) == 0 {
return "/"
}
return strings.Join(resource.Path, " / ")
return apiui.FormatResourcePath(resource.Path)
}
+55 -17
View File
@@ -27,6 +27,7 @@ import (
"git.julianfamily.org/keepassgo/internal/apiaudit"
"git.julianfamily.org/keepassgo/internal/apitokens"
"git.julianfamily.org/keepassgo/internal/appstate"
apiui "git.julianfamily.org/keepassgo/internal/appui/api"
detailmodel "git.julianfamily.org/keepassgo/internal/appui/detail"
detaillayout "git.julianfamily.org/keepassgo/internal/appui/detail/layout"
lifecyclemodel "git.julianfamily.org/keepassgo/internal/appui/lifecycle"
@@ -139,6 +140,7 @@ type statePaths struct {
AutofillCachePath string
PendingSharedVaultPath string
PendingSharedVaultNamePath string
PendingSharedLookupPath string
}
type recentVaultRecord struct {
@@ -364,12 +366,13 @@ type ui struct {
showAutofillApprovalAsk widget.Clickable
showAutofillApprovalAllow widget.Clickable
showAutofillApprovalBlock widget.Clickable
allowApproval widget.Clickable
denyApproval widget.Clickable
allowApprovalOnce widget.Clickable
allowApprovalPermanent widget.Clickable
denyApprovalOnce widget.Clickable
denyApprovalPermanent widget.Clickable
cancelApproval widget.Clickable
cancelLifecycleProgress widget.Clickable
retryLifecycleOpen widget.Clickable
approvalPermanent widget.Bool
syncSetupAutomatic widget.Bool
apiPolicyAllow widget.Bool
apiPolicyGroupScopeW widget.Bool
@@ -379,8 +382,10 @@ type ui struct {
settingsHistory widget.Bool
settingsDenseLayout widget.Bool
settingsDebugHeaderBounds widget.Bool
settingsAutoSaveRemote widget.Bool
entryClicks []widget.Clickable
apiTokenClicks []widget.Clickable
apiPolicyEdits []widget.Clickable
apiPolicyRemoves []widget.Clickable
apiAuditClicks []widget.Clickable
apiAuditTokenFilters []widget.Clickable
@@ -416,6 +421,8 @@ type ui struct {
useSelectedEntryForPolicy widget.Clickable
clearAPIPolicyTarget widget.Clickable
addAPIPolicyRule widget.Clickable
saveAPIPolicyRule widget.Clickable
cancelAPIPolicyEdit widget.Clickable
phoneSplit widget.Float
splitDrag gesture.Drag
splitBase float32
@@ -468,9 +475,12 @@ type ui struct {
autofillCachePath string
pendingSharedVaultPath string
pendingSharedVaultNamePath string
pendingSharedLookupPath string
pendingSharedLookupQuery string
editingEntry bool
syncDefaultSourceMode syncSourceMode
syncDefaultDirection syncDirection
autoSaveRemote bool
groupControlsHidden bool
lifecycleAdvancedHidden bool
historyHidden bool
@@ -488,6 +498,7 @@ type ui struct {
entriesState entriesSectionState
deleteGroupPath []string
apiPolicyGroupScope bool
selectedAPIPolicyIndex int
apiTokenSecret string
phoneSyncMenuOrigin image.Point
phoneMainMenuOrigin image.Point
@@ -648,6 +659,7 @@ func newUIWithState(mode string, sess appstate.CurrentSession, paths statePaths)
autofillCachePath: paths.AutofillCachePath,
pendingSharedVaultPath: paths.PendingSharedVaultPath,
pendingSharedVaultNamePath: paths.PendingSharedVaultNamePath,
pendingSharedLookupPath: paths.PendingSharedLookupPath,
recentVaultGroups: map[string][]string{},
recentVaultUsedAt: map[string]time.Time{},
lifecycleAdvancedHidden: true,
@@ -660,11 +672,13 @@ func newUIWithState(mode string, sess appstate.CurrentSession, paths statePaths)
syncDirection: syncDirectionPull,
syncDefaultSourceMode: syncSourceLocal,
syncDefaultDirection: syncDirectionPull,
autoSaveRemote: false,
apiPolicyGroupScope: true,
autofillNoticePreference: autofillNoticeAll,
vaultSharer: platform.NewVaultSharer(runtime.GOOS),
backgroundResults: make(chan backgroundActionResult, 8),
phoneGroupBrowserExpanded: true,
selectedAPIPolicyIndex: -1,
}
if mode == "phone" {
u.groupControlsHidden = true
@@ -672,6 +686,7 @@ func newUIWithState(mode string, sess appstate.CurrentSession, paths statePaths)
u.apiPolicyAllow.Value = true
u.apiPolicyGroupScopeW.Value = true
u.state.Session = sess
u.state.AutoSaveRemote = u.autoSaveRemote
u.phoneSplit.Value = 0.46
u.eyeIcon, _ = widget.NewIcon(icons.ActionVisibility)
u.eyeOffIcon, _ = widget.NewIcon(icons.ActionVisibilityOff)
@@ -693,6 +708,7 @@ func newUIWithState(mode string, sess appstate.CurrentSession, paths statePaths)
u.showStatusMessage("Some saved remote sign-ins came from an older KeePassGO build. Reopen those remotes and save them in the vault to migrate them.")
}
u.consumePendingSharedVaultImport()
u.consumePendingSharedLookup()
u.restoreStartupLifecycleTarget()
u.requestMasterPassFocus = u.hasSelectedLifecycleTarget()
u.loadUIPreferences()
@@ -774,6 +790,7 @@ func defaultStatePaths(stateDir string) statePaths {
AutofillCachePath: filepath.Join(baseDir, "autofill-cache.json"),
PendingSharedVaultPath: filepath.Join(baseDir, "pending-shared-vault.kdbx"),
PendingSharedVaultNamePath: filepath.Join(baseDir, "pending-shared-vault-name.txt"),
PendingSharedLookupPath: filepath.Join(baseDir, "pending-shared-lookup.txt"),
}
}
@@ -1205,6 +1222,17 @@ func (u *ui) securityDialogContent(gtx layout.Context) layout.Dimensions {
return syncDialogSummaryCard(gtx, u.theme, syncDialogPurposeAdvanced, u.settingsDraft.Sync.SourceDefault, u.settingsDraft.Sync.DirectionDefault)
},
layout.Spacer{Height: unit.Dp(8)}.Layout,
func(gtx layout.Context) layout.Dimensions {
check := material.CheckBox(u.theme, &u.settingsAutoSaveRemote, "Auto-save remote vault edits")
return check.Layout(gtx)
},
layout.Spacer{Height: unit.Dp(4)}.Layout,
func(gtx layout.Context) layout.Dimensions {
lbl := material.Label(u.theme, unit.Sp(12), "When enabled, edits to an already-open remote vault save immediately instead of waiting for an explicit remote save.")
lbl.Color = mutedColor
return lbl.Layout(gtx)
},
layout.Spacer{Height: unit.Dp(8)}.Layout,
func(gtx layout.Context) layout.Dimensions {
lbl := material.Label(u.theme, unit.Sp(12), "Conflict handling stays retry-safe: merged entry changes keep history, while remote save conflicts still require reopening the vault and retrying the save.")
lbl.Color = mutedColor
@@ -1431,23 +1459,33 @@ func (u *ui) approvalDialogContent(gtx layout.Context) layout.Dimensions {
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return approvalFact(u.theme, "Operation", string(request.Operation), resourceText)(gtx)
}),
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
check := material.CheckBox(u.theme, &u.approvalPermanent, "Make this decision permanent")
check.Color = accentColor
return check.Layout(gtx)
}),
layout.Rigid(layout.Spacer{Height: unit.Dp(14)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return layout.Flex{Spacing: layout.SpaceStart}.Layout(gtx,
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return tonedButton(gtx, u.theme, &u.allowApproval, "Allow")
return layout.Flex{Spacing: layout.SpaceStart}.Layout(gtx,
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return tonedButton(gtx, u.theme, &u.allowApprovalOnce, "Allow Once")
}),
layout.Rigid(layout.Spacer{Width: unit.Dp(8)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return tonedButton(gtx, u.theme, &u.allowApprovalPermanent, "Allow Permanently")
}),
)
}),
layout.Rigid(layout.Spacer{Width: unit.Dp(8)}.Layout),
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return tonedButton(gtx, u.theme, &u.denyApproval, "Deny")
return layout.Flex{Spacing: layout.SpaceStart}.Layout(gtx,
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return tonedButton(gtx, u.theme, &u.denyApprovalOnce, "Deny Once")
}),
layout.Rigid(layout.Spacer{Width: unit.Dp(8)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return tonedButton(gtx, u.theme, &u.denyApprovalPermanent, "Deny Permanently")
}),
)
}),
layout.Rigid(layout.Spacer{Width: unit.Dp(8)}.Layout),
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return tonedButton(gtx, u.theme, &u.cancelApproval, "Cancel")
}),
@@ -1480,7 +1518,7 @@ func approvalResourceText(request apiapproval.Request) string {
}
case apitokens.ResourceGroup:
if len(request.Resource.Path) > 0 {
return strings.Join(request.Resource.Path, " / ")
return apiui.FormatResourcePath(request.Resource.Path)
}
}
return "Vault root"
@@ -2840,7 +2878,7 @@ func (u *ui) groupBar(gtx layout.Context) layout.Dimensions {
return layout.Inset{Bottom: unit.Dp(6)}.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
for u.groupClicks[idx].Clicked(gtx) {
u.state.EnterGroup(name)
u.currentPath = append([]string(nil), u.state.CurrentPath...)
u.adoptStateCurrentPath()
u.filter()
}
return tonedButton(gtx, u.theme, &u.groupClicks[idx], name)
@@ -2871,7 +2909,7 @@ func (u *ui) groupBar(gtx layout.Context) layout.Dimensions {
return layout.Inset{Bottom: unit.Dp(6)}.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
for u.groupClicks[idx].Clicked(gtx) {
u.state.EnterGroup(name)
u.currentPath = append([]string(nil), u.state.CurrentPath...)
u.adoptStateCurrentPath()
u.filter()
}
return tonedButton(gtx, u.theme, &u.groupClicks[idx], name)
+1 -2
View File
@@ -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
}
+105 -24
View File
@@ -23,6 +23,8 @@ import (
detaillayout "git.julianfamily.org/keepassgo/internal/appui/detail/layout"
"git.julianfamily.org/keepassgo/internal/clipboard"
"git.julianfamily.org/keepassgo/internal/session"
"git.julianfamily.org/keepassgo/internal/vault"
"git.julianfamily.org/keepassgo/internal/vaultview"
)
func (u *ui) bannerSurface() uiBanner {
@@ -552,10 +554,80 @@ func (u *ui) setCurrentPath(path []string) {
u.clearDeleteGroupConfirmation()
}
func copyPath(path []string) []string {
return append([]string(nil), path...)
}
func pathExistsInModel(model vault.Model, path []string) bool {
if len(path) > 0 && path[0] == "Root" {
view := vaultview.VaultRoot(model)
viewPath := entriesViewPathForModel(model, path)
return len(view.EntriesInPath(viewPath)) > 0 || len(view.ChildGroups(viewPath)) > 0 || hasExactGroup(model, view.ToPhysicalPath(viewPath))
}
return len(model.EntriesInPath(path)) > 0 || len(model.ChildGroups(path)) > 0 || hasExactGroup(model, path)
}
func normalizeEntriesPathWithoutModel(path []string, root string) []string {
if root == "" {
return copyPath(path)
}
if len(path) == 0 {
return []string{root}
}
if path[0] == "Root" {
return copyPath(path)
}
if path[0] == vaultview.KeepassRoot {
return append([]string{root}, path[1:]...)
}
return append([]string{root}, copyPath(path)...)
}
func (u *ui) normalizedEntriesPath(path []string) []string {
if u.state.Section != appstate.SectionEntries {
return copyPath(path)
}
root := u.hiddenVaultRoot()
model, err := u.state.Session.Current()
if err != nil {
return normalizeEntriesPathWithoutModel(path, root)
}
if len(path) == 0 {
if root == "" {
return nil
}
return []string{root}
}
if path[0] == "Root" && root != "" {
candidate := copyPath(path)
if pathExistsInModel(model, candidate) {
return candidate
}
}
if (len(path) == 1 && root != "" && path[0] == root) || pathExistsInModel(model, path) {
return copyPath(path)
}
if root == "" {
return copyPath(path)
}
return []string{root}
}
func (u *ui) adoptStateCurrentPath() {
path := u.normalizedEntriesPath(u.state.CurrentPath)
u.currentPath = append([]string(nil), path...)
u.state.CurrentPath = append([]string(nil), path...)
u.syncedPath = append([]string(nil), path...)
u.syncPhoneGroupBrowser(path)
if len(u.deleteGroupPath) > 0 && !slices.Equal(u.deleteGroupPath, u.currentPath) {
u.clearDeleteGroupConfirmation()
}
}
func (u *ui) syncCurrentPath() {
switch {
case slices.Equal(u.currentPath, u.syncedPath) && !slices.Equal(u.state.CurrentPath, u.syncedPath):
u.currentPath = append([]string(nil), u.state.CurrentPath...)
u.adoptStateCurrentPath()
case !slices.Equal(u.currentPath, u.syncedPath) && slices.Equal(u.state.CurrentPath, u.syncedPath):
u.state.CurrentPath = append([]string(nil), u.currentPath...)
case !slices.Equal(u.currentPath, u.syncedPath) && !slices.Equal(u.state.CurrentPath, u.syncedPath):
@@ -928,33 +1000,29 @@ func (u *ui) handleApprovalAndAPIClicks(gtx layout.Context) {
}
func (u *ui) handleApprovalClicks(gtx layout.Context) {
for u.allowApproval.Clicked(gtx) {
for u.allowApprovalOnce.Clicked(gtx) {
u.runAction("allow API request", func() error {
outcome := apiapproval.OutcomeAllowOnce
if u.approvalPermanent.Value {
outcome = apiapproval.OutcomeAllowPermanent
}
err := u.resolvePendingApproval(outcome)
u.approvalPermanent.Value = false
return err
return u.resolvePendingApproval(apiapproval.OutcomeAllowOnce)
})
}
for u.denyApproval.Clicked(gtx) {
for u.allowApprovalPermanent.Clicked(gtx) {
u.runAction("allow API request permanently", func() error {
return u.resolvePendingApproval(apiapproval.OutcomeAllowPermanent)
})
}
for u.denyApprovalOnce.Clicked(gtx) {
u.runAction("deny API request", func() error {
outcome := apiapproval.OutcomeDenyOnce
if u.approvalPermanent.Value {
outcome = apiapproval.OutcomeDenyPermanent
}
err := u.resolvePendingApproval(outcome)
u.approvalPermanent.Value = false
return err
return u.resolvePendingApproval(apiapproval.OutcomeDenyOnce)
})
}
for u.denyApprovalPermanent.Clicked(gtx) {
u.runAction("deny API request permanently", func() error {
return u.resolvePendingApproval(apiapproval.OutcomeDenyPermanent)
})
}
for u.cancelApproval.Clicked(gtx) {
u.runAction("cancel API request", func() error {
err := u.resolvePendingApproval(apiapproval.OutcomeCancel)
u.approvalPermanent.Value = false
return err
return u.resolvePendingApproval(apiapproval.OutcomeCancel)
})
}
}
@@ -996,6 +1064,12 @@ func (u *ui) handleAPIPolicyClicks(gtx layout.Context) {
for u.addAPIPolicyRule.Clicked(gtx) {
u.runAction("add API policy rule", u.addAPIPolicyRuleAction)
}
for u.saveAPIPolicyRule.Clicked(gtx) {
u.runAction("save API policy rule", u.saveAPIPolicyRuleAction)
}
for u.cancelAPIPolicyEdit.Clicked(gtx) {
u.runAction("cancel API policy edit", u.cancelAPIPolicyEditAction)
}
for u.useCurrentGroupForPolicy.Clicked(gtx) {
u.runAction("use current group for API policy", u.useCurrentGroupForPolicyAction)
}
@@ -1005,8 +1079,16 @@ func (u *ui) handleAPIPolicyClicks(gtx layout.Context) {
for u.clearAPIPolicyTarget.Clicked(gtx) {
u.runAction("clear API policy target", u.clearAPIPolicyTargetAction)
}
for i := range u.apiPolicyRemoves {
for u.apiPolicyRemoves[i].Clicked(gtx) {
editClicks := u.apiPolicyEdits
for i := range editClicks {
for editClicks[i].Clicked(gtx) {
index := i
u.runAction("edit API policy rule", func() error { return u.editAPIPolicyRuleAction(index) })
}
}
removeClicks := u.apiPolicyRemoves
for i := range removeClicks {
for removeClicks[i].Clicked(gtx) {
index := i
u.runAction("remove API policy rule", func() error { return u.removeAPIPolicyRuleAction(index) })
}
@@ -1207,8 +1289,7 @@ func (u *ui) handleGroupClicks(gtx layout.Context) {
for u.moveGroup.Clicked(gtx) {
u.clearDeleteGroupConfirmation()
u.runAction("move group", u.moveCurrentGroupAction)
u.currentPath = append([]string(nil), u.state.CurrentPath...)
u.syncedPath = append([]string(nil), u.state.CurrentPath...)
u.adoptStateCurrentPath()
u.filter()
}
for u.toggleGroupControls.Clicked(gtx) {
+55 -6
View File
@@ -4,8 +4,10 @@ import (
"errors"
"fmt"
"io"
"net/url"
"os"
"path/filepath"
"regexp"
"runtime"
"strings"
@@ -17,6 +19,8 @@ import (
"git.julianfamily.org/keepassgo/internal/webdav"
)
var pendingSharedLookupURLPattern = regexp.MustCompile(`https?://[^\s<>"']+`)
func (u *ui) createVaultAction() error {
key, err := u.currentMasterKey()
defer u.clearMasterPassword()
@@ -47,7 +51,7 @@ func (u *ui) createVaultAction() error {
u.noteRecentVault(u.saveAsTargetPath())
}
u.resetPasswordPeek()
u.currentPath = append([]string(nil), u.state.CurrentPath...)
u.adoptStateCurrentPath()
u.loadSecuritySettingsFromSession()
u.editingEntry = false
u.filter()
@@ -69,7 +73,7 @@ func (u *ui) openVaultAction() error {
}
u.noteRecentVault(path)
u.resetPasswordPeek()
u.currentPath = append([]string(nil), u.state.CurrentPath...)
u.adoptStateCurrentPath()
u.restoreRecentVaultGroup(path)
u.syncSavedRemoteBindingSelection()
if err := u.synchronizeSelectedRemoteBindingOnOpen(); err != nil {
@@ -78,6 +82,7 @@ func (u *ui) openVaultAction() error {
u.loadSecuritySettingsFromSession()
u.editingEntry = false
u.filter()
u.applyPendingSharedLookup()
u.applyPendingLifecycleOpenIntent()
return nil
}
@@ -111,7 +116,7 @@ func (u *ui) startOpenVaultAction() {
manager.ApplyPreparedLocalOpen(prepared)
u.noteRecentVault(path)
u.resetPasswordPeek()
u.currentPath = append([]string(nil), u.state.CurrentPath...)
u.adoptStateCurrentPath()
u.restoreRecentVaultGroup(path)
u.syncSavedRemoteBindingSelection()
if err := u.synchronizeSelectedRemoteBindingOnOpen(); err != nil {
@@ -120,6 +125,7 @@ func (u *ui) startOpenVaultAction() {
u.loadSecuritySettingsFromSession()
u.editingEntry = false
u.filter()
u.applyPendingSharedLookup()
u.applyPendingLifecycleOpenIntent()
return nil
}, nil
@@ -329,7 +335,7 @@ func (u *ui) lockAction() error {
return err
}
u.requestMasterPassFocus = true
u.currentPath = append([]string(nil), u.state.CurrentPath...)
u.adoptStateCurrentPath()
u.resetPasswordPeek()
u.editingEntry = false
u.filter()
@@ -346,7 +352,7 @@ func (u *ui) unlockAction() error {
return err
}
u.resetPasswordPeek()
u.currentPath = append([]string(nil), u.state.CurrentPath...)
u.adoptStateCurrentPath()
u.loadSecuritySettingsFromSession()
u.editingEntry = false
u.filter()
@@ -375,7 +381,7 @@ func (u *ui) startUnlockAction() {
return func() error {
manager.ApplyPreparedUnlock(prepared)
u.resetPasswordPeek()
u.currentPath = append([]string(nil), u.state.CurrentPath...)
u.adoptStateCurrentPath()
u.loadSecuritySettingsFromSession()
u.editingEntry = false
u.filter()
@@ -741,6 +747,49 @@ func (u *ui) consumePendingSharedVaultImport() {
}
}
func normalizePendingSharedLookupQuery(raw string) string {
value := strings.TrimSpace(raw)
if value == "" {
return ""
}
if match := pendingSharedLookupURLPattern.FindString(value); match != "" {
value = match
}
if parsed, err := url.Parse(value); err == nil && strings.TrimSpace(parsed.Hostname()) != "" {
return strings.ToLower(strings.TrimSpace(parsed.Hostname()))
}
return value
}
func (u *ui) consumePendingSharedLookup() {
path := strings.TrimSpace(u.pendingSharedLookupPath)
if path == "" {
return
}
data, err := os.ReadFile(path)
if err != nil {
if !errors.Is(err, os.ErrNotExist) {
u.state.ErrorMessage = fmt.Sprintf("shared lookup: %v", err)
}
return
}
_ = os.Remove(path)
u.pendingSharedLookupQuery = normalizePendingSharedLookupQuery(string(data))
u.applyPendingSharedLookup()
}
func (u *ui) applyPendingSharedLookup() {
query := strings.TrimSpace(u.pendingSharedLookupQuery)
status, ok := u.state.Session.(sessionStatus)
if query == "" || !ok || !status.HasVault() || status.IsLocked() {
return
}
u.pendingSharedLookupQuery = ""
u.state.Section = appstate.SectionEntries
u.search.SetText(query)
u.filter()
}
func (u *ui) importSharedVaultBytesAction(name string, content []byte) error {
target := u.importedVaultDestination(name)
if err := os.MkdirAll(filepath.Dir(target), 0o700); err != nil {
+513 -29
View File
@@ -754,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)
}
@@ -761,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{
@@ -799,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)
@@ -809,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()
@@ -2323,8 +2441,8 @@ func TestUIOpenRemoteActionBootstrapsFromLocalVaultBinding(t *testing.T) {
if err != nil {
t.Fatalf("Session.Current() error = %v", err)
}
if got := current.EntriesInPath([]string{"Root", "Internet"}); len(got) != 1 || got[0].Title != "Vault Console" {
t.Fatalf("EntriesInPath(Root/Internet) = %#v, want Vault Console", got)
if got := current.EntriesInPath([]string{"keepass", "Internet"}); len(got) != 1 || got[0].Title != "Vault Console" {
t.Fatalf("EntriesInPath(keepass/Internet) = %#v, want Vault Console", got)
}
}
@@ -2557,8 +2675,8 @@ func TestUIStartOpenRemoteActionBootstrapsFromLocalVaultBinding(t *testing.T) {
if err != nil {
t.Fatalf("Session.Current() error = %v", err)
}
if got := current.EntriesInPath([]string{"Root", "Internet"}); len(got) != 1 || got[0].Title != "Vault Console" {
t.Fatalf("EntriesInPath(Root/Internet) = %#v, want Vault Console", got)
if got := current.EntriesInPath([]string{"keepass", "Internet"}); len(got) != 1 || got[0].Title != "Vault Console" {
t.Fatalf("EntriesInPath(keepass/Internet) = %#v, want Vault Console", got)
}
}
@@ -3062,8 +3180,8 @@ func TestUIAdvancedSynchronizeFromLocalMergesIntoCurrentVault(t *testing.T) {
if err != nil {
t.Fatalf("reopened Current() error = %v", err)
}
if got := len(model.EntriesInPath([]string{"Root", "Internet"})); got != 2 {
t.Fatalf("len(EntriesInPath(Root/Internet)) = %d, want 2", got)
if got := len(model.EntriesInPath([]string{"keepass", "Internet"})); got != 2 {
t.Fatalf("len(EntriesInPath(keepass/Internet)) = %d, want 2", got)
}
}
@@ -3123,8 +3241,8 @@ func TestUIAdvancedSynchronizeFromImportedLocalVaultMergesIntoCurrentVault(t *te
if err != nil {
t.Fatalf("reopened Current() error = %v", err)
}
if got := len(model.EntriesInPath([]string{"Root", "Internet"})); got != 2 {
t.Fatalf("len(EntriesInPath(Root/Internet)) = %d, want 2", got)
if got := len(model.EntriesInPath([]string{"keepass", "Internet"})); got != 2 {
t.Fatalf("len(EntriesInPath(keepass/Internet)) = %d, want 2", got)
}
}
@@ -3288,8 +3406,8 @@ func TestUIAdvancedSynchronizeToRemoteWritesMergedVaultToTarget(t *testing.T) {
if err != nil {
t.Fatalf("reopened Current() error = %v", err)
}
if got := len(model.EntriesInPath([]string{"Root", "Internet"})); got != 2 {
t.Fatalf("len(EntriesInPath(Root/Internet)) = %d, want 2", got)
if got := len(model.EntriesInPath([]string{"keepass", "Internet"})); got != 2 {
t.Fatalf("len(EntriesInPath(keepass/Internet)) = %d, want 2", got)
}
}
@@ -3488,11 +3606,11 @@ func TestUICreateGroupActionSupportsNestedSubgroups(t *testing.T) {
t.Fatalf("createGroupAction() error = %v", err)
}
if got := u.state.Session.(*uiSession).model.ChildGroups([]string{"Root"}); !slices.Equal(got, []string{"Infrastructure"}) {
t.Fatalf("ChildGroups(Root) = %v, want [Infrastructure]", got)
if got := u.state.Session.(*uiSession).model.ChildGroups([]string{"keepass"}); !slices.Equal(got, []string{"Infrastructure"}) {
t.Fatalf("ChildGroups(keepass) = %v, want [Infrastructure]", got)
}
if got := u.state.Session.(*uiSession).model.ChildGroups([]string{"Root", "Infrastructure"}); !slices.Equal(got, []string{"Prod"}) {
t.Fatalf("ChildGroups(Root/Infrastructure) = %v, want [Prod]", got)
if got := u.state.Session.(*uiSession).model.ChildGroups([]string{"keepass", "Infrastructure"}); !slices.Equal(got, []string{"Prod"}) {
t.Fatalf("ChildGroups(keepass/Infrastructure) = %v, want [Prod]", got)
}
}
@@ -4858,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()
@@ -4901,8 +5125,68 @@ func TestUIAutoEntersSingleVaultRootGroupAndDisplaysSlashRoot(t *testing.T) {
t.Fatalf("openVaultAction() error = %v", err)
}
if got := u.currentPath; !slices.Equal(got, []string{"keepass"}) {
t.Fatalf("currentPath = %v, want [keepass]", got)
if got := u.currentPath; !slices.Equal(got, []string{"Root"}) {
t.Fatalf("currentPath = %v, want [Root]", 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 TestUIOpenVaultShowsLegacyRootNormalizationWarning(t *testing.T) {
t.Parallel()
path := filepath.Join(t.TempDir(), "legacy-root.kdbx")
var encoded bytes.Buffer
if err := vault.SaveKDBX(&encoded, vault.Model{
Entries: []vault.Entry{
{ID: "vault-console", Title: "Vault Console", Path: []string{"Root", "Crew", "Internet"}},
},
Groups: [][]string{
{"Root"},
{"Root", "Crew"},
{"Root", "Crew", "Internet"},
},
}, "correct horse battery staple"); err != nil {
t.Fatalf("SaveKDBX() error = %v", err)
}
if err := os.WriteFile(path, encoded.Bytes(), 0o600); err != nil {
t.Fatalf("WriteFile(legacy-root.kdbx) error = %v", err)
}
u := newUIWithSession("desktop", &session.Manager{})
u.masterPassword.SetText("correct horse battery staple")
u.vaultPath.SetText(path)
if err := u.openVaultAction(); err != nil {
t.Fatalf("openVaultAction() error = %v", err)
}
if got := u.state.StatusMessage; !strings.Contains(got, "legacy vault root") {
t.Fatalf("StatusMessage = %q, want legacy vault root normalization warning", got)
}
}
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{"Root"}) {
t.Fatalf("currentPath = %v, want [Root]", got)
}
if got := u.displayPath(); len(got) != 0 {
t.Fatalf("displayPath() = %v, want root slash path", got)
@@ -4923,15 +5207,15 @@ func TestUIShowEntriesSectionRestoresHiddenRootAfterLeavingEntries(t *testing.T)
})
u.showEntriesSection()
if got := u.currentPath; !slices.Equal(got, []string{"keepass"}) {
t.Fatalf("currentPath after initial entries section = %v, want [keepass]", got)
if got := u.currentPath; !slices.Equal(got, []string{"Root"}) {
t.Fatalf("currentPath after initial entries section = %v, want [Root]", got)
}
u.showAPITokensSection()
u.showEntriesSection()
if got := u.currentPath; !slices.Equal(got, []string{"keepass"}) {
t.Fatalf("currentPath after returning to entries = %v, want [keepass]", got)
if got := u.currentPath; !slices.Equal(got, []string{"Root"}) {
t.Fatalf("currentPath after returning to entries = %v, want [Root]", got)
}
if got := u.displayPath(); len(got) != 0 {
t.Fatalf("displayPath() after returning to entries = %v, want root slash path", got)
@@ -4941,6 +5225,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{"Root"}) {
t.Fatalf("currentPath after syncCurrentPath() = %v, want [Root]", 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()
@@ -4953,7 +5268,7 @@ func TestUIShowEntriesSectionRestoresEntriesViewState(t *testing.T) {
})
u.showEntriesSection()
u.setCurrentPath([]string{"keepass", "Crew", "Internet"})
u.setCurrentPath([]string{"Root", "Crew", "Internet"})
u.search.SetText("amazon")
u.filter()
u.state.SelectedEntryID = "amazon"
@@ -4963,8 +5278,8 @@ func TestUIShowEntriesSectionRestoresEntriesViewState(t *testing.T) {
u.showAPITokensSection()
u.showEntriesSection()
if got := u.currentPath; !slices.Equal(got, []string{"keepass", "Crew", "Internet"}) {
t.Fatalf("currentPath after returning to entries = %v, want [keepass Crew Internet]", got)
if got := u.currentPath; !slices.Equal(got, []string{"Root", "Crew", "Internet"}) {
t.Fatalf("currentPath after returning to entries = %v, want [Root Crew Internet]", got)
}
if got := u.search.Text(); got != "amazon" {
t.Fatalf("search text after returning to entries = %q, want amazon", got)
@@ -5460,6 +5775,33 @@ 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()
@@ -5556,6 +5898,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)
@@ -5572,6 +5915,12 @@ 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) {
@@ -7757,7 +8106,7 @@ func TestUISelectedRemoteCardUsesLocalCacheSummaryForBoundRemote(t *testing.T) {
wantDetails := []string{
"/vaults/cache",
"Sync target: home.kdbx · dav.example.invalid",
"Last group: Root / Internet",
"Last group: Internet",
}
if !slices.Equal(gotDetails, wantDetails) {
t.Fatalf("selectedRemoteCardDetailLines() = %v, want %v", gotDetails, wantDetails)
@@ -7789,7 +8138,7 @@ func TestUISelectedRemoteCardUsesConnectionSummaryWithoutLocalCache(t *testing.T
wantDetails := []string{
"Path: vaults/home.kdbx",
"Server: https://dav.example.invalid",
"Last group: Root / Internet",
"Last group: Internet",
}
if !slices.Equal(gotDetails, wantDetails) {
t.Fatalf("selectedRemoteCardDetailLines() = %v, want %v", gotDetails, wantDetails)
@@ -8041,6 +8390,9 @@ func TestDefaultStatePathsUsesProvidedStateDir(t *testing.T) {
if got := paths.PendingSharedVaultNamePath; got != filepath.Join(base, "pending-shared-vault-name.txt") {
t.Fatalf("PendingSharedVaultNamePath = %q, want %q", got, filepath.Join(base, "pending-shared-vault-name.txt"))
}
if got := paths.PendingSharedLookupPath; got != filepath.Join(base, "pending-shared-lookup.txt") {
t.Fatalf("PendingSharedLookupPath = %q, want %q", got, filepath.Join(base, "pending-shared-lookup.txt"))
}
}
func TestImportedVaultDestinationUsesIncomingFilenameInsideDefaultDirectory(t *testing.T) {
@@ -8164,13 +8516,102 @@ func TestUIConsumesPendingSharedVaultImportOnStartup(t *testing.T) {
if err := reopened.openVaultAction(); err != nil {
t.Fatalf("openVaultAction(imported) error = %v", err)
}
reopened.state.NavigateToPath([]string{"Crew", "Internet"})
reopened.state.NavigateToPath([]string{"Root", "Crew", "Internet"})
reopened.filter()
if got := reopened.filteredTitles(); !slices.Equal(got, []string{"Bellagio"}) {
t.Fatalf("filteredTitles() = %v, want [Bellagio]", got)
}
}
func TestUIConsumesPendingSharedLookupOnStartupWhenVaultIsAlreadyOpen(t *testing.T) {
t.Parallel()
dir := t.TempDir()
paths := statePaths{
DefaultSaveAsPath: filepath.Join(dir, "vault.kdbx"),
RecentVaultsPath: filepath.Join(dir, "recent-vaults.json"),
RecentRemotesPath: filepath.Join(dir, "recent-remotes.json"),
UIPreferencesPath: filepath.Join(dir, "ui-prefs.json"),
PendingSharedLookupPath: filepath.Join(dir, "pending-shared-lookup.txt"),
}
if err := os.WriteFile(paths.PendingSharedLookupPath, []byte("https://bellagio.example.invalid/login\n"), 0o600); err != nil {
t.Fatalf("WriteFile(PendingSharedLookupPath) error = %v", err)
}
u := newUIWithSession("phone", &uiSession{model: vault.Model{
Entries: []vault.Entry{
{ID: "bellagio-login", Title: "Bellagio", URL: "https://bellagio.example.invalid/login", Path: []string{"Crew", "Internet"}},
{ID: "vault-console", Title: "Vault Console", URL: "https://vault.example.invalid", Path: []string{"Crew", "Internet"}},
},
}}, paths)
if got := u.search.Text(); got != "bellagio.example.invalid" {
t.Fatalf("search after pending shared lookup = %q, want %q", got, "bellagio.example.invalid")
}
if got := u.filteredTitles(); !slices.Equal(got, []string{"Bellagio"}) {
t.Fatalf("filteredTitles() after pending shared lookup = %v, want [Bellagio]", got)
}
if _, err := os.Stat(paths.PendingSharedLookupPath); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("Stat(PendingSharedLookupPath) error = %v, want not exist", err)
}
}
func TestNormalizePendingSharedLookupQueryExtractsURLFromTextSnippet(t *testing.T) {
t.Parallel()
raw := "Meet the crew at https://bellagio.example.invalid/login before the vault opens."
if got := normalizePendingSharedLookupQuery(raw); got != "bellagio.example.invalid" {
t.Fatalf("normalizePendingSharedLookupQuery() = %q, want %q", got, "bellagio.example.invalid")
}
}
func TestUIAppliesPendingSharedLookupAfterOpeningVault(t *testing.T) {
t.Parallel()
dir := t.TempDir()
paths := statePaths{
DefaultSaveAsPath: filepath.Join(dir, "vault.kdbx"),
RecentVaultsPath: filepath.Join(dir, "recent-vaults.json"),
RecentRemotesPath: filepath.Join(dir, "recent-remotes.json"),
UIPreferencesPath: filepath.Join(dir, "ui-prefs.json"),
PendingSharedLookupPath: filepath.Join(dir, "pending-shared-lookup.txt"),
}
if err := os.WriteFile(paths.PendingSharedLookupPath, []byte("https://bellagio.example.invalid/login\n"), 0o600); err != nil {
t.Fatalf("WriteFile(PendingSharedLookupPath) error = %v", err)
}
key := vault.MasterKey{Password: "correct horse battery staple"}
vaultPath := filepath.Join(dir, "bellagio.kdbx")
var encoded bytes.Buffer
if err := vault.SaveKDBXWithKey(&encoded, vault.Model{
Entries: []vault.Entry{
{ID: "bellagio-login", Title: "Bellagio", URL: "https://bellagio.example.invalid/login", Path: []string{"Crew", "Internet"}},
{ID: "vault-console", Title: "Vault Console", URL: "https://vault.example.invalid", Path: []string{"Crew", "Internet"}},
},
}, key); err != nil {
t.Fatalf("SaveKDBXWithKey() error = %v", err)
}
if err := os.WriteFile(vaultPath, encoded.Bytes(), 0o600); err != nil {
t.Fatalf("WriteFile(vaultPath) error = %v", err)
}
u := newUIWithState("phone", &session.Manager{}, paths)
if got := u.search.Text(); got != "" {
t.Fatalf("search before open with pending shared lookup = %q, want empty", got)
}
u.vaultPath.SetText(vaultPath)
u.masterPassword.SetText(key.Password)
if err := u.openVaultAction(); err != nil {
t.Fatalf("openVaultAction() with pending shared lookup error = %v", err)
}
if got := u.search.Text(); got != "bellagio.example.invalid" {
t.Fatalf("search after open with pending shared lookup = %q, want %q", got, "bellagio.example.invalid")
}
if got := u.filteredTitles(); !slices.Equal(got, []string{"Bellagio"}) {
t.Fatalf("filteredTitles() after open with pending shared lookup = %v, want [Bellagio]", got)
}
}
func TestUICurrentShareableVaultPathUsesSelectedVaultPath(t *testing.T) {
t.Parallel()
@@ -9011,8 +9452,8 @@ func TestUIAPIPolicyTargetActionsUseCurrentContext(t *testing.T) {
if err := u.useCurrentGroupForPolicyAction(); err != nil {
t.Fatalf("useCurrentGroupForPolicyAction() error = %v", err)
}
if got := u.apiPolicyPath.Text(); got != "bashertarr" {
t.Fatalf("apiPolicyPath.Text() = %q, want %q", got, "bashertarr")
if got := u.apiPolicyPath.Text(); got != "Crew / bashertarr" {
t.Fatalf("apiPolicyPath.Text() = %q, want %q", got, "Crew / bashertarr")
}
if !u.apiPolicyGroupScopeW.Value {
t.Fatal("apiPolicyGroupScopeW.Value = false, want true")
@@ -9039,6 +9480,49 @@ func TestUIAPIPolicyTargetActionsUseCurrentContext(t *testing.T) {
}
}
func TestUIEditAPIPolicyRuleHidesPhysicalKeepassRoot(t *testing.T) {
t.Parallel()
token := apitokens.Token{
ID: "token-1",
Name: "Crew Browser",
Policies: []apitokens.PolicyRule{{
Effect: apitokens.EffectAllow,
Operation: apitokens.OperationListEntries,
Resource: apitokens.Resource{
Kind: apitokens.ResourceGroup,
Path: []string{"keepass", "Crew", "bashertarr"},
},
}},
}
u := newUIWithModel("desktop", vault.Model{
Entries: []vault.Entry{
token.Entry(apitokens.EntryPath),
},
})
u.showAPITokensSection()
u.state.SelectedEntryID = "token-1"
if err := u.editAPIPolicyRuleAction(0); err != nil {
t.Fatalf("editAPIPolicyRuleAction() error = %v", err)
}
if got := u.apiPolicyPath.Text(); got != "Root / Crew / bashertarr" {
t.Fatalf("apiPolicyPath.Text() = %q, want %q", got, "Root / Crew / bashertarr")
}
}
func TestUIAuditAndApprovalFormattingHidePhysicalKeepassRoot(t *testing.T) {
t.Parallel()
resource := apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"keepass", "Crew", "bashertarr"}}
if got := formatAuditResource(resource); got != "Root / Crew / bashertarr" {
t.Fatalf("formatAuditResource() = %q, want %q", got, "Root / Crew / bashertarr")
}
if got := approvalResourceText(apiapproval.Request{Resource: resource}); got != "Root / Crew / bashertarr" {
t.Fatalf("approvalResourceText() = %q, want %q", got, "Root / Crew / bashertarr")
}
}
func TestUIVisibleBreadcrumbsCompressesAggressivelyOnPhone(t *testing.T) {
t.Parallel()
+69 -20
View File
@@ -18,6 +18,7 @@ import (
"git.julianfamily.org/keepassgo/internal/autofillcache"
"git.julianfamily.org/keepassgo/internal/session"
"git.julianfamily.org/keepassgo/internal/vault"
"git.julianfamily.org/keepassgo/internal/vaultview"
"git.julianfamily.org/keepassgo/internal/webdav"
)
@@ -1259,21 +1260,10 @@ func (u *ui) recentVaultGroup(path string) []string {
}
func (u *ui) hiddenVaultRoot() string {
if u.state.Section != appstate.SectionEntries {
return ""
if u.state.Section == appstate.SectionEntries {
return "Root"
}
model, err := u.state.Session.Current()
if err != nil {
return ""
}
if len(model.EntriesInPath(nil)) != 0 {
return ""
}
groups := model.ChildGroups(nil)
if len(groups) != 1 {
return ""
}
return groups[0]
return ""
}
func (u *ui) enterHiddenVaultRoot() {
@@ -1300,7 +1290,7 @@ func (u *ui) restoreRecentVaultGroup(path string) {
u.setCurrentPath(saved)
return
}
if len(model.EntriesInPath(saved)) > 0 || len(model.ChildGroups(saved)) > 0 || hasExactGroup(model, saved) {
if pathExistsInModel(model, saved) {
u.setCurrentPath(saved)
return
}
@@ -1323,7 +1313,7 @@ func (u *ui) restoreRecentRemoteGroup(baseURL, path string) {
u.setCurrentPath(saved)
return
}
if len(model.EntriesInPath(saved)) > 0 || len(model.ChildGroups(saved)) > 0 || hasExactGroup(model, saved) {
if pathExistsInModel(model, saved) {
u.setCurrentPath(saved)
return
}
@@ -1345,7 +1335,7 @@ func (u *ui) restoreEntriesPath(path []string) {
u.setCurrentPath(path)
return
}
if len(model.EntriesInPath(path)) > 0 || len(model.ChildGroups(path)) > 0 || hasExactGroup(model, path) {
if pathExistsInModel(model, path) {
u.setCurrentPath(path)
return
}
@@ -1421,6 +1411,22 @@ func pathHasPrefix(path, prefix []string) bool {
return slices.Equal(path[:len(prefix)], prefix)
}
func entriesViewPathForModel(model vault.Model, path []string) []string {
if len(path) == 0 {
return nil
}
switch {
case usesPhysicalEntriesRoot(model) && path[0] == "Root":
return append([]string(nil), path[1:]...)
case usesLogicalEntriesRoot(model):
return append([]string(nil), path...)
case path[0] == "Root":
return append([]string(nil), path[1:]...)
default:
return append([]string(nil), path...)
}
}
func hasExactGroup(model vault.Model, path []string) bool {
for _, group := range model.Groups {
if slices.Equal(group, path) {
@@ -1439,12 +1445,14 @@ func (u *ui) currentGroupDeletionState() (bool, string) {
if err != nil {
return false, ""
}
path := append([]string(nil), u.currentPath...)
if len(model.ChildGroups(path)) > 0 {
view := vaultview.VaultRoot(model)
path := entriesViewPathForModel(model, u.currentPath)
physicalPath := view.ToPhysicalPath(path)
if len(model.ChildGroups(physicalPath)) > 0 {
return false, "This group contains child groups. Move or delete them before removing the group."
}
for _, item := range model.Entries {
if slices.Equal(item.Path, path) || pathHasPrefix(item.Path, path) {
if slices.Equal(item.Path, physicalPath) || pathHasPrefix(item.Path, physicalPath) {
return false, "This group contains entries. Move or delete them before removing the group."
}
}
@@ -1456,6 +1464,47 @@ func (u *ui) currentGroupDeletionState() (bool, string) {
return true, "Deleting this empty group will not remove any entries."
}
func usesPhysicalEntriesRoot(model vault.Model) bool {
if len(model.Entries) == 0 && len(model.Groups) == 0 && len(model.RecycleBin) == 0 {
return true
}
for _, group := range model.Groups {
if len(group) > 0 && group[0] == vaultview.KeepassRoot {
return true
}
}
for _, entry := range model.Entries {
if len(entry.Path) > 0 && entry.Path[0] == vaultview.KeepassRoot {
return true
}
}
for _, entry := range model.RecycleBin {
if len(entry.Path) > 0 && entry.Path[0] == vaultview.KeepassRoot {
return true
}
}
return false
}
func usesLogicalEntriesRoot(model vault.Model) bool {
for _, group := range model.Groups {
if len(group) > 0 && group[0] == "Root" {
return true
}
}
for _, entry := range model.Entries {
if len(entry.Path) > 0 && entry.Path[0] == "Root" {
return true
}
}
for _, entry := range model.RecycleBin {
if len(entry.Path) > 0 && entry.Path[0] == "Root" {
return true
}
}
return false
}
func (u *ui) deleteGroupPendingConfirmation() bool {
return len(u.deleteGroupPath) > 0 && slices.Equal(u.deleteGroupPath, u.currentPath)
}
+23 -4
View File
@@ -16,6 +16,8 @@ import (
"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"
@@ -56,13 +58,11 @@ func Main() {
}
func defaultGRPCAddr(goos string) string {
if strings.EqualFold(strings.TrimSpace(goos), "android") {
return "off"
}
return "127.0.0.1:47777"
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)
@@ -75,8 +75,14 @@ func run(w *app.Window, mode string, paths statePaths, grpcAddr string) error {
} 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 {
@@ -95,6 +101,19 @@ func run(w *app.Window, mode string, paths statePaths, grpcAddr string) error {
}
}
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
}
+12
View File
@@ -44,6 +44,7 @@ type settingsFile struct {
type syncSettings struct {
SourceDefault string `json:"sourceDefault,omitempty"`
DirectionDefault string `json:"directionDefault,omitempty"`
AutoSaveRemote bool `json:"autoSaveRemote,omitempty"`
}
type debugSettings struct {
@@ -53,6 +54,7 @@ type debugSettings struct {
type syncSettingsDraft struct {
SourceDefault syncSourceMode
DirectionDefault syncDirection
AutoSaveRemote bool
}
type settingsDraft struct {
@@ -198,12 +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 {
@@ -226,9 +230,12 @@ func (u *ui) applySecuritySettingsLive() error {
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 = ""
@@ -243,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)
@@ -251,6 +259,8 @@ 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
}
@@ -258,6 +268,7 @@ func (u *ui) loadSettings() {
}
u.loadLegacySyncDefaultsFromUIPreferences()
u.state.AutoSaveRemote = u.autoSaveRemote
}
func (u *ui) loadLegacySyncDefaultsFromUIPreferences() {
@@ -287,6 +298,7 @@ func (u *ui) saveSettings() {
Sync: syncSettings{
SourceDefault: string(u.syncDefaultSourceMode),
DirectionDefault: string(u.syncDefaultDirection),
AutoSaveRemote: u.autoSaveRemote,
},
Debug: debugSettings{
LogHeaderBounds: u.debugLogHeaderBounds,
+87
View File
@@ -0,0 +1,87 @@
package autofillcache
import (
"encoding/json"
"os"
"path/filepath"
"slices"
"strings"
"time"
)
type BindingsFile struct {
UpdatedAt string `json:"updatedAt"`
Apps map[string]string `json:"apps,omitempty"`
}
func ReadBindings(path string) (BindingsFile, error) {
data, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
return BindingsFile{}, nil
}
return BindingsFile{}, err
}
var bindings BindingsFile
if err := json.Unmarshal(data, &bindings); err != nil {
return BindingsFile{}, err
}
if bindings.Apps == nil {
bindings.Apps = make(map[string]string)
}
return bindings, nil
}
func RememberBinding(path, rawTarget, entryID string, now time.Time) error {
bindings, err := ReadBindings(path)
if err != nil {
return err
}
if bindings.Apps == nil {
bindings.Apps = make(map[string]string)
}
target := strings.TrimSpace(rawTarget)
id := strings.TrimSpace(entryID)
if target == "" || id == "" {
return nil
}
bindings.Apps[target] = id
bindings.UpdatedAt = now.UTC().Format(time.RFC3339)
data, err := json.MarshalIndent(bindings, "", " ")
if err != nil {
return err
}
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return err
}
return os.WriteFile(path, data, 0o600)
}
func ResolveWithBindings(cache File, bindings BindingsFile, rawTarget string) MatchResult {
target := strings.TrimSpace(rawTarget)
if entryID := strings.TrimSpace(bindings.Apps[target]); entryID != "" {
for _, entry := range cache.Entries {
if entry.ID == entryID {
return MatchResult{Status: MatchStatusFound, Entry: entry}
}
}
}
return Resolve(cache, rawTarget)
}
func ChooserCandidates(cache File, rawTarget string) []Entry {
if result := Resolve(cache, rawTarget); result.Status == MatchStatusFound {
return []Entry{result.Entry}
}
candidates := append([]Entry(nil), cache.Entries...)
slices.SortFunc(candidates, func(left, right Entry) int {
if cmp := strings.Compare(strings.ToLower(strings.TrimSpace(left.Title)), strings.ToLower(strings.TrimSpace(right.Title))); cmp != 0 {
return cmp
}
if cmp := strings.Compare(strings.ToLower(strings.Join(left.Path, "/")), strings.ToLower(strings.Join(right.Path, "/"))); cmp != 0 {
return cmp
}
return strings.Compare(left.ID, right.ID)
})
return candidates
}
+102
View File
@@ -0,0 +1,102 @@
package autofillcache
import (
"path/filepath"
"testing"
"time"
)
func TestResolvePrefersLearnedAndroidAppBinding(t *testing.T) {
t.Parallel()
cache := File{
Entries: []Entry{
{
ID: "danny-ocean",
Title: "Bellagio Vault",
Username: "danny",
Password: "secret1",
URL: "https://bellagio.example.invalid/login",
Host: "bellagio.example.invalid",
},
{
ID: "rusty-ryan",
Title: "Mirage Crew",
Username: "rusty",
Password: "secret2",
URL: "https://mirage.example.invalid/login",
Host: "mirage.example.invalid",
},
},
}
bindings := BindingsFile{
Apps: map[string]string{
"androidapp://com.samsung.android.shealth": "rusty-ryan",
},
}
got := ResolveWithBindings(cache, bindings, "androidapp://com.samsung.android.shealth")
if got.Status != MatchStatusFound {
t.Fatalf("ResolveWithBindings() status = %q, want found", got.Status)
}
if got.Entry.ID != "rusty-ryan" {
t.Fatalf("ResolveWithBindings() entry = %q, want rusty-ryan", got.Entry.ID)
}
}
func TestChooserCandidatesFallBackToAllEntriesForUnknownAndroidApp(t *testing.T) {
t.Parallel()
cache := File{
Entries: []Entry{
{
ID: "basher-tarr",
Title: "Bellagio Vault",
Username: "basher",
Password: "secret1",
URL: "https://bellagio.example.invalid/login",
Host: "bellagio.example.invalid",
Path: []string{"Crew"},
},
{
ID: "linus-caldwell",
Title: "Bank Floor",
Username: "linus",
Password: "secret2",
URL: "https://bank.example.invalid/sign-in",
Host: "bank.example.invalid",
Path: []string{"Operations"},
},
},
}
got := ChooserCandidates(cache, "androidapp://com.samsung.android.shealth")
if len(got) != 2 {
t.Fatalf("len(ChooserCandidates()) = %d, want 2", len(got))
}
if got[0].ID != "linus-caldwell" || got[1].ID != "basher-tarr" {
t.Fatalf("ChooserCandidates() = %#v, want title-sorted fallback candidates", got)
}
}
func TestRememberBindingPersistsAndroidAppSelection(t *testing.T) {
t.Parallel()
path := filepath.Join(t.TempDir(), "autofill-bindings.json")
now := time.Date(2026, time.April, 13, 18, 0, 0, 0, time.UTC)
if err := RememberBinding(path, "androidapp://com.samsung.android.shealth", "saul-bloom", now); err != nil {
t.Fatalf("RememberBinding() error = %v", err)
}
got, err := ReadBindings(path)
if err != nil {
t.Fatalf("ReadBindings() error = %v", err)
}
if got.UpdatedAt != now.UTC().Format(time.RFC3339) {
t.Fatalf("UpdatedAt = %q, want %q", got.UpdatedAt, now.UTC().Format(time.RFC3339))
}
if got.Apps["androidapp://com.samsung.android.shealth"] != "saul-bloom" {
t.Fatalf("binding = %#v, want samsung health -> saul-bloom", got.Apps)
}
}
+502
View File
@@ -0,0 +1,502 @@
package browserbridge
import (
"context"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/binary"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"net/url"
"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"`
Query string `json:"query,omitempty"`
Title string `json:"title,omitempty"`
Username string `json:"username,omitempty"`
Password string `json:"password,omitempty"`
Path []string `json:"path,omitempty"`
}
type Response struct {
Success bool `json:"success"`
Error string `json:"error,omitempty"`
Status *Status `json:"status,omitempty"`
Matches []Match `json:"matches,omitempty"`
SearchResults []Match `json:"searchResults,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)
ListEntries(context.Context, []string, string) ([]*keepassgov1.Entry, error)
GetBrowserCredential(context.Context, string, string) (*keepassgov1.GetBrowserCredentialResponse, error)
UpsertEntry(context.Context, *keepassgov1.Entry) (*keepassgov1.Entry, error)
}
type Browser string
type actionHandler func(context.Context, Client, Request, string) Response
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)
handler, ok := actionHandlers[action]
if !ok {
return Response{Success: false, Error: fmt.Sprintf("unsupported action %q", action)}
}
return handler(ctx, client, req, conn.GRPCAddress)
}
var actionHandlers = map[string]actionHandler{
"status": handleStatusAction,
"find-logins": handleFindLoginsAction,
"search-logins": handleSearchLoginsAction,
"get-login": handleGetLoginAction,
"save-login": handleSaveLoginAction,
}
func handleStatusAction(ctx context.Context, client Client, _ Request, grpcAddress string) Response {
status, err := statusResponse(ctx, client, grpcAddress)
if err != nil {
return Response{Success: false, Error: err.Error(), Status: disconnectedStatus(grpcAddress)}
}
return Response{Success: true, Status: status, Version: responseVersion}
}
func handleFindLoginsAction(ctx context.Context, client Client, req Request, grpcAddress string) Response {
matches, err := findMatches(ctx, client, req.URL)
if err != nil {
if status := inferredActionStatus(grpcAddress, err); status != nil {
return Response{Success: true, Status: status, Matches: nil, Version: responseVersion}
}
return Response{Success: false, Error: err.Error(), Status: disconnectedStatus(grpcAddress)}
}
return Response{Success: true, Status: availableStatus(grpcAddress), Matches: matches, Version: responseVersion}
}
func handleSearchLoginsAction(ctx context.Context, client Client, req Request, grpcAddress string) Response {
results, err := searchEntries(ctx, client, req.Query)
if err != nil {
if status := inferredActionStatus(grpcAddress, err); status != nil {
return Response{Success: true, Status: status, SearchResults: nil, Version: responseVersion}
}
return Response{Success: false, Error: err.Error(), Status: disconnectedStatus(grpcAddress)}
}
return Response{Success: true, Status: availableStatus(grpcAddress), SearchResults: results, Version: responseVersion}
}
func handleGetLoginAction(ctx context.Context, client Client, req Request, grpcAddress string) Response {
credential, err := loadCredential(ctx, client, req.EntryID, req.URL)
if err != nil {
if status := inferredActionStatus(grpcAddress, err); status != nil {
return Response{Success: false, Error: err.Error(), Status: status}
}
return Response{Success: false, Error: err.Error(), Status: disconnectedStatus(grpcAddress)}
}
return Response{Success: true, Status: availableStatus(grpcAddress), Credential: credential, Version: responseVersion}
}
func handleSaveLoginAction(ctx context.Context, client Client, req Request, grpcAddress string) Response {
if err := saveLogin(ctx, client, req); err != nil {
if status := inferredActionStatus(grpcAddress, err); status != nil {
return Response{Success: false, Error: err.Error(), Status: status}
}
return Response{Success: false, Error: err.Error(), Status: disconnectedStatus(grpcAddress)}
}
return Response{Success: true, Status: availableStatus(grpcAddress), Version: responseVersion}
}
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 saveLogin(ctx context.Context, client Client, req Request) error {
if strings.TrimSpace(req.Password) == "" {
return fmt.Errorf("browser save requires a password")
}
if strings.TrimSpace(req.EntryID) != "" {
entries, err := client.ListEntries(ctx, nil, "")
if err != nil {
return err
}
existing := findEntry(entries, req.EntryID)
if existing == nil {
return fmt.Errorf("entry %q was not found", strings.TrimSpace(req.EntryID))
}
entry := cloneEntry(existing)
entry.Title = coalesceTitle(req.Title, existing.Title, req.URL)
entry.Username = strings.TrimSpace(req.Username)
entry.Password = strings.TrimSpace(req.Password)
entry.Url = strings.TrimSpace(req.URL)
_, err = client.UpsertEntry(ctx, entry)
return err
}
path := append([]string(nil), req.Path...)
if len(path) == 0 {
return fmt.Errorf("browser save requires a target group path")
}
entry := &keepassgov1.Entry{
Id: newBrowserEntryID(),
Title: coalesceTitle(req.Title, "", req.URL),
Username: strings.TrimSpace(req.Username),
Password: strings.TrimSpace(req.Password),
Url: strings.TrimSpace(req.URL),
Path: path,
Fields: map[string]string{},
}
_, err := client.UpsertEntry(ctx, entry)
return err
}
func findEntry(entries []*keepassgov1.Entry, id string) *keepassgov1.Entry {
for _, entry := range entries {
if entry.GetId() == strings.TrimSpace(id) {
return entry
}
}
return nil
}
func cloneEntry(entry *keepassgov1.Entry) *keepassgov1.Entry {
if entry == nil {
return &keepassgov1.Entry{Fields: map[string]string{}}
}
fields := make(map[string]string, len(entry.GetFields()))
for key, value := range entry.GetFields() {
fields[key] = value
}
return &keepassgov1.Entry{
Id: entry.GetId(),
Title: entry.GetTitle(),
Username: entry.GetUsername(),
Password: entry.GetPassword(),
Url: entry.GetUrl(),
Notes: entry.GetNotes(),
Tags: append([]string(nil), entry.GetTags()...),
Path: append([]string(nil), entry.GetPath()...),
Fields: fields,
}
}
func coalesceTitle(title, fallback, rawURL string) string {
if trimmed := strings.TrimSpace(title); trimmed != "" {
return trimmed
}
if trimmed := strings.TrimSpace(fallback); trimmed != "" {
return trimmed
}
if parsed, err := url.Parse(strings.TrimSpace(rawURL)); err == nil && strings.TrimSpace(parsed.Hostname()) != "" {
return strings.ToLower(strings.TrimSpace(parsed.Hostname()))
}
return "Browser Login"
}
func newBrowserEntryID() string {
var buf [16]byte
if _, err := rand.Read(buf[:]); err != nil {
return fmt.Sprintf("browser-%d", os.Getpid())
}
return hex.EncodeToString(buf[:])
}
func searchEntries(ctx context.Context, client Client, query string) ([]Match, error) {
resp, err := client.ListEntries(ctx, nil, strings.TrimSpace(query))
if err != nil {
return nil, err
}
out := make([]Match, 0, len(resp))
for _, entry := range resp {
out = append(out, Match{
ID: entry.GetId(),
Title: entry.GetTitle(),
Username: entry.GetUsername(),
URL: entry.GetUrl(),
Path: append([]string(nil), entry.GetPath()...),
})
}
return out, 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)
}
+522
View File
@@ -0,0 +1,522 @@
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 TestHandleRequestSearchLogins(t *testing.T) {
t.Parallel()
client := &fakeClient{
entries: []*keepassgov1.Entry{
{Id: "rusty-gitlab", Title: "Rusty GitLab", Username: "rustyryan", Url: "gitlab.com", Path: []string{"Joe", "Internet"}},
},
}
resp := HandleRequest(context.Background(), Request{
Action: "search-logins",
BearerToken: "secret",
Query: "GitLab",
}, "", client)
if !resp.Success {
t.Fatalf("HandleRequest(search-logins) success = false, error = %q", resp.Error)
}
if len(resp.SearchResults) != 1 || resp.SearchResults[0].ID != "rusty-gitlab" {
t.Fatalf("HandleRequest(search-logins).SearchResults = %#v, want rusty-gitlab", resp.SearchResults)
}
}
func TestHandleRequestSaveLoginUpdatesExistingEntry(t *testing.T) {
t.Parallel()
client := &fakeClient{
entries: []*keepassgov1.Entry{
{
Id: "vault-console",
Title: "Vault Console",
Username: "dannyocean",
Password: "old-password",
Url: "https://vault.example.invalid/login",
Path: []string{"Crew", "Internet"},
Fields: map[string]string{
"URL1": "vault.example.invalid",
"X-Role": "inside-man",
},
Tags: []string{"vault"},
Notes: "Original notes stay intact.",
},
},
}
resp := HandleRequest(context.Background(), Request{
Action: "save-login",
BearerToken: "secret",
EntryID: "vault-console",
Username: "dannyocean",
Password: "new-password",
URL: "https://vault.example.invalid/login",
}, "", client)
if !resp.Success {
t.Fatalf("HandleRequest(save-login update) success = false, error = %q", resp.Error)
}
if client.upserted == nil {
t.Fatal("HandleRequest(save-login update) did not upsert an entry")
}
if got := client.upserted.Id; got != "vault-console" {
t.Fatalf("upserted.Id = %q, want vault-console", got)
}
if got := client.upserted.Password; got != "new-password" {
t.Fatalf("upserted.Password = %q, want new-password", got)
}
if got := client.upserted.Fields["X-Role"]; got != "inside-man" {
t.Fatalf("upserted.Fields[X-Role] = %q, want inside-man", got)
}
if got := client.upserted.Notes; got != "Original notes stay intact." {
t.Fatalf("upserted.Notes = %q, want original notes", got)
}
}
func TestHandleRequestSaveLoginCreatesNewEntryInChosenPath(t *testing.T) {
t.Parallel()
client := &fakeClient{}
resp := HandleRequest(context.Background(), Request{
Action: "save-login",
BearerToken: "secret",
Title: "Bellagio Login",
Username: "linuscaldwell",
Password: "yellow-chip",
URL: "https://bellagio.example.invalid/login",
Path: []string{"Crew", "Internet"},
}, "", client)
if !resp.Success {
t.Fatalf("HandleRequest(save-login create) success = false, error = %q", resp.Error)
}
if client.upserted == nil {
t.Fatal("HandleRequest(save-login create) did not upsert an entry")
}
if got := client.upserted.Title; got != "Bellagio Login" {
t.Fatalf("upserted.Title = %q, want Bellagio Login", got)
}
if got := client.upserted.Username; got != "linuscaldwell" {
t.Fatalf("upserted.Username = %q, want linuscaldwell", got)
}
if got := client.upserted.Path; !slices.Equal(got, []string{"Crew", "Internet"}) {
t.Fatalf("upserted.Path = %v, want [Crew Internet]", got)
}
if got := client.upserted.Id; got == "" {
t.Fatal("upserted.Id = empty, want generated id")
}
}
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
entries []*keepassgov1.Entry
credential *keepassgov1.GetBrowserCredentialResponse
upserted *keepassgov1.Entry
err error
matchesErr error
entriesErr error
credentialErr error
upsertErr 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) ListEntries(context.Context, []string, string) ([]*keepassgov1.Entry, error) {
if f.entriesErr != nil {
return nil, f.entriesErr
}
if f.err != nil {
return nil, f.err
}
return f.entries, 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
}
func (f *fakeClient) UpsertEntry(_ context.Context, entry *keepassgov1.Entry) (*keepassgov1.Entry, error) {
if f.upsertErr != nil {
return nil, f.upsertErr
}
f.upserted = entry
return entry, nil
}
+92
View File
@@ -0,0 +1,92 @@
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) ListEntries(ctx context.Context, path []string, query string) ([]*keepassgov1.Entry, error) {
resp, err := c.client.ListEntries(ctx, &keepassgov1.ListEntriesRequest{
Path: append([]string(nil), path...),
Query: strings.TrimSpace(query),
})
if err != nil {
return nil, err
}
return resp.GetEntries(), 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),
})
}
func (c *GRPCClient) UpsertEntry(ctx context.Context, entry *keepassgov1.Entry) (*keepassgov1.Entry, error) {
resp, err := c.client.UpsertEntry(ctx, &keepassgov1.UpsertEntryRequest{Entry: entry})
if err != nil {
return nil, err
}
return resp.GetEntry(), nil
}
@@ -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)
}
})
}
}
+76 -25
View File
@@ -12,6 +12,7 @@ import (
"strings"
"git.julianfamily.org/keepassgo/internal/vault"
"git.julianfamily.org/keepassgo/internal/vaultview"
"git.julianfamily.org/keepassgo/internal/webdav"
)
@@ -31,6 +32,7 @@ type Manager struct {
remoteClient *webdav.Client
remotePath string
remoteVersion webdav.Version
warning string
}
type PreparedLocalOpen struct {
@@ -40,6 +42,7 @@ type PreparedLocalOpen struct {
Key vault.MasterKey
Encoded []byte
VaultRoot string
Warning string
}
type PreparedRemoteOpen struct {
@@ -51,6 +54,7 @@ type PreparedRemoteOpen struct {
Encoded []byte
VaultRoot string
RemoteVersion webdav.Version
Warning string
}
type PreparedUnlock struct {
@@ -58,6 +62,7 @@ type PreparedUnlock struct {
Config *vault.KDBXConfig
Key vault.MasterKey
VaultRoot string
Warning string
}
func (m *Manager) SecuritySettings() vault.SecuritySettings {
@@ -74,7 +79,7 @@ func (m *Manager) ConfigureSecurity(settings vault.SecuritySettings) error {
}
func (m *Manager) Create(model vault.Model, key vault.MasterKey) error {
root := detectSingleVaultRoot(model)
root := vaultview.KeepassRoot
model = normalizeUnderRoot(model, root)
var encoded bytes.Buffer
if err := vault.SaveKDBXWithConfigAndKey(&encoded, model, key, m.config); err != nil {
@@ -86,6 +91,7 @@ func (m *Manager) Create(model vault.Model, key vault.MasterKey) error {
m.vaultRoot = root
m.encoded = encoded.Bytes()
m.locked = false
m.warning = ""
return nil
}
@@ -93,6 +99,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...)
}
@@ -114,6 +124,12 @@ func (m *Manager) Open(path string, key vault.MasterKey) error {
return nil
}
func (m *Manager) ConsumeWarning() string {
warning := strings.TrimSpace(m.warning)
m.warning = ""
return warning
}
func (m *Manager) Save() error {
if m.remoteClient != nil && m.remotePath != "" {
return m.SaveRemote()
@@ -250,7 +266,7 @@ func (m *Manager) SaveAs(path string) error {
func (m *Manager) Replace(model vault.Model) {
root := m.vaultRoot
if root == "" {
root = detectSingleVaultRoot(model)
root = vaultview.KeepassRoot
}
m.model = normalizeUnderRoot(model, root)
m.vaultRoot = root
@@ -301,12 +317,13 @@ func PrepareLocalOpen(path string, key vault.MasterKey) (PreparedLocalOpen, erro
return PreparedLocalOpen{}, fmt.Errorf("open %s: %w", path, err)
}
return PreparedLocalOpen{
Model: model,
Model: normalizeUnderRoot(model, vaultview.KeepassRoot),
Config: config,
Path: path,
Key: key,
Encoded: content,
VaultRoot: detectSingleVaultRoot(model),
VaultRoot: vaultview.KeepassRoot,
Warning: normalizationWarning(model),
}, nil
}
@@ -320,14 +337,15 @@ func PrepareRemoteOpen(client webdav.Client, path string, key vault.MasterKey) (
return PreparedRemoteOpen{}, fmt.Errorf("decode remote %s: %w", path, err)
}
return PreparedRemoteOpen{
Model: model,
Model: normalizeUnderRoot(model, vaultview.KeepassRoot),
Config: config,
Client: client,
Path: path,
Key: key,
Encoded: content,
VaultRoot: detectSingleVaultRoot(model),
VaultRoot: vaultview.KeepassRoot,
RemoteVersion: version,
Warning: normalizationWarning(model),
}, nil
}
@@ -337,10 +355,11 @@ func PrepareUnlock(encoded []byte, key vault.MasterKey) (PreparedUnlock, error)
return PreparedUnlock{}, fmt.Errorf("unlock vault: %w", err)
}
return PreparedUnlock{
Model: model,
Model: normalizeUnderRoot(model, vaultview.KeepassRoot),
Config: config,
Key: key,
VaultRoot: detectSingleVaultRoot(model),
VaultRoot: vaultview.KeepassRoot,
Warning: normalizationWarning(model),
}, nil
}
@@ -355,6 +374,7 @@ func (m *Manager) ApplyPreparedLocalOpen(prepared PreparedLocalOpen) {
m.remoteClient = nil
m.remotePath = ""
m.remoteVersion = webdav.Version{}
m.warning = prepared.Warning
}
func (m *Manager) ApplyPreparedRemoteOpen(prepared PreparedRemoteOpen) {
@@ -368,6 +388,7 @@ func (m *Manager) ApplyPreparedRemoteOpen(prepared PreparedRemoteOpen) {
m.remotePath = prepared.Path
m.remoteVersion = prepared.RemoteVersion
m.path = ""
m.warning = prepared.Warning
}
func (m *Manager) ApplyPreparedUnlock(prepared PreparedUnlock) {
@@ -376,6 +397,7 @@ func (m *Manager) ApplyPreparedUnlock(prepared PreparedUnlock) {
m.key = prepared.Key
m.vaultRoot = prepared.VaultRoot
m.locked = false
m.warning = prepared.Warning
}
func (m *Manager) ChangeMasterKey(key vault.MasterKey) error {
@@ -580,9 +602,7 @@ func (m *Manager) reloadCurrentLocal(merged vault.Model) error {
return err
}
m.model = merged
if root := detectSingleVaultRoot(merged); root != "" {
m.vaultRoot = root
}
m.vaultRoot = vaultview.KeepassRoot
m.encoded = encoded
m.locked = false
return nil
@@ -599,9 +619,7 @@ func (m *Manager) reloadCurrentRemote(merged vault.Model) error {
return fmt.Errorf("reopen remote %s after synchronize: %w", m.remotePath, err)
}
m.model = merged
if root := detectSingleVaultRoot(merged); root != "" {
m.vaultRoot = root
}
m.vaultRoot = vaultview.KeepassRoot
m.encoded = encoded
m.remoteVersion = version
m.locked = false
@@ -863,17 +881,6 @@ func mergePeerGroups(primary, secondary [][]string) [][]string {
return out
}
func detectSingleVaultRoot(model vault.Model) string {
if len(model.EntriesInPath(nil)) != 0 {
return ""
}
groups := model.ChildGroups(nil)
if len(groups) != 1 {
return ""
}
return groups[0]
}
func normalizeUnderRoot(model vault.Model, root string) vault.Model {
if root == "" {
return model
@@ -884,8 +891,15 @@ func normalizeUnderRoot(model vault.Model, root string) vault.Model {
switch {
case len(path) == 0:
return []string{root}
case path[0] == "Root":
if len(path) == 1 {
return []string{root}
}
return append([]string{root}, path[1:]...)
case path[0] == root:
return path
case path[0] == "Templates":
return path
default:
return append([]string{root}, path...)
}
@@ -903,12 +917,49 @@ func normalizeUnderRoot(model vault.Model, root string) vault.Model {
out.RecycleBin[i].History[j].Path = normalizePath(out.RecycleBin[i].History[j].Path)
}
}
for i := range out.Templates {
out.Templates[i].Path = normalizePath(out.Templates[i].Path)
for j := range out.Templates[i].History {
out.Templates[i].History[j].Path = normalizePath(out.Templates[i].History[j].Path)
}
}
for i := range out.Groups {
out.Groups[i] = normalizePath(out.Groups[i])
}
return out
}
func normalizationWarning(model vault.Model) string {
if len(model.Entries) == 0 && len(model.Groups) == 0 && len(model.RecycleBin) == 0 {
return ""
}
if usesKeepassStorageRoot(model) {
return ""
}
return "Opened legacy vault root layout and normalized it under keepass."
}
func usesKeepassStorageRoot(model vault.Model) bool {
if len(model.Entries) != 0 || len(model.RecycleBin) != 0 {
for _, entry := range model.Entries {
if len(entry.Path) > 0 && entry.Path[0] == vaultview.KeepassRoot {
return true
}
}
for _, entry := range model.RecycleBin {
if len(entry.Path) > 0 && entry.Path[0] == vaultview.KeepassRoot {
return true
}
}
}
for _, group := range model.Groups {
if len(group) > 0 && group[0] == vaultview.KeepassRoot {
return true
}
}
return false
}
func loadLocalSource(path string, key vault.MasterKey) (vault.Model, *vault.KDBXConfig, error) {
content, err := os.ReadFile(path)
if err != nil {
+63 -12
View File
@@ -64,7 +64,7 @@ func TestCreateSaveAsLockAndUnlockRoundTripsVault(t *testing.T) {
t.Fatalf("Current() after Unlock() error = %v", err)
}
got := current.EntriesInPath([]string{"Root", "Internet"})
got := current.EntriesInPath([]string{"keepass", "Internet"})
if len(got) != 1 || got[0].Title != "Vault Console" || got[0].Password != "token-1" {
t.Fatalf("Current() entries = %#v, want persisted Vault Console entry", got)
}
@@ -110,12 +110,63 @@ func TestOpenLoadsExistingKDBXFromDisk(t *testing.T) {
t.Fatalf("Current() error = %v", err)
}
got := current.EntriesInPath([]string{"Root", "Home Assistant"})
got := current.EntriesInPath([]string{"keepass", "Home Assistant"})
if len(got) != 1 || got[0].Password != "token-2" {
t.Fatalf("Current() entries = %#v, want Home Assistant entry", got)
}
}
func TestOpenNormalizesLegacyVaultRootToKeepassAndReportsWarning(t *testing.T) {
t.Parallel()
key := vault.MasterKey{Password: "correct horse battery staple"}
model := vault.Model{
Entries: []vault.Entry{
{
ID: "entry-1",
Title: "Surveillance Console",
Username: "codex",
Password: "token-2",
URL: "https://surveillance.crew.example.invalid",
Path: []string{"Root", "Home Assistant"},
},
},
Groups: [][]string{
{"Root"},
{"Root", "Home Assistant"},
},
}
path := filepath.Join(t.TempDir(), "legacy-root.kdbx")
file, err := os.Create(path)
if err != nil {
t.Fatalf("Create(legacy path) error = %v", err)
}
if err := vault.SaveKDBXWithKey(file, model, key); err != nil {
file.Close()
t.Fatalf("SaveKDBXWithKey() error = %v", err)
}
if err := file.Close(); err != nil {
t.Fatalf("Close(legacy path) error = %v", err)
}
var sess Manager
if err := sess.Open(path, key); err != nil {
t.Fatalf("Open() error = %v", err)
}
current, err := sess.Current()
if err != nil {
t.Fatalf("Current() error = %v", err)
}
if got := current.EntriesInPath([]string{"keepass", "Home Assistant"}); len(got) != 1 || got[0].ID != "entry-1" {
t.Fatalf("Current().EntriesInPath([keepass Home Assistant]) = %#v, want normalized legacy entry", got)
}
if got := sess.ConsumeWarning(); got == "" {
t.Fatal("ConsumeWarning() = empty, want legacy root normalization warning")
}
}
func TestSavePersistsEditsBackToCurrentPath(t *testing.T) {
t.Parallel()
@@ -169,7 +220,7 @@ func TestSavePersistsEditsBackToCurrentPath(t *testing.T) {
t.Fatalf("LoadKDBXWithKey() error = %v", err)
}
got := loaded.EntriesInPath([]string{"Root", "Internet"})
got := loaded.EntriesInPath([]string{"keepass", "Internet"})
if len(got) != 1 || got[0].Password != "token-2" {
t.Fatalf("loaded entries = %#v, want updated password token-2", got)
}
@@ -307,7 +358,7 @@ func TestOpenRemoteLoadsExistingKDBXFromWebDAV(t *testing.T) {
t.Fatalf("Current() error = %v", err)
}
got := current.EntriesInPath([]string{"Root", "Internet"})
got := current.EntriesInPath([]string{"keepass", "Internet"})
if len(got) != 1 || got[0].Password != "token-1" {
t.Fatalf("Current() entries = %#v, want Vault Console entry from remote vault", got)
}
@@ -392,7 +443,7 @@ func TestSaveRemotePersistsEditsBackToWebDAV(t *testing.T) {
t.Fatalf("LoadKDBXWithKey(savedBytes) error = %v", err)
}
got := loaded.EntriesInPath([]string{"Root", "Home Assistant"})
got := loaded.EntriesInPath([]string{"keepass", "Home Assistant"})
if len(got) != 1 || got[0].Password != "token-2" {
t.Fatalf("loaded remote entries = %#v, want updated token-2 entry", got)
}
@@ -513,7 +564,7 @@ func TestChangeMasterKeyReencryptsSavedAndLockedVault(t *testing.T) {
if err != nil {
t.Fatalf("Current() error = %v", err)
}
got := current.EntriesInPath([]string{"Root", "Internet"})
got := current.EntriesInPath([]string{"keepass", "Internet"})
if len(got) != 1 || got[0].Title != "Vault Console" {
t.Fatalf("Current() entries = %#v, want Vault Console entry after ChangeMasterKey", got)
}
@@ -720,7 +771,7 @@ func TestRemoteSaveAndReopenPreservesCrossFeatureState(t *testing.T) {
t.Fatalf("Current() after reopen error = %v", err)
}
got := current.EntriesInPath([]string{"Root", "Internet"})
got := current.EntriesInPath([]string{"keepass", "Internet"})
if len(got) != 1 {
t.Fatalf("len(EntriesInPath(Root/Internet)) after reopen = %d, want 1", len(got))
}
@@ -879,7 +930,7 @@ func TestSynchronizeRemotePreservesOverwrittenRemoteVariantInHistory(t *testing.
t.Fatalf("reopened Current() error = %v", err)
}
got := current.EntriesInPath([]string{"Root", "Internet"})
got := current.EntriesInPath([]string{"keepass", "Internet"})
if len(got) != 1 {
t.Fatalf("len(EntriesInPath(Root/Internet)) = %d, want 1", len(got))
}
@@ -947,7 +998,7 @@ func TestSynchronizeFromLocalMergesOtherVaultIntoCurrentSource(t *testing.T) {
t.Fatalf("reopened Current() error = %v", err)
}
got := current.EntriesInPath([]string{"Root", "Internet"})
got := current.EntriesInPath([]string{"keepass", "Internet"})
if len(got) != 2 {
t.Fatalf("len(EntriesInPath(Root/Internet)) = %d, want 2", len(got))
}
@@ -1004,7 +1055,7 @@ func TestSynchronizeFromLocalBytesMergesOtherVaultIntoCurrentSource(t *testing.T
t.Fatalf("reopened Current() error = %v", err)
}
got := current.EntriesInPath([]string{"Root", "Internet"})
got := current.EntriesInPath([]string{"keepass", "Internet"})
if len(got) != 2 {
t.Fatalf("len(EntriesInPath(Root/Internet)) = %d, want 2", len(got))
}
@@ -1063,7 +1114,7 @@ func TestSynchronizeToLocalWritesMergedVaultToTarget(t *testing.T) {
t.Fatalf("reopened Current() error = %v", err)
}
got := current.EntriesInPath([]string{"Root", "Internet"})
got := current.EntriesInPath([]string{"keepass", "Internet"})
if len(got) != 2 {
t.Fatalf("len(EntriesInPath(Root/Internet)) = %d, want 2", len(got))
}
@@ -1148,7 +1199,7 @@ func TestSynchronizeToRemoteWritesMergedVaultToTarget(t *testing.T) {
t.Fatalf("reopened Current() error = %v", err)
}
got := current.EntriesInPath([]string{"Root", "Internet"})
got := current.EntriesInPath([]string{"keepass", "Internet"})
if len(got) != 2 {
t.Fatalf("len(EntriesInPath(Root/Internet)) = %d, want 2", len(got))
}
+5
View File
@@ -26,6 +26,7 @@ var ErrInvalidMasterKey = errors.New("invalid master key")
const (
templatesRoot = "Templates"
recycleBinRoot = "Recycle Bin"
keepassRoot = "keepass"
keepassGOIDField = "KeePassGO-ID"
remoteProfilesKey = "keepassgo.remoteProfiles"
)
@@ -502,6 +503,10 @@ func compareGroupNames(a, b string) int {
return -1
case b == "Root":
return 1
case a == keepassRoot:
return -1
case b == keepassRoot:
return 1
case a == templatesRoot:
return -1
case b == templatesRoot:
+51
View File
@@ -755,6 +755,57 @@ func TestKDBXReopenCyclesPreserveStableIDsAndCrossFeatureState(t *testing.T) {
}
}
func TestKDBXKeepassRootEntriesPreserveAttachmentsWithTemplates(t *testing.T) {
t.Parallel()
model := Model{
Entries: []Entry{
{
ID: "entry-1",
Title: "Vault Console",
Username: "dannyocean",
Password: "bellagio-pass-2",
URL: "https://vault.crew.example.invalid",
Path: []string{"keepass", "Internet"},
Attachments: map[string][]byte{
"token.txt": []byte("secret attachment contents"),
},
},
},
Templates: []Entry{
{
ID: "tpl-1",
Title: "Website Login",
Username: "template-user",
Password: "template-password",
Path: []string{"Templates", "Web"},
},
},
Groups: [][]string{
{"keepass", "Internet"},
{"Templates", "Web"},
},
}
var encoded bytes.Buffer
if err := SaveKDBX(&encoded, model, "correct horse battery staple"); err != nil {
t.Fatalf("SaveKDBX() error = %v", err)
}
loaded, err := LoadKDBX(bytes.NewReader(encoded.Bytes()), "correct horse battery staple")
if err != nil {
t.Fatalf("LoadKDBX() error = %v", err)
}
got := loaded.EntriesInPath([]string{"keepass", "Internet"})
if len(got) != 1 {
t.Fatalf("len(EntriesInPath()) = %d, want 1", len(got))
}
if string(got[0].Attachments["token.txt"]) != "secret attachment contents" {
t.Fatalf("attachment contents = %q, want %q", string(got[0].Attachments["token.txt"]), "secret attachment contents")
}
}
func mustGroup(name string, children ...any) gokeepasslib.Group {
group := gokeepasslib.NewGroup()
group.Name = name
+34
View File
@@ -0,0 +1,34 @@
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 hasGroup(model.Groups, []string{KeepassRoot}) {
return KeepassRoot
}
if usesTopLevelRoot(model, KeepassRoot) {
return KeepassRoot
}
return ""
}
func hasGroup(groups [][]string, path []string) bool {
for _, group := range groups {
if len(group) != len(path) {
continue
}
match := true
for i := range group {
if group[i] != path[i] {
match = false
break
}
}
if match {
return true
}
}
return false
}
+43
View File
@@ -0,0 +1,43 @@
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")
}
}
func TestHiddenRootFallsBackToEntryPathsWhenGroupsAreSparse(t *testing.T) {
t.Parallel()
model := vault.Model{
Entries: []vault.Entry{
{ID: "rusty", Title: "Rusty GitLab", Path: []string{"keepass", "Joe", "Internet"}},
},
Groups: [][]string{
{"Recycle Bin"},
},
}
if got := HiddenRoot(model); got != "keepass" {
t.Fatalf("HiddenRoot() = %q, want %q", got, "keepass")
}
}
+427
View File
@@ -0,0 +1,427 @@
package vaultview
import (
"slices"
"git.julianfamily.org/keepassgo/internal/vault"
)
const KeepassRoot = "keepass"
const TemplatesRoot = "Templates"
// View projects the physical vault model into a logical tree for a specific
// product surface.
type View interface {
ChildGroups(path []string) []string
EntriesInPath(path []string) []vault.Entry
EntriesUnderPath(path []string) []vault.Entry
ToPhysicalPath(path []string) []string
FromPhysicalPath(path []string) []string
ToPhysicalEntry(entry vault.Entry) vault.Entry
FromPhysicalEntry(entry vault.Entry) vault.Entry
}
// Vault returns the physical datastore view.
func Vault(model vault.Model) View {
return physicalView{model: model}
}
// VaultRoot returns the logical main-vault view rooted at the physical
// keepass storage group.
func VaultRoot(model vault.Model) View {
return prefixedView{model: model, root: KeepassRoot, rooted: usesTopLevelRoot(model, KeepassRoot)}
}
// VaultTemplates returns the logical templates view rooted at the physical
// Templates storage group.
func VaultTemplates(model vault.Model) View {
return templatesView{model: model}
}
// VaultRecycleBin returns the logical recycle-bin view.
func VaultRecycleBin(model vault.Model) View {
return recycleBinView{model: model}
}
type physicalView struct {
model vault.Model
}
func (v physicalView) ChildGroups(path []string) []string {
return v.model.ChildGroups(path)
}
func (v physicalView) EntriesInPath(path []string) []vault.Entry {
return cloneEntries(v.model.EntriesInPath(path))
}
func (v physicalView) EntriesUnderPath(path []string) []vault.Entry {
return cloneEntries(v.model.EntriesUnderPath(path))
}
func (v physicalView) ToPhysicalPath(path []string) []string {
return clonePath(path)
}
func (v physicalView) FromPhysicalPath(path []string) []string {
return clonePath(path)
}
func (v physicalView) ToPhysicalEntry(entry vault.Entry) vault.Entry {
return cloneEntry(entry)
}
func (v physicalView) FromPhysicalEntry(entry vault.Entry) vault.Entry {
return cloneEntry(entry)
}
type prefixedView struct {
model vault.Model
root string
rooted bool
}
func (v prefixedView) ChildGroups(path []string) []string {
return v.model.ChildGroups(v.ToPhysicalPath(path))
}
func (v prefixedView) EntriesInPath(path []string) []vault.Entry {
return v.mapEntries(v.model.EntriesInPath(v.ToPhysicalPath(path)))
}
func (v prefixedView) EntriesUnderPath(path []string) []vault.Entry {
return v.mapEntries(v.model.EntriesUnderPath(v.ToPhysicalPath(path)))
}
func (v prefixedView) ToPhysicalPath(path []string) []string {
if !v.rooted {
return clonePath(path)
}
if len(path) == 0 {
return []string{v.root}
}
return append([]string{v.root}, clonePath(path)...)
}
func (v prefixedView) FromPhysicalPath(path []string) []string {
if !v.rooted {
return clonePath(path)
}
if len(path) == 0 {
return nil
}
if path[0] != v.root {
return clonePath(path)
}
return clonePath(path[1:])
}
func (v prefixedView) ToPhysicalEntry(entry vault.Entry) vault.Entry {
entry = cloneEntry(entry)
entry.Path = v.ToPhysicalPath(entry.Path)
for i := range entry.History {
entry.History[i].Path = v.ToPhysicalPath(entry.History[i].Path)
}
return entry
}
func (v prefixedView) FromPhysicalEntry(entry vault.Entry) vault.Entry {
entry = cloneEntry(entry)
entry.Path = v.FromPhysicalPath(entry.Path)
for i := range entry.History {
entry.History[i].Path = v.FromPhysicalPath(entry.History[i].Path)
}
return entry
}
func (v prefixedView) mapEntries(entries []vault.Entry) []vault.Entry {
out := make([]vault.Entry, 0, len(entries))
for _, entry := range entries {
out = append(out, v.FromPhysicalEntry(entry))
}
return out
}
type recycleBinView struct {
model vault.Model
}
type templatesView struct {
model vault.Model
}
func (v templatesView) ChildGroups(path []string) []string {
return groupChildren(templateGroupPaths(v.model), v.EntriesUnderPath(nil), path)
}
func (v templatesView) EntriesInPath(path []string) []vault.Entry {
return entriesInPath(v.EntriesUnderPath(nil), path)
}
func (v templatesView) EntriesUnderPath(path []string) []vault.Entry {
var out []vault.Entry
for _, entry := range v.model.Templates {
if len(path) > len(entry.Path) {
continue
}
physical := entry.Path
if len(physical) > 0 && physical[0] == TemplatesRoot {
physical = physical[1:]
}
if len(path) > len(physical) {
continue
}
if !slices.Equal(physical[:len(path)], path) {
continue
}
item := cloneEntry(entry)
item.Path = clonePath(physical)
for i := range item.History {
item.History[i].Path = v.FromPhysicalPath(item.History[i].Path)
}
out = append(out, item)
}
slices.SortFunc(out, func(a, b vault.Entry) int {
switch {
case a.Title < b.Title:
return -1
case a.Title > b.Title:
return 1
default:
return 0
}
})
return out
}
func (v templatesView) ToPhysicalPath(path []string) []string {
if len(path) == 0 {
return []string{TemplatesRoot}
}
return append([]string{TemplatesRoot}, clonePath(path)...)
}
func (v templatesView) FromPhysicalPath(path []string) []string {
if len(path) == 0 {
return nil
}
if path[0] != TemplatesRoot {
return clonePath(path)
}
return clonePath(path[1:])
}
func (v templatesView) ToPhysicalEntry(entry vault.Entry) vault.Entry {
entry = cloneEntry(entry)
entry.Path = v.ToPhysicalPath(entry.Path)
for i := range entry.History {
entry.History[i].Path = v.ToPhysicalPath(entry.History[i].Path)
}
return entry
}
func (v templatesView) FromPhysicalEntry(entry vault.Entry) vault.Entry {
entry = cloneEntry(entry)
entry.Path = v.FromPhysicalPath(entry.Path)
for i := range entry.History {
entry.History[i].Path = v.FromPhysicalPath(entry.History[i].Path)
}
return entry
}
func (v recycleBinView) ChildGroups(path []string) []string {
return childGroups(v.model.RecycleBin, path)
}
func (v recycleBinView) EntriesInPath(path []string) []vault.Entry {
return entriesInPath(v.model.RecycleBin, path)
}
func (v recycleBinView) EntriesUnderPath(path []string) []vault.Entry {
var out []vault.Entry
for _, entry := range v.model.RecycleBin {
if len(path) > len(entry.Path) {
continue
}
if !slices.Equal(entry.Path[:len(path)], path) {
continue
}
out = append(out, cloneEntry(entry))
}
slices.SortFunc(out, func(a, b vault.Entry) int {
switch {
case a.Title < b.Title:
return -1
case a.Title > b.Title:
return 1
default:
return 0
}
})
return out
}
func (v recycleBinView) ToPhysicalPath(path []string) []string {
return clonePath(path)
}
func (v recycleBinView) FromPhysicalPath(path []string) []string {
return clonePath(path)
}
func (v recycleBinView) ToPhysicalEntry(entry vault.Entry) vault.Entry {
return cloneEntry(entry)
}
func (v recycleBinView) FromPhysicalEntry(entry vault.Entry) vault.Entry {
return cloneEntry(entry)
}
func childGroups(entries []vault.Entry, path []string) []string {
return groupChildren(nil, entries, path)
}
func groupChildren(groupPaths [][]string, entries []vault.Entry, path []string) []string {
seen := map[string]bool{}
var groups []string
for _, entry := range entries {
if len(path) > len(entry.Path) {
continue
}
if !slices.Equal(entry.Path[:len(path)], path) {
continue
}
if len(entry.Path) == len(path) {
continue
}
group := entry.Path[len(path)]
if seen[group] {
continue
}
seen[group] = true
groups = append(groups, group)
}
for _, groupPath := range groupPaths {
if len(path) > len(groupPath) {
continue
}
if !slices.Equal(groupPath[:len(path)], path) {
continue
}
if len(groupPath) == len(path) {
continue
}
group := groupPath[len(path)]
if seen[group] {
continue
}
seen[group] = true
groups = append(groups, group)
}
slices.Sort(groups)
return groups
}
func entriesInPath(entries []vault.Entry, path []string) []vault.Entry {
var out []vault.Entry
for _, entry := range entries {
if slices.Equal(entry.Path, path) {
out = append(out, cloneEntry(entry))
}
}
slices.SortFunc(out, func(a, b vault.Entry) int {
switch {
case a.Title < b.Title:
return -1
case a.Title > b.Title:
return 1
default:
return 0
}
})
return out
}
func cloneEntries(entries []vault.Entry) []vault.Entry {
if len(entries) == 0 {
return nil
}
out := make([]vault.Entry, len(entries))
for i := range entries {
out[i] = cloneEntry(entries[i])
}
return out
}
func cloneEntry(entry vault.Entry) vault.Entry {
entry.Path = clonePath(entry.Path)
entry.Tags = slices.Clone(entry.Tags)
if entry.Fields != nil {
fields := make(map[string]string, len(entry.Fields))
for key, value := range entry.Fields {
fields[key] = value
}
entry.Fields = fields
}
if entry.Attachments != nil {
attachments := make(map[string][]byte, len(entry.Attachments))
for key, value := range entry.Attachments {
attachments[key] = slices.Clone(value)
}
entry.Attachments = attachments
}
if len(entry.History) != 0 {
history := make([]vault.Entry, len(entry.History))
for i := range entry.History {
history[i] = cloneEntry(entry.History[i])
}
entry.History = history
}
return entry
}
func clonePath(path []string) []string {
if len(path) == 0 {
return nil
}
return slices.Clone(path)
}
func templateGroupPaths(model vault.Model) [][]string {
var out [][]string
for _, group := range model.Groups {
if len(group) == 0 || group[0] != TemplatesRoot {
continue
}
out = append(out, clonePath(group[1:]))
}
return out
}
func usesTopLevelRoot(model vault.Model, root string) bool {
if len(model.Entries) == 0 && len(model.Groups) == 0 && len(model.RecycleBin) == 0 {
return root == KeepassRoot
}
return groupsUseRoot(model.Groups, root) ||
entriesUseRoot(model.Entries, root) ||
entriesUseRoot(model.Templates, root) ||
entriesUseRoot(model.RecycleBin, root)
}
func groupsUseRoot(groups [][]string, root string) bool {
for _, group := range groups {
if len(group) > 0 && group[0] == root {
return true
}
}
return false
}
func entriesUseRoot(entries []vault.Entry, root string) bool {
for _, entry := range entries {
if len(entry.Path) > 0 && entry.Path[0] == root {
return true
}
}
return false
}
+139
View File
@@ -0,0 +1,139 @@
package vaultview
import (
"slices"
"testing"
"git.julianfamily.org/keepassgo/internal/vault"
)
func TestVaultRootProjectsKeepassStorageRoot(t *testing.T) {
t.Parallel()
model := vault.Model{
Entries: []vault.Entry{
{ID: "bellagio-ledger", Title: "Bellagio Ledger", Path: []string{"keepass", "Crew", "Internet"}},
{ID: "fountain-cameras", Title: "Fountain Cameras", Path: []string{"keepass", "Crew", "Security"}},
},
Groups: [][]string{
{"keepass"},
{"keepass", "Crew"},
{"keepass", "Crew", "Internet"},
{"keepass", "Crew", "Security"},
{"Recycle Bin"},
},
}
view := VaultRoot(model)
if got := view.ChildGroups(nil); !slices.Equal(got, []string{"Crew"}) {
t.Fatalf("VaultRoot(model).ChildGroups(nil) = %v, want [Crew]", got)
}
if got := view.ChildGroups([]string{"Crew"}); !slices.Equal(got, []string{"Internet", "Security"}) {
t.Fatalf("VaultRoot(model).ChildGroups([Crew]) = %v, want [Internet Security]", got)
}
gotEntries := view.EntriesInPath([]string{"Crew", "Internet"})
if len(gotEntries) != 1 || !slices.Equal(gotEntries[0].Path, []string{"Crew", "Internet"}) {
t.Fatalf("VaultRoot(model).EntriesInPath([Crew Internet]) = %#v, want logical path [Crew Internet]", gotEntries)
}
if got := view.ToPhysicalPath(nil); !slices.Equal(got, []string{"keepass"}) {
t.Fatalf("VaultRoot(model).ToPhysicalPath(nil) = %v, want [keepass]", got)
}
if got := view.ToPhysicalPath([]string{"Crew", "Internet"}); !slices.Equal(got, []string{"keepass", "Crew", "Internet"}) {
t.Fatalf("VaultRoot(model).ToPhysicalPath([Crew Internet]) = %v, want [keepass Crew Internet]", got)
}
if got := view.FromPhysicalPath([]string{"keepass", "Crew", "Internet"}); !slices.Equal(got, []string{"Crew", "Internet"}) {
t.Fatalf("VaultRoot(model).FromPhysicalPath([keepass Crew Internet]) = %v, want [Crew Internet]", got)
}
}
func TestVaultRecycleBinProjectsRecycleTree(t *testing.T) {
t.Parallel()
model := vault.Model{
RecycleBin: []vault.Entry{
{ID: "bellagio-ledger", Title: "Bellagio Ledger", Path: []string{"Crew", "Internet"}},
{ID: "fountain-cameras", Title: "Fountain Cameras", Path: []string{"Crew", "Security"}},
},
}
view := VaultRecycleBin(model)
if got := view.ChildGroups(nil); !slices.Equal(got, []string{"Crew"}) {
t.Fatalf("VaultRecycleBin(model).ChildGroups(nil) = %v, want [Crew]", got)
}
if got := view.ChildGroups([]string{"Crew"}); !slices.Equal(got, []string{"Internet", "Security"}) {
t.Fatalf("VaultRecycleBin(model).ChildGroups([Crew]) = %v, want [Internet Security]", got)
}
gotEntries := view.EntriesInPath([]string{"Crew", "Internet"})
if len(gotEntries) != 1 || !slices.Equal(gotEntries[0].Path, []string{"Crew", "Internet"}) {
t.Fatalf("VaultRecycleBin(model).EntriesInPath([Crew Internet]) = %#v, want logical recycle-bin path [Crew Internet]", gotEntries)
}
if got := view.ToPhysicalPath([]string{"Crew", "Internet"}); !slices.Equal(got, []string{"Crew", "Internet"}) {
t.Fatalf("VaultRecycleBin(model).ToPhysicalPath([Crew Internet]) = %v, want [Crew Internet]", got)
}
}
func TestVaultTemplatesProjectsTemplatesStorageRoot(t *testing.T) {
t.Parallel()
model := vault.Model{
Templates: []vault.Entry{
{ID: "website-login", Title: "Website Login", Path: []string{"Templates", "Web"}},
{ID: "ssh-login", Title: "SSH Login", Path: []string{"Templates", "Infra"}},
},
Groups: [][]string{
{"Templates"},
{"Templates", "Infra"},
{"Templates", "Web"},
{"keepass"},
},
}
view := VaultTemplates(model)
if got := view.ChildGroups(nil); !slices.Equal(got, []string{"Infra", "Web"}) {
t.Fatalf("VaultTemplates(model).ChildGroups(nil) = %v, want [Infra Web]", got)
}
gotEntries := view.EntriesInPath([]string{"Web"})
if len(gotEntries) != 1 || !slices.Equal(gotEntries[0].Path, []string{"Web"}) {
t.Fatalf("VaultTemplates(model).EntriesInPath([Web]) = %#v, want logical path [Web]", gotEntries)
}
if got := view.ToPhysicalPath(nil); !slices.Equal(got, []string{"Templates"}) {
t.Fatalf("VaultTemplates(model).ToPhysicalPath(nil) = %v, want [Templates]", got)
}
if got := view.ToPhysicalPath([]string{"Web"}); !slices.Equal(got, []string{"Templates", "Web"}) {
t.Fatalf("VaultTemplates(model).ToPhysicalPath([Web]) = %v, want [Templates Web]", got)
}
if got := view.FromPhysicalPath([]string{"Templates", "Web"}); !slices.Equal(got, []string{"Web"}) {
t.Fatalf("VaultTemplates(model).FromPhysicalPath([Templates Web]) = %v, want [Web]", got)
}
}
func TestVaultReturnsPhysicalPathsUnchanged(t *testing.T) {
t.Parallel()
model := vault.Model{
Entries: []vault.Entry{
{ID: "bellagio-ledger", Title: "Bellagio Ledger", Path: []string{"keepass", "Crew", "Internet"}},
},
}
view := Vault(model)
if got := view.ChildGroups(nil); !slices.Equal(got, []string{"keepass"}) {
t.Fatalf("Vault(model).ChildGroups(nil) = %v, want [keepass]", got)
}
if got := view.ToPhysicalPath([]string{"keepass", "Crew"}); !slices.Equal(got, []string{"keepass", "Crew"}) {
t.Fatalf("Vault(model).ToPhysicalPath([keepass Crew]) = %v, want [keepass Crew]", got)
}
if got := view.FromPhysicalEntry(model.Entries[0]); !slices.Equal(got.Path, []string{"keepass", "Crew", "Internet"}) {
t.Fatalf("Vault(model).FromPhysicalEntry(entry).Path = %v, want [keepass Crew Internet]", got.Path)
}
}
@@ -42,12 +42,44 @@ 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/icons/icon-16.png \
"${pkgdir}/usr/share/keepassgo/browser-extension/icons/icon-16.png"
install -Dm644 browser/extension/icons/icon-32.png \
"${pkgdir}/usr/share/keepassgo/browser-extension/icons/icon-32.png"
install -Dm644 browser/extension/icons/icon-48.png \
"${pkgdir}/usr/share/keepassgo/browser-extension/icons/icon-48.png"
install -Dm644 browser/extension/icons/icon-96.png \
"${pkgdir}/usr/share/keepassgo/browser-extension/icons/icon-96.png"
install -Dm644 browser/extension/icons/icon-128.png \
"${pkgdir}/usr/share/keepassgo/browser-extension/icons/icon-128.png"
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 \
+16
View File
@@ -0,0 +1,16 @@
FROM golang:1.26-bookworm AS gobase
FROM eclipse-temurin:25-jdk
RUN apt-get update && apt-get install -y --no-install-recommends \
findutils \
git \
make \
&& rm -rf /var/lib/apt/lists/*
COPY --from=gobase /usr/local/go /usr/local/go
ENV JAVA_HOME=/opt/java/openjdk
ENV PATH=/usr/local/go/bin:${PATH}
WORKDIR /workspace
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)
}
}
+29
View File
@@ -0,0 +1,29 @@
#!/usr/bin/env python3
import argparse
import json
import shutil
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parents[1]
SOURCE_DIR = REPO_ROOT / "browser" / "extension"
def main() -> int:
parser = argparse.ArgumentParser(description="Prepare a Firefox extension directory for web-ext.")
parser.add_argument("output_dir", help="directory to write the prepared extension into")
args = parser.parse_args()
output_dir = Path(args.output_dir).resolve()
if output_dir.exists():
shutil.rmtree(output_dir)
shutil.copytree(SOURCE_DIR, output_dir)
manifest = json.loads((output_dir / "manifest.firefox.json").read_text(encoding="utf-8"))
(output_dir / "manifest.json").write_text(json.dumps(manifest, indent=2) + "\n", encoding="utf-8")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+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()