Compare commits
56 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0538cd2feb | |||
| 2269944702 | |||
| 2ccd5bc337 | |||
| 9a9d9e7447 | |||
| 2c065a04a4 | |||
| f82ddf7435 | |||
| 14c9bc72f6 | |||
| 515eb730f0 | |||
| d60a8d2fbf | |||
| 4afbc3c933 | |||
| c7d35927f3 | |||
| a6340f5c9e | |||
| 0adf1b8826 | |||
| c517794182 | |||
| b511ab4dc0 | |||
| 7b06388712 | |||
| fea1a75cdf | |||
| 0dfaeef7bf | |||
| 92a7853258 | |||
| 14f22b4ebf | |||
| 4d972bfab0 | |||
| e005a42a3f | |||
| 58d6d510f9 | |||
| bb114cee16 | |||
| 2431467aa7 | |||
| c302c29d4f | |||
| 361d6dbe03 | |||
| a41e842a65 | |||
| 54398837e6 | |||
| 989b41735f | |||
| a88b8a824b | |||
| eccfb886ee | |||
| 6790399e24 | |||
| 9882d3fc04 | |||
| 59cd01f8e7 | |||
| ea30775eb7 | |||
| 0ce25a9712 | |||
| 32e6fc6c90 | |||
| e8a48fb7aa | |||
| 3b323ea4fd | |||
| 8117e3e8c1 | |||
| 77e92a2368 | |||
| 4b8c1de1a6 | |||
| af2ce66b78 | |||
| a02d4a3b1c | |||
| 57870ca4f1 | |||
| dc7dd19543 | |||
| d522af7d51 | |||
| 2f2338f6f2 | |||
| 12796ef639 | |||
| e16067b345 | |||
| c8f91b300b | |||
| ebb8d4f4ff | |||
| 83bd1334d0 | |||
| 675aeebdeb | |||
| 0de682a3af |
@@ -45,8 +45,8 @@ Use this skill together with the installed `android-emulator-debug` skill. That
|
|||||||
## Build Workflow
|
## Build Workflow
|
||||||
|
|
||||||
1. Verify the JDK/SDK paths match the known working environment.
|
1. Verify the JDK/SDK paths match the known working environment.
|
||||||
2. Build with `make apk`.
|
2. Build with `make apk` for debug validation, or `make apk-release` when validating production signing behavior.
|
||||||
3. If `make apk` fails, inspect the effective `JAVA_HOME`, `ANDROID_SDK_ROOT`, and `ANDROID_NDK_ROOT` before changing code.
|
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 ./...`.
|
4. If the problem is Android-only, avoid desktop-only conclusions from `go test ./...`.
|
||||||
|
|
||||||
Typical local build:
|
Typical local build:
|
||||||
@@ -55,6 +55,12 @@ Typical local build:
|
|||||||
JAVA_HOME=/usr/lib/jvm/java-25-openjdk make apk
|
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
|
## Emulator Workflow
|
||||||
|
|
||||||
1. Reuse an existing emulator session if one is already running.
|
1. Reuse an existing emulator session if one is already running.
|
||||||
@@ -79,7 +85,7 @@ adb shell dumpsys window | rg 'mCurrentFocus|mFocusedApp'
|
|||||||
|
|
||||||
## Validation Checklist
|
## 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`.
|
- App launches to `org.julianfamily.keepassgo/org.gioui.GioActivity`.
|
||||||
- Screenshot shows the expected screen, not just a black frame.
|
- Screenshot shows the expected screen, not just a black frame.
|
||||||
- `logcat` shows no app crash or Android runtime fatal error.
|
- `logcat` shows no app crash or Android runtime fatal error.
|
||||||
|
|||||||
@@ -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:
|
Use the repo's known-good local JDK unless the environment already proves otherwise:
|
||||||
|
|
||||||
```sh
|
```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.
|
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
|
### 4. Zip The APK
|
||||||
|
|
||||||
- Create the ZIP under the globally required temporary secret-safe directory.
|
- Create the ZIP under the globally required temporary secret-safe directory.
|
||||||
|
|||||||
+61
-5
@@ -8,6 +8,9 @@ on:
|
|||||||
- "v*"
|
- "v*"
|
||||||
- "release-*"
|
- "release-*"
|
||||||
- "[0-9]+.[0-9]+.[0-9]+*"
|
- "[0-9]+.[0-9]+.[0-9]+*"
|
||||||
|
pull_request:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: write
|
contents: write
|
||||||
@@ -16,7 +19,6 @@ env:
|
|||||||
GO_VERSION: "1.26.1"
|
GO_VERSION: "1.26.1"
|
||||||
ANDROID_SDK_ROOT: /opt/android-sdk
|
ANDROID_SDK_ROOT: /opt/android-sdk
|
||||||
ANDROID_NDK_ROOT: /opt/android-sdk/ndk
|
ANDROID_NDK_ROOT: /opt/android-sdk/ndk
|
||||||
JAVA_HOME: /usr/lib/jvm/java-21-openjdk-amd64
|
|
||||||
DIST_DIR: dist
|
DIST_DIR: dist
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
@@ -31,6 +33,17 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
go-version: ${{ env.GO_VERSION }}
|
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
|
- name: Install native build dependencies
|
||||||
shell: bash
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
@@ -39,6 +52,7 @@ jobs:
|
|||||||
apt-get update
|
apt-get update
|
||||||
apt-get install -y --no-install-recommends \
|
apt-get install -y --no-install-recommends \
|
||||||
zsh \
|
zsh \
|
||||||
|
python3 \
|
||||||
pkg-config \
|
pkg-config \
|
||||||
libx11-dev \
|
libx11-dev \
|
||||||
libx11-xcb-dev \
|
libx11-xcb-dev \
|
||||||
@@ -50,6 +64,12 @@ jobs:
|
|||||||
libxcursor-dev \
|
libxcursor-dev \
|
||||||
libxfixes-dev
|
libxfixes-dev
|
||||||
|
|
||||||
|
- name: Install web-ext
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
npm install -g web-ext
|
||||||
|
|
||||||
- name: Lint
|
- name: Lint
|
||||||
shell: bash
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
@@ -66,6 +86,12 @@ jobs:
|
|||||||
trap 'rm -rf -- "$state_dir"' EXIT
|
trap 'rm -rf -- "$state_dir"' EXIT
|
||||||
KEEPASSGO_STATE_DIR="$state_dir" go test -tags nox11,nowayland,novulkan ./...
|
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:
|
build:
|
||||||
needs: lint-test
|
needs: lint-test
|
||||||
runs-on: keepassgo-android
|
runs-on: keepassgo-android
|
||||||
@@ -78,6 +104,17 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
go-version: ${{ env.GO_VERSION }}
|
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
|
- name: Install native build dependencies
|
||||||
shell: bash
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
@@ -86,6 +123,7 @@ jobs:
|
|||||||
apt-get update
|
apt-get update
|
||||||
apt-get install -y --no-install-recommends \
|
apt-get install -y --no-install-recommends \
|
||||||
zsh \
|
zsh \
|
||||||
|
python3 \
|
||||||
pkg-config \
|
pkg-config \
|
||||||
libx11-dev \
|
libx11-dev \
|
||||||
libx11-xcb-dev \
|
libx11-xcb-dev \
|
||||||
@@ -97,6 +135,12 @@ jobs:
|
|||||||
libxcursor-dev \
|
libxcursor-dev \
|
||||||
libxfixes-dev
|
libxfixes-dev
|
||||||
|
|
||||||
|
- name: Install web-ext
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
npm install -g web-ext
|
||||||
|
|
||||||
- name: Prepare dist directory
|
- name: Prepare dist directory
|
||||||
shell: bash
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
@@ -135,13 +179,23 @@ jobs:
|
|||||||
shell: bash
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
signkey_path="$(mktemp)"
|
mkdir -p build/ci-signing
|
||||||
trap 'rm -f -- "$signkey_path"' EXIT
|
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_SIGNKEY_B64 }}' | base64 -d > "$signkey_path"
|
||||||
|
printf '%s' '${{ secrets.APK_SIGNPASS }}' > "$signpass_path"
|
||||||
export APP_VERSION="$(git describe --tags --always --dirty)"
|
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"
|
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
|
- name: Upload CI artifacts
|
||||||
uses: christopherhx/gitea-upload-artifact@v4
|
uses: christopherhx/gitea-upload-artifact@v4
|
||||||
env:
|
env:
|
||||||
@@ -154,6 +208,7 @@ jobs:
|
|||||||
dist/keepassgo-windows-amd64.exe
|
dist/keepassgo-windows-amd64.exe
|
||||||
dist/keepassgo-windows-arm64.exe
|
dist/keepassgo-windows-arm64.exe
|
||||||
dist/keepassgo.apk
|
dist/keepassgo.apk
|
||||||
|
dist/*.zip
|
||||||
retention-days: 30
|
retention-days: 30
|
||||||
|
|
||||||
- name: Publish release artifacts
|
- name: Publish release artifacts
|
||||||
@@ -176,4 +231,5 @@ jobs:
|
|||||||
"${DIST_DIR}/keepassgo-linux-amd64" \
|
"${DIST_DIR}/keepassgo-linux-amd64" \
|
||||||
"${DIST_DIR}/keepassgo-windows-amd64.exe" \
|
"${DIST_DIR}/keepassgo-windows-amd64.exe" \
|
||||||
"${DIST_DIR}/keepassgo-windows-arm64.exe" \
|
"${DIST_DIR}/keepassgo-windows-arm64.exe" \
|
||||||
"${DIST_DIR}/keepassgo.apk"
|
"${DIST_DIR}/keepassgo.apk" \
|
||||||
|
"${DIST_DIR}"/*.zip
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
build/
|
build/
|
||||||
*.apk
|
*.apk
|
||||||
/keepassgo
|
/keepassgo
|
||||||
|
/keepassgo-browser-bridge
|
||||||
android/keepassgo-android.jar
|
android/keepassgo-android.jar
|
||||||
packaging/archlinux/keepassgo-git/*.pkg.tar.zst
|
packaging/archlinux/keepassgo-git/*.pkg.tar.zst
|
||||||
packaging/archlinux/keepassgo-git/PKGBUILD
|
packaging/archlinux/keepassgo-git/PKGBUILD
|
||||||
|
|||||||
@@ -135,6 +135,11 @@ These features are product requirements, not “nice to have” ideas.
|
|||||||
|
|
||||||
## Delivery Discipline
|
## 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 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.
|
- Do not stop at a “good checkpoint” or “meaningful tranche” when required product capabilities are still missing.
|
||||||
- Continue iterating in test-first slices:
|
- 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`,
|
local `ANDROID_NDK_ROOT=/opt/android-ndk`,
|
||||||
CI `ANDROID_NDK_ROOT=/opt/android-sdk/ndk`,
|
CI `ANDROID_NDK_ROOT=/opt/android-sdk/ndk`,
|
||||||
local `JAVA_HOME=/usr/lib/jvm/java-25-openjdk`,
|
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:
|
- 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.
|
`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:
|
- When validating an APK in the emulator, prefer the known KeePassGO setup:
|
||||||
|
|||||||
@@ -6,17 +6,43 @@ Build the APK with:
|
|||||||
make apk
|
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:
|
Environment:
|
||||||
|
|
||||||
- `ANDROID_SDK_ROOT` defaults to `/opt/android-sdk`.
|
- `ANDROID_SDK_ROOT` defaults to `/opt/android-sdk`.
|
||||||
- `ANDROID_NDK_ROOT` defaults to `/opt/android-ndk`.
|
- `ANDROID_NDK_ROOT` defaults to `/opt/android-ndk`.
|
||||||
- `JAVA_HOME` defaults to `/usr/lib/jvm/java-25-openjdk`.
|
- `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_ID` overrides the Android application id.
|
||||||
- `APP_VERSION` overrides the version shown inside KeePassGO itself.
|
- `APP_VERSION` overrides the version shown inside KeePassGO itself.
|
||||||
- `APK_OUT` overrides the output path.
|
- `APK_OUT` overrides the output path.
|
||||||
- `APK_VERSION` overrides the packaged app version.
|
- `APK_VERSION` overrides the packaged app version.
|
||||||
- `ANDROID_MIN_SDK` overrides the minimum supported Android SDK.
|
- `ANDROID_MIN_SDK` overrides the minimum supported Android SDK.
|
||||||
- `ANDROID_TARGET_SDK` overrides the target 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:
|
Installed machine prerequisites expected by this repo:
|
||||||
|
|
||||||
@@ -24,24 +50,28 @@ Installed machine prerequisites expected by this repo:
|
|||||||
- `android-sdk-build-tools`
|
- `android-sdk-build-tools`
|
||||||
- `android-platform-35`
|
- `android-platform-35`
|
||||||
- `android-sdk-platform-tools`
|
- `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
|
```sh
|
||||||
go tool gogio -target android ./cmd/keepassgo ...
|
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:
|
The Android build uses the branded icon asset at:
|
||||||
|
|
||||||
- `internal/assets/keepassgo-icon.png`
|
- `internal/assets/keepassgo-icon.png`
|
||||||
|
|
||||||
Note:
|
Note:
|
||||||
|
|
||||||
- Gio's Android doc currently references Java 1.8, but the Android build-tools
|
- KeePassGO's documented Android build uses Java 25 locally and in CI.
|
||||||
installed on this machine (`d8` from build-tools 37) do not run on Java 8.
|
- If that host setup is unavailable, `make apk` falls back to the Docker image
|
||||||
- In this environment, KeePassGO's APK build requires a newer JDK runtime on
|
so the build still runs under Java 25 instead of encoding a newer host JDK as
|
||||||
`PATH`, which is why the repo defaults `JAVA_HOME` to `/usr/lib/jvm/java-25-openjdk`.
|
a requirement.
|
||||||
- Android runtime testing on the `KeepassGoAPI35` emulator showed a black-screen
|
- 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
|
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
|
rendered correctly with `gioui.org v0.8.0` on the same emulator and SDK/JDK
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ ANDROID_SDK_ROOT ?= /opt/android-sdk
|
|||||||
ANDROID_NDK_ROOT ?= /opt/android-ndk
|
ANDROID_NDK_ROOT ?= /opt/android-ndk
|
||||||
JAVA_HOME ?= /usr/lib/jvm/java-25-openjdk
|
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)
|
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
|
APP_ID ?= org.julianfamily.keepassgo
|
||||||
APK_OUT ?= build/keepassgo.apk
|
APK_OUT ?= build/keepassgo.apk
|
||||||
APK_VERSION ?= 0.1.0.1
|
APK_VERSION ?= 0.1.0.1
|
||||||
@@ -11,11 +12,17 @@ ANDROID_MIN_SDK ?= 28
|
|||||||
ANDROID_TARGET_SDK ?= 35
|
ANDROID_TARGET_SDK ?= 35
|
||||||
SIGNKEY ?=
|
SIGNKEY ?=
|
||||||
SIGNPASS ?=
|
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_DIR ?= packaging/archlinux/keepassgo-git
|
||||||
ARCH_PKG_TMPL ?= $(ARCH_PKG_DIR)/PKGBUILD.tmpl
|
ARCH_PKG_TMPL ?= $(ARCH_PKG_DIR)/PKGBUILD.tmpl
|
||||||
ARCH_PKGBUILD ?= $(ARCH_PKG_DIR)/PKGBUILD
|
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_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)
|
ARCH_REPO_DIR ?= $(CURDIR)
|
||||||
|
WEB_EXT ?= web-ext
|
||||||
|
FIREFOX_EXTENSION_DIR ?= build/firefox-extension
|
||||||
|
FIREFOX_EXTENSION_ARTIFACT_DIR ?= build/browser-extension
|
||||||
|
|
||||||
GOGIO_SIGN_FLAGS :=
|
GOGIO_SIGN_FLAGS :=
|
||||||
ifneq ($(strip $(SIGNKEY)),)
|
ifneq ($(strip $(SIGNKEY)),)
|
||||||
@@ -25,8 +32,31 @@ ifneq ($(strip $(SIGNPASS)),)
|
|||||||
GOGIO_SIGN_FLAGS += -signpass $(SIGNPASS)
|
GOGIO_SIGN_FLAGS += -signpass $(SIGNPASS)
|
||||||
endif
|
endif
|
||||||
|
|
||||||
.PHONY: apk archlinux-pkgbuild browser-bridge
|
CONTAINER_SIGNKEY_MOUNT :=
|
||||||
apk: android/keepassgo-android.jar
|
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 -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_SDK_ROOT)" || { echo "ANDROID_SDK_ROOT must point to an Android SDK install"; exit 1; }
|
||||||
@test -d "$(ANDROID_NDK_ROOT)" || { echo "ANDROID_NDK_ROOT must point to an Android NDK install"; exit 1; }
|
@test -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)/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; }
|
@test -d "$(ANDROID_SDK_ROOT)/build-tools" || { echo "Android build-tools are missing"; exit 1; }
|
||||||
@mkdir -p "$(dir $(APK_OUT))"
|
@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_HOME="$(ANDROID_SDK_ROOT)" \
|
||||||
ANDROID_SDK_ROOT="$(ANDROID_SDK_ROOT)" \
|
ANDROID_SDK_ROOT="$(ANDROID_SDK_ROOT)" \
|
||||||
ANDROID_NDK_ROOT="$(ANDROID_NDK_ROOT)" \
|
ANDROID_NDK_ROOT="$(ANDROID_NDK_ROOT)" \
|
||||||
@@ -50,12 +86,39 @@ apk: android/keepassgo-android.jar
|
|||||||
-icon internal/assets/keepassgo-icon.png \
|
-icon internal/assets/keepassgo-icon.png \
|
||||||
./cmd/keepassgo
|
./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)
|
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 -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; }
|
@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
|
@mkdir -p android
|
||||||
@zsh -lc 'tmpdir=$$(mktemp -d); \
|
@sh -ec 'tmpdir=$$(mktemp -d); \
|
||||||
trap '\''python3 -c "import shutil,sys; shutil.rmtree(sys.argv[1], ignore_errors=True)" "$$tmpdir"'\'' EXIT; \
|
trap "rm -rf $$tmpdir" EXIT; \
|
||||||
"$(JAVA_HOME)/bin/javac" \
|
"$(JAVA_HOME)/bin/javac" \
|
||||||
-classpath "$(ANDROID_SDK_ROOT)/platforms/android-$(ANDROID_TARGET_SDK)/android.jar" \
|
-classpath "$(ANDROID_SDK_ROOT)/platforms/android-$(ANDROID_TARGET_SDK)/android.jar" \
|
||||||
-d "$$tmpdir" \
|
-d "$$tmpdir" \
|
||||||
@@ -71,3 +134,26 @@ archlinux-pkgbuild: $(ARCH_PKG_TMPL) Makefile
|
|||||||
|
|
||||||
browser-bridge:
|
browser-bridge:
|
||||||
go build ./cmd/keepassgo-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),)
|
||||||
|
|||||||
@@ -90,10 +90,25 @@ go get -tool gioui.org/cmd/gogio@latest
|
|||||||
Package:
|
Package:
|
||||||
|
|
||||||
```bash
|
```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
|
## Automation
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
These should remain in the main user flow rather than being hidden behind a settings gear.
|
||||||
|
|
||||||
|
- Browser extension:
|
||||||
- Local open flow:
|
- Local open flow:
|
||||||
make the start screen primarily about opening a vault, not configuring one.
|
make the start screen primarily about opening a vault, not configuring one.
|
||||||
- Local open flow:
|
- 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.
|
keep the split-button pattern, but reduce the visual weight of the sync controls and make advanced sync affordances clearer.
|
||||||
- Synchronize:
|
- Synchronize:
|
||||||
avoid layout-shifting success banners and keep noncritical notifications ephemeral.
|
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:
|
- Phone layout:
|
||||||
continue reducing header and control density so content appears sooner.
|
continue reducing header and control density so content appears sooner.
|
||||||
- Mobile reliability:
|
- Mobile reliability:
|
||||||
@@ -125,6 +130,98 @@ These are important, but they should likely move behind a dedicated settings gea
|
|||||||
- Accessibility preferences:
|
- Accessibility preferences:
|
||||||
future display-density, contrast, reduced-motion, or keyboard-focus tuning should live under settings.
|
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
|
### Exit Criteria
|
||||||
|
|
||||||
- The main workflow screens prioritize opening, browsing, copying, editing, and synchronizing credentials.
|
- The main workflow screens prioritize opening, browsing, copying, editing, and synchronizing credentials.
|
||||||
@@ -264,7 +361,7 @@ Exit criteria:
|
|||||||
- Tests cover clear/reset transitions.
|
- Tests cover clear/reset transitions.
|
||||||
- `go test ./...` passes.
|
- `go test ./...` passes.
|
||||||
|
|
||||||
### Segment 10: Template CRUD UI
|
### Segment 10 (stage 3): Template CRUD UI
|
||||||
|
|
||||||
Scope:
|
Scope:
|
||||||
- Create template.
|
- Create template.
|
||||||
|
|||||||
@@ -22,6 +22,10 @@
|
|||||||
android:name="android.accessibilityservice"
|
android:name="android.accessibilityservice"
|
||||||
android:resource="@xml/keepassgo_accessibility_service" />
|
android:resource="@xml/keepassgo_accessibility_service" />
|
||||||
</service>
|
</service>
|
||||||
|
<activity
|
||||||
|
android:name="org.julianfamily.keepassgo.KeePassGOAutofillPickerActivity"
|
||||||
|
android:exported="false"
|
||||||
|
android:label="Search KeePassGO" />
|
||||||
<provider
|
<provider
|
||||||
android:name="org.julianfamily.keepassgo.SharedVaultProvider"
|
android:name="org.julianfamily.keepassgo.SharedVaultProvider"
|
||||||
android:authorities="org.julianfamily.keepassgo.share"
|
android:authorities="org.julianfamily.keepassgo.share"
|
||||||
@@ -31,6 +35,11 @@
|
|||||||
android:name="org.julianfamily.keepassgo.SharedVaultImportActivity"
|
android:name="org.julianfamily.keepassgo.SharedVaultImportActivity"
|
||||||
android:exported="true"
|
android:exported="true"
|
||||||
android:theme="@android:style/Theme.Translucent.NoTitleBar">
|
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>
|
<intent-filter>
|
||||||
<action android:name="android.intent.action.SEND" />
|
<action android:name="android.intent.action.SEND" />
|
||||||
<category android:name="android.intent.category.DEFAULT" />
|
<category android:name="android.intent.category.DEFAULT" />
|
||||||
@@ -41,6 +50,16 @@
|
|||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.intent.action.VIEW" />
|
<action android:name="android.intent.action.VIEW" />
|
||||||
<category android:name="android.intent.category.DEFAULT" />
|
<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="content" android:pathPattern=".*\\.kdbx" />
|
||||||
<data android:scheme="file" android:pathPattern=".*\\.kdbx" />
|
<data android:scheme="file" android:pathPattern=".*\\.kdbx" />
|
||||||
</intent-filter>
|
</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.io.InputStreamReader;
|
||||||
import java.nio.charset.StandardCharsets;
|
import java.nio.charset.StandardCharsets;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
|
import java.util.Comparator;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Locale;
|
import java.util.Locale;
|
||||||
|
|
||||||
@@ -20,41 +21,79 @@ final class AutofillCacheStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
static Entry findBestMatch(Context context, String webDomain) {
|
static Entry findBestMatch(Context context, String webDomain) {
|
||||||
File cacheFile = findCacheFile(context);
|
List<Entry> entries = readEntries(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;
|
|
||||||
}
|
|
||||||
if (entries.isEmpty()) {
|
if (entries.isEmpty()) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
NormalizedTarget target = normalizeURL(webDomain);
|
return fromMatcherEntry(AutofillTargetMatcher.findBestMatch(toMatcherEntries(entries), webDomain));
|
||||||
if (target.host.isEmpty()) {
|
}
|
||||||
|
|
||||||
|
static Entry findByID(Context context, String entryID) {
|
||||||
|
if (entryID == null || entryID.trim().isEmpty()) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
List<Entry> exactHost = new ArrayList<>();
|
for (Entry entry : readEntries(context)) {
|
||||||
List<Entry> parentHost = new ArrayList<>();
|
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) {
|
for (Entry entry : entries) {
|
||||||
if (entryMatchesHost(entry, target.host)) {
|
converted.add(new AutofillTargetMatcher.Entry(
|
||||||
exactHost.add(entry);
|
entry.id,
|
||||||
continue;
|
entry.title,
|
||||||
|
entry.username,
|
||||||
|
entry.password,
|
||||||
|
entry.host,
|
||||||
|
entry.url,
|
||||||
|
entry.targets,
|
||||||
|
entry.path
|
||||||
|
));
|
||||||
}
|
}
|
||||||
if (entryMatchesParentHost(entry, target.host)) {
|
return converted;
|
||||||
parentHost.add(entry);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static Entry fromMatcherEntry(AutofillTargetMatcher.Entry entry) {
|
||||||
|
if (entry == null) {
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
Entry matched = chooseEntry(target, exactHost);
|
return new Entry(entry.id, entry.title, entry.username, entry.password, entry.host, entry.url, entry.targets, entry.path);
|
||||||
if (matched != null) {
|
}
|
||||||
return matched;
|
|
||||||
|
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) {
|
private static File findCacheFile(Context context) {
|
||||||
@@ -103,16 +142,21 @@ final class AutofillCacheStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private static Entry readEntry(JsonReader reader) throws IOException {
|
private static Entry readEntry(JsonReader reader) throws IOException {
|
||||||
|
String id = "";
|
||||||
String title = "";
|
String title = "";
|
||||||
String username = "";
|
String username = "";
|
||||||
String password = "";
|
String password = "";
|
||||||
String host = "";
|
String host = "";
|
||||||
String url = "";
|
String url = "";
|
||||||
List<String> targets = new ArrayList<>();
|
List<String> targets = new ArrayList<>();
|
||||||
|
List<String> path = new ArrayList<>();
|
||||||
reader.beginObject();
|
reader.beginObject();
|
||||||
while (reader.hasNext()) {
|
while (reader.hasNext()) {
|
||||||
String name = reader.nextName();
|
String name = reader.nextName();
|
||||||
switch (name) {
|
switch (name) {
|
||||||
|
case "id":
|
||||||
|
id = nextString(reader);
|
||||||
|
break;
|
||||||
case "title":
|
case "title":
|
||||||
title = nextString(reader);
|
title = nextString(reader);
|
||||||
break;
|
break;
|
||||||
@@ -135,13 +179,20 @@ final class AutofillCacheStore {
|
|||||||
}
|
}
|
||||||
reader.endArray();
|
reader.endArray();
|
||||||
break;
|
break;
|
||||||
|
case "path":
|
||||||
|
reader.beginArray();
|
||||||
|
while (reader.hasNext()) {
|
||||||
|
path.add(nextString(reader));
|
||||||
|
}
|
||||||
|
reader.endArray();
|
||||||
|
break;
|
||||||
default:
|
default:
|
||||||
reader.skipValue();
|
reader.skipValue();
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
reader.endObject();
|
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 {
|
private static String nextString(JsonReader reader) throws IOException {
|
||||||
@@ -153,182 +204,28 @@ final class AutofillCacheStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private static String normalizeHost(String raw) {
|
private static String normalizeHost(String raw) {
|
||||||
return normalizeURL(raw).host;
|
return AutofillTargetMatcher.normalize(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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
static final class Entry {
|
static final class Entry {
|
||||||
|
final String id;
|
||||||
final String title;
|
final String title;
|
||||||
final String username;
|
final String username;
|
||||||
final String password;
|
final String password;
|
||||||
final String host;
|
final String host;
|
||||||
final String url;
|
final String url;
|
||||||
final List<String> targets;
|
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.title = title;
|
||||||
this.username = username;
|
this.username = username;
|
||||||
this.password = password;
|
this.password = password;
|
||||||
this.host = host;
|
this.host = host;
|
||||||
this.url = url;
|
this.url = url;
|
||||||
this.targets = new ArrayList<>(targets);
|
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
public final class KeePassGOAccessibilityService extends AccessibilityService {
|
||||||
private static final String TAG = "KeePassGOA11y";
|
private static final String TAG = "KeePassGOA11y";
|
||||||
|
|
||||||
private String lastFilledSignature = "";
|
private String lastFilledSignature = "";
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@@ -22,22 +21,17 @@ public final class KeePassGOAccessibilityService extends AccessibilityService {
|
|||||||
if (root == null) {
|
if (root == null) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
CharSequence packageName = root.getPackageName();
|
ChromeForm form = inspectWindow(root);
|
||||||
if (packageName == null || !"com.android.chrome".contentEquals(packageName)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
ChromeForm form = inspectChrome(root);
|
|
||||||
if (form == null || form.passwordField == null) {
|
if (form == null || form.passwordField == null) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
AutofillCacheStore.Entry entry = AutofillCacheStore.findBestMatch(this, form.url);
|
AutofillCacheStore.Entry entry = AutofillCacheStore.findBestMatch(this, form.matchTarget);
|
||||||
if (entry == null) {
|
if (entry == null) {
|
||||||
Log.i(TAG, "no accessibility-fill match for " + form.url);
|
Log.i(TAG, "no accessibility-fill match for " + form.matchTarget);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
String signature = form.url + "|" + entry.username + "|" + nodeKey(form.passwordField);
|
String signature = form.matchTarget + "|" + entry.username + "|" + nodeKey(form.passwordField);
|
||||||
if (signature.equals(lastFilledSignature)) {
|
if (signature.equals(lastFilledSignature)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -49,7 +43,7 @@ public final class KeePassGOAccessibilityService extends AccessibilityService {
|
|||||||
filled |= setNodeText(form.passwordField, entry.password);
|
filled |= setNodeText(form.passwordField, entry.password);
|
||||||
if (filled) {
|
if (filled) {
|
||||||
lastFilledSignature = signature;
|
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) {
|
} catch (Exception err) {
|
||||||
Log.e(TAG, "accessibility fill failed", err);
|
Log.e(TAG, "accessibility fill failed", err);
|
||||||
@@ -61,11 +55,11 @@ public final class KeePassGOAccessibilityService extends AccessibilityService {
|
|||||||
Log.i(TAG, "accessibility service interrupted");
|
Log.i(TAG, "accessibility service interrupted");
|
||||||
}
|
}
|
||||||
|
|
||||||
private static ChromeForm inspectChrome(AccessibilityNodeInfo root) {
|
private static ChromeForm inspectWindow(AccessibilityNodeInfo root) {
|
||||||
List<AccessibilityNodeInfo> editables = new ArrayList<>();
|
List<AccessibilityNodeInfo> editables = new ArrayList<>();
|
||||||
ChromeForm form = new ChromeForm();
|
ChromeForm form = new ChromeForm();
|
||||||
walk(root, editables, form);
|
walk(root, editables, form);
|
||||||
if (form.url.isEmpty()) {
|
if (form.matchTarget.isEmpty()) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
if (form.passwordField == null) {
|
if (form.passwordField == null) {
|
||||||
@@ -111,7 +105,13 @@ public final class KeePassGOAccessibilityService extends AccessibilityService {
|
|||||||
if (viewID != null && viewID.toString().endsWith(":id/url_bar")) {
|
if (viewID != null && viewID.toString().endsWith(":id/url_bar")) {
|
||||||
CharSequence text = node.getText();
|
CharSequence text = node.getText();
|
||||||
if (text != null) {
|
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()) {
|
if (node.isEditable()) {
|
||||||
@@ -128,6 +128,7 @@ public final class KeePassGOAccessibilityService extends AccessibilityService {
|
|||||||
walk(child, editables, form);
|
walk(child, editables, form);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
form.matchTarget = AutofillFallbackTarget.resolve(form.packageName, form.webDomain);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static boolean isPasswordNode(AccessibilityNodeInfo node) {
|
private static boolean isPasswordNode(AccessibilityNodeInfo node) {
|
||||||
@@ -188,7 +189,9 @@ public final class KeePassGOAccessibilityService extends AccessibilityService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private static final class ChromeForm {
|
private static final class ChromeForm {
|
||||||
String url = "";
|
String packageName = "";
|
||||||
|
String webDomain = "";
|
||||||
|
String matchTarget = "";
|
||||||
AccessibilityNodeInfo usernameField;
|
AccessibilityNodeInfo usernameField;
|
||||||
AccessibilityNodeInfo passwordField;
|
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;
|
package org.julianfamily.keepassgo;
|
||||||
|
|
||||||
|
import android.app.PendingIntent;
|
||||||
import android.app.assist.AssistStructure;
|
import android.app.assist.AssistStructure;
|
||||||
|
import android.content.Intent;
|
||||||
import android.os.CancellationSignal;
|
import android.os.CancellationSignal;
|
||||||
import android.service.autofill.AutofillService;
|
import android.service.autofill.AutofillService;
|
||||||
import android.service.autofill.Dataset;
|
import android.service.autofill.Dataset;
|
||||||
@@ -66,29 +68,21 @@ public final class KeePassGOAutofillService extends AutofillService {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
AutofillCacheStore.Entry entry = AutofillCacheStore.findBestMatch(this, target.matchTarget);
|
AutofillCacheStore.Entry entry = findBoundOrBestMatch(target.matchTarget);
|
||||||
if (entry == null) {
|
if (entry == null) {
|
||||||
|
FillResponse chooser = chooserResponse(target, fields);
|
||||||
|
if (chooser == null) {
|
||||||
Log.i(TAG, "no autofill cache match");
|
Log.i(TAG, "no autofill cache match");
|
||||||
callback.onSuccess(null);
|
callback.onSuccess(null);
|
||||||
return;
|
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);
|
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);
|
FillResponse response = directFillResponse(entry, fields);
|
||||||
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();
|
|
||||||
Log.i(TAG, "returning dataset");
|
Log.i(TAG, "returning dataset");
|
||||||
callback.onSuccess(response);
|
callback.onSuccess(response);
|
||||||
} catch (Exception err) {
|
} catch (Exception err) {
|
||||||
@@ -103,6 +97,72 @@ public final class KeePassGOAutofillService extends AutofillService {
|
|||||||
callback.onSuccess();
|
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) {
|
private static ParsedTarget parseWindow(AssistStructure structure, ParsedFields fields) {
|
||||||
String domain = "";
|
String domain = "";
|
||||||
final int windowCount = structure.getWindowNodeCount();
|
final int windowCount = structure.getWindowNodeCount();
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package org.julianfamily.keepassgo;
|
package org.julianfamily.keepassgo;
|
||||||
|
|
||||||
import android.app.Activity;
|
import android.app.Activity;
|
||||||
|
import android.content.ClipData;
|
||||||
import android.content.Intent;
|
import android.content.Intent;
|
||||||
import android.database.Cursor;
|
import android.database.Cursor;
|
||||||
import android.net.Uri;
|
import android.net.Uri;
|
||||||
@@ -10,13 +11,18 @@ import android.util.Log;
|
|||||||
|
|
||||||
import java.io.File;
|
import java.io.File;
|
||||||
import java.io.FileOutputStream;
|
import java.io.FileOutputStream;
|
||||||
|
import java.io.FileInputStream;
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
import java.io.InputStream;
|
import java.io.InputStream;
|
||||||
import java.nio.charset.StandardCharsets;
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
|
||||||
public final class SharedVaultImportActivity extends Activity {
|
public final class SharedVaultImportActivity extends Activity {
|
||||||
private static final String TAG = "KeePassGOImport";
|
private static final String TAG = "KeePassGOImport";
|
||||||
private static final String DEFAULT_NAME = "shared-vault.kdbx";
|
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
|
@Override
|
||||||
protected void onCreate(Bundle state) {
|
protected void onCreate(Bundle state) {
|
||||||
@@ -36,6 +42,17 @@ public final class SharedVaultImportActivity extends Activity {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private void handleIntent(Intent intent) {
|
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);
|
Uri uri = resolveSharedUri(intent);
|
||||||
if (uri == null) {
|
if (uri == null) {
|
||||||
Log.i(TAG, "no shared vault URI on intent");
|
Log.i(TAG, "no shared vault URI on intent");
|
||||||
@@ -55,21 +72,63 @@ public final class SharedVaultImportActivity extends Activity {
|
|||||||
}
|
}
|
||||||
String action = intent.getAction();
|
String action = intent.getAction();
|
||||||
if (Intent.ACTION_SEND.equals(action)) {
|
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)) {
|
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;
|
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 {
|
private void persistPendingImport(Uri uri) throws IOException {
|
||||||
File dir = new File(getFilesDir(), "keepassgo");
|
File dir = new File(getFilesDir(), "keepassgo");
|
||||||
if (!dir.exists() && !dir.mkdirs()) {
|
if (!dir.exists() && !dir.mkdirs()) {
|
||||||
throw new IOException("failed to create " + dir.getAbsolutePath());
|
throw new IOException("failed to create " + dir.getAbsolutePath());
|
||||||
}
|
}
|
||||||
File pendingFile = new File(dir, "pending-shared-vault.kdbx");
|
File pendingFile = new File(dir, PENDING_SHARED_VAULT);
|
||||||
try (InputStream in = getContentResolver().openInputStream(uri)) {
|
try (InputStream in = openSharedInputStream(uri)) {
|
||||||
if (in == null) {
|
if (in == null) {
|
||||||
throw new IOException("failed to open shared vault stream");
|
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)) {
|
try (FileOutputStream out = new FileOutputStream(nameFile, false)) {
|
||||||
out.write(resolveDisplayName(uri).getBytes(StandardCharsets.UTF_8));
|
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) {
|
private String resolveDisplayName(Uri uri) {
|
||||||
String displayName = queryDisplayName(uri);
|
String displayName = queryDisplayName(uri);
|
||||||
if (!displayName.isEmpty()) {
|
if (!displayName.isEmpty()) {
|
||||||
@@ -123,6 +204,20 @@ public final class SharedVaultImportActivity extends Activity {
|
|||||||
return "";
|
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() {
|
private void launchMainActivity() {
|
||||||
Intent launch = new Intent();
|
Intent launch = new Intent();
|
||||||
launch.setClassName(this, "org.gioui.GioActivity");
|
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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,10 +2,26 @@
|
|||||||
|
|
||||||
Shared extension assets for Firefox and Chromium-based browsers live here.
|
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.firefox.json` uses the fixed Firefox extension id `browser@keepassgo.com`
|
||||||
- `manifest.chromium.json` is the Chromium/Chrome manifest template
|
- `manifest.chromium.json` is the Chromium/Chrome manifest template
|
||||||
- `background.js` talks to the native messaging host `com.keepassgo.browser`
|
- `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
|
- `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 local gRPC address and API token in browser extension storage
|
- `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.
|
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.
|
||||||
|
|||||||
+915
-48
File diff suppressed because it is too large
Load Diff
@@ -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"]);
|
||||||
|
});
|
||||||
+852
-22
@@ -1,3 +1,23 @@
|
|||||||
|
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) {
|
function isVisibleInput(input) {
|
||||||
if (!(input instanceof HTMLInputElement)) {
|
if (!(input instanceof HTMLInputElement)) {
|
||||||
return false;
|
return false;
|
||||||
@@ -12,41 +32,361 @@ function isVisibleInput(input) {
|
|||||||
return input.offsetParent !== null || style.position === "fixed";
|
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) {
|
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("input", { bubbles: true }));
|
||||||
|
}
|
||||||
input.dispatchEvent(new Event("change", { bubbles: true }));
|
input.dispatchEvent(new Event("change", { bubbles: true }));
|
||||||
}
|
}
|
||||||
|
|
||||||
function findPasswordInput() {
|
function setInputValue(input, value) {
|
||||||
return Array.from(document.querySelectorAll('input[type="password"]')).find(isVisibleInput) || null;
|
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 findUsernameInput(passwordInput) {
|
function visibleInputs(scope) {
|
||||||
const form = passwordInput?.form || null;
|
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 scope = form || document;
|
||||||
const candidates = Array.from(scope.querySelectorAll('input[type="text"], input[type="email"], input:not([type])'))
|
const inputs = visibleInputs(scope);
|
||||||
.filter(isVisibleInput);
|
const fieldIndex = inputs.indexOf(input);
|
||||||
if (passwordInput) {
|
const forms = Array.from(document.forms || []);
|
||||||
const sameForm = candidates.find((input) => input.form === passwordInput.form);
|
return {
|
||||||
if (sameForm) {
|
role: normalizedRole,
|
||||||
return sameForm;
|
formIndex: form ? forms.indexOf(form) : -1,
|
||||||
}
|
fieldIndex,
|
||||||
}
|
id: String(input.id || ""),
|
||||||
return candidates[0] || null;
|
name: String(input.name || ""),
|
||||||
|
autocomplete: String(input.autocomplete || "").toLowerCase()
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function fillCredential(credential) {
|
function resolveFieldDescriptor(descriptor) {
|
||||||
const passwordInput = findPasswordInput();
|
if (!descriptor || typeof descriptor !== "object") {
|
||||||
const usernameInput = findUsernameInput(passwordInput);
|
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) {
|
if (usernameInput && credential.username) {
|
||||||
usernameInput.focus();
|
usernameInput.focus();
|
||||||
usernameInput.value = credential.username;
|
setInputValue(usernameInput, credential.username);
|
||||||
dispatchFillEvents(usernameInput);
|
dispatchFillEvents(usernameInput);
|
||||||
}
|
}
|
||||||
if (passwordInput && credential.password) {
|
if (passwordInput && credential.password) {
|
||||||
passwordInput.focus();
|
passwordInput.focus();
|
||||||
passwordInput.value = credential.password;
|
setInputValue(passwordInput, credential.password);
|
||||||
dispatchFillEvents(passwordInput);
|
dispatchFillEvents(passwordInput);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -56,14 +396,504 @@ function fillCredential(credential) {
|
|||||||
return { ok: true };
|
return { ok: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
(globalThis.browser ?? globalThis.chrome).runtime.onMessage.addListener((message, _sender, sendResponse) => {
|
function submittedCredential(candidate, rawURL) {
|
||||||
if (message?.type !== "keepassgo-fill-credential") {
|
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 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 {
|
try {
|
||||||
sendResponse(fillCredential(message.credential || {}));
|
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) {
|
} catch (error) {
|
||||||
sendResponse({ ok: false, error: error instanceof Error ? error.message : String(error) });
|
sendResponse({ ok: false, error: error instanceof Error ? error.message : String(error) });
|
||||||
}
|
}
|
||||||
return false;
|
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);
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 |
@@ -2,7 +2,7 @@
|
|||||||
"manifest_version": 3,
|
"manifest_version": 3,
|
||||||
"name": "KeePassGO Browser",
|
"name": "KeePassGO Browser",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"description": "Fill credentials from KeePassGO over the local gRPC API.",
|
"description": "Fill credentials from KeePassGO on sign-in pages.",
|
||||||
"permissions": ["activeTab", "nativeMessaging", "storage", "tabs"],
|
"permissions": ["activeTab", "nativeMessaging", "storage", "tabs"],
|
||||||
"host_permissions": ["http://*/*", "https://*/*"],
|
"host_permissions": ["http://*/*", "https://*/*"],
|
||||||
"background": {
|
"background": {
|
||||||
|
|||||||
@@ -1,15 +1,32 @@
|
|||||||
{
|
{
|
||||||
"manifest_version": 3,
|
"manifest_version": 2,
|
||||||
"name": "KeePassGO Browser",
|
"name": "KeePassGO Browser",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"description": "Fill credentials from KeePassGO over the local gRPC API.",
|
"description": "Fill credentials from KeePassGO on sign-in pages.",
|
||||||
"permissions": ["activeTab", "nativeMessaging", "storage", "tabs"],
|
"icons": {
|
||||||
"host_permissions": ["http://*/*", "https://*/*"],
|
"16": "icons/icon-16.png",
|
||||||
"background": {
|
"32": "icons/icon-32.png",
|
||||||
"service_worker": "background.js"
|
"48": "icons/icon-48.png",
|
||||||
|
"96": "icons/icon-96.png",
|
||||||
|
"128": "icons/icon-128.png"
|
||||||
},
|
},
|
||||||
"action": {
|
"permissions": [
|
||||||
|
"activeTab",
|
||||||
|
"nativeMessaging",
|
||||||
|
"storage",
|
||||||
|
"tabs",
|
||||||
|
"http://*/*",
|
||||||
|
"https://*/*"
|
||||||
|
],
|
||||||
|
"background": {
|
||||||
|
"scripts": ["background.js"]
|
||||||
|
},
|
||||||
|
"browser_action": {
|
||||||
"default_title": "KeePassGO Browser",
|
"default_title": "KeePassGO Browser",
|
||||||
|
"default_icon": {
|
||||||
|
"16": "icons/icon-16.png",
|
||||||
|
"32": "icons/icon-32.png"
|
||||||
|
},
|
||||||
"default_popup": "popup.html"
|
"default_popup": "popup.html"
|
||||||
},
|
},
|
||||||
"options_ui": {
|
"options_ui": {
|
||||||
@@ -25,7 +42,14 @@
|
|||||||
],
|
],
|
||||||
"browser_specific_settings": {
|
"browser_specific_settings": {
|
||||||
"gecko": {
|
"gecko": {
|
||||||
"id": "browser@keepassgo.com"
|
"id": "browser@keepassgo.com",
|
||||||
|
"data_collection_permissions": {
|
||||||
|
"required": ["authenticationInfo", "websiteActivity"]
|
||||||
|
},
|
||||||
|
"strict_min_version": "140.0"
|
||||||
|
},
|
||||||
|
"gecko_android": {
|
||||||
|
"strict_min_version": "142.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,18 +11,33 @@
|
|||||||
<header class="topbar">
|
<header class="topbar">
|
||||||
<div>
|
<div>
|
||||||
<h1>Browser Settings</h1>
|
<h1>Browser Settings</h1>
|
||||||
<p class="subtle">Configure how the extension reaches KeePassGO.</p>
|
<p class="subtle">Connect the extension to KeePassGO.</p>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<form id="settings-form" class="settings-form">
|
<form id="settings-form" class="settings-form">
|
||||||
<label>
|
|
||||||
<span>gRPC address</span>
|
|
||||||
<input id="grpc-address" name="grpc-address" type="text" value="" placeholder="Leave blank for the local default socket" autocomplete="off">
|
|
||||||
</label>
|
|
||||||
<label>
|
<label>
|
||||||
<span>API token</span>
|
<span>API token</span>
|
||||||
<textarea id="bearer-token" name="bearer-token" rows="6" spellcheck="false"></textarea>
|
<textarea id="bearer-token" name="bearer-token" rows="6" spellcheck="false"></textarea>
|
||||||
</label>
|
</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">
|
<div class="actions">
|
||||||
<button type="submit">Save</button>
|
<button type="submit">Save</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
const extOptions = globalThis.browser ?? globalThis.chrome;
|
const extOptions = globalThis.browser ?? globalThis.chrome;
|
||||||
|
const usePromiseAPI = typeof globalThis.browser !== "undefined";
|
||||||
|
|
||||||
function runtimeSend(message) {
|
function runtimeSend(message) {
|
||||||
|
if (usePromiseAPI) {
|
||||||
|
return extOptions.runtime.sendMessage(message);
|
||||||
|
}
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
extOptions.runtime.sendMessage(message, (response) => {
|
extOptions.runtime.sendMessage(message, (response) => {
|
||||||
const error = extOptions.runtime.lastError;
|
const error = extOptions.runtime.lastError;
|
||||||
@@ -18,8 +22,10 @@ async function loadSettings() {
|
|||||||
if (!response?.success) {
|
if (!response?.success) {
|
||||||
throw new Error(response?.error || "Could not load settings.");
|
throw new Error(response?.error || "Could not load settings.");
|
||||||
}
|
}
|
||||||
document.getElementById("grpc-address").value = response.settings.grpcAddress || "";
|
|
||||||
document.getElementById("bearer-token").value = response.settings.bearerToken || "";
|
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) {
|
async function saveSettings(event) {
|
||||||
@@ -30,8 +36,10 @@ async function saveSettings(event) {
|
|||||||
const response = await runtimeSend({
|
const response = await runtimeSend({
|
||||||
type: "keepassgo-save-settings",
|
type: "keepassgo-save-settings",
|
||||||
settings: {
|
settings: {
|
||||||
grpcAddress: document.getElementById("grpc-address").value,
|
bearerToken: document.getElementById("bearer-token").value,
|
||||||
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) {
|
if (!response?.success) {
|
||||||
|
|||||||
@@ -19,10 +19,26 @@
|
|||||||
<strong id="status-title">Loading</strong>
|
<strong id="status-title">Loading</strong>
|
||||||
<p id="status-message" class="subtle">Checking KeePassGO.</p>
|
<p id="status-message" class="subtle">Checking KeePassGO.</p>
|
||||||
</section>
|
</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>
|
<section>
|
||||||
<h2>Matches</h2>
|
<h2>Matches</h2>
|
||||||
<div id="matches" class="match-list"></div>
|
<div id="matches" class="match-list"></div>
|
||||||
</section>
|
</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>
|
</main>
|
||||||
<script src="popup.js"></script>
|
<script src="popup.js"></script>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
+217
-19
@@ -1,6 +1,10 @@
|
|||||||
const extPopup = globalThis.browser ?? globalThis.chrome;
|
const extPopup = globalThis.browser ?? globalThis.chrome;
|
||||||
|
const usePromiseAPI = typeof globalThis.browser !== "undefined";
|
||||||
|
|
||||||
function runtimeSend(message) {
|
function runtimeSend(message) {
|
||||||
|
if (usePromiseAPI) {
|
||||||
|
return extPopup.runtime.sendMessage(message);
|
||||||
|
}
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
extPopup.runtime.sendMessage(message, (response) => {
|
extPopup.runtime.sendMessage(message, (response) => {
|
||||||
const error = extPopup.runtime.lastError;
|
const error = extPopup.runtime.lastError;
|
||||||
@@ -28,38 +32,72 @@ function setStatus(title, message, tone) {
|
|||||||
document.getElementById("status-message").textContent = message;
|
document.getElementById("status-message").textContent = message;
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderMatches(state) {
|
function matchSubtitle(match) {
|
||||||
const root = document.getElementById("matches");
|
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 = "";
|
root.textContent = "";
|
||||||
if (!Array.isArray(state.matches) || state.matches.length === 0) {
|
if (!Array.isArray(matches) || matches.length === 0) {
|
||||||
const empty = document.createElement("p");
|
const empty = document.createElement("p");
|
||||||
empty.className = "subtle";
|
empty.className = "subtle";
|
||||||
empty.textContent = "No matching entries for this page.";
|
empty.textContent = emptyMessage;
|
||||||
root.appendChild(empty);
|
root.appendChild(empty);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const match of state.matches) {
|
for (const match of matches) {
|
||||||
const row = document.createElement("button");
|
const row = document.createElement("button");
|
||||||
row.type = "button";
|
row.type = "button";
|
||||||
row.className = "match-row";
|
row.className = "match-row";
|
||||||
row.innerHTML = `
|
const main = document.createElement("span");
|
||||||
<span class="match-main">
|
main.className = "match-main";
|
||||||
<strong>${match.title}</strong>
|
const title = document.createElement("strong");
|
||||||
<span class="subtle">${match.username || "No username"}</span>
|
title.textContent = match.title;
|
||||||
</span>
|
const subtitle = document.createElement("span");
|
||||||
<span class="quality">${match.quality || ""}</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.addEventListener("click", async () => {
|
||||||
row.disabled = true;
|
row.disabled = true;
|
||||||
try {
|
try {
|
||||||
const result = await runtimeSend({ type: "keepassgo-fill-entry", entryId: match.id });
|
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) {
|
if (!result?.success) {
|
||||||
throw new Error(result?.error || "Fill failed.");
|
throw new Error(result?.error || "Fill failed.");
|
||||||
}
|
}
|
||||||
setStatus("Filled", `${match.title} was sent to the current page.`, "ready");
|
setStatus("Filled", `${match.title} was sent to the current page.`, "ready");
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setStatus("Fill failed", error instanceof Error ? error.message : String(error), "error");
|
setStatus(options.onSelect ? "Save failed" : "Fill failed", error instanceof Error ? error.message : String(error), "error");
|
||||||
} finally {
|
} finally {
|
||||||
row.disabled = false;
|
row.disabled = false;
|
||||||
}
|
}
|
||||||
@@ -68,29 +106,189 @@ function renderMatches(state) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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() {
|
async function main() {
|
||||||
try {
|
try {
|
||||||
const state = await runtimeSend({ type: "keepassgo-popup-state" });
|
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 || "");
|
document.getElementById("page-host").textContent = hostFromURL(state.pageUrl || "");
|
||||||
|
renderPageHint(state);
|
||||||
|
renderPendingSave(state);
|
||||||
|
|
||||||
if (!state.configured) {
|
if (!state.configured) {
|
||||||
setStatus("Configure access", state.error || "Set the API token in extension settings.", "warning");
|
setStatus("Configure access", state.error || "Set the API token in extension settings.", "warning");
|
||||||
renderMatches({ matches: [] });
|
renderMatches({ matches: [] });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (state.pendingFill) {
|
||||||
|
setStatus("Approval needed", state.pendingMessage || "Approve or deny the fill request in KeePassGO.", "warning");
|
||||||
|
renderMatches(state);
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (!state.success) {
|
if (!state.success) {
|
||||||
setStatus("KeePassGO unavailable", state.error || "The native host could not reach KeePassGO.", "error");
|
setStatus("KeePassGO unavailable", state.error || "The native host could not reach KeePassGO.", "error");
|
||||||
renderMatches({ matches: [] });
|
renderMatches(state);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (state.status?.locked) {
|
if (state.status?.locked) {
|
||||||
setStatus("Vault locked", "Unlock KeePassGO, then open the popup again.", "warning");
|
setStatus("Vault locked", "Unlock KeePassGO, then try the page again.", "warning");
|
||||||
renderMatches({ matches: [] });
|
renderMatches(state);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const count = Array.isArray(state.matches) ? state.matches.length : 0;
|
const count = Array.isArray(state.matches) ? state.matches.length : 0;
|
||||||
setStatus("Ready", count === 0 ? "KeePassGO is connected." : `${count} matching entr${count === 1 ? "y" : "ies"} found.`, "ready");
|
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);
|
renderMatches(state);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setStatus("Error", error instanceof Error ? error.message : String(error), "error");
|
setStatus("Error", error instanceof Error ? error.message : String(error), "error");
|
||||||
|
|||||||
@@ -86,12 +86,39 @@ h2 {
|
|||||||
background: #fcf1f1;
|
background: #fcf1f1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.inline-hint {
|
||||||
|
margin: -6px 0 16px;
|
||||||
|
}
|
||||||
|
|
||||||
.match-list {
|
.match-list {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 8px;
|
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,
|
.match-row,
|
||||||
button,
|
button,
|
||||||
.link-button {
|
.link-button {
|
||||||
@@ -150,7 +177,8 @@ label {
|
|||||||
}
|
}
|
||||||
|
|
||||||
input,
|
input,
|
||||||
textarea {
|
textarea,
|
||||||
|
select {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 10px 12px;
|
padding: 10px 12px;
|
||||||
border: 1px solid var(--line);
|
border: 1px solid var(--line);
|
||||||
@@ -160,6 +188,33 @@ textarea {
|
|||||||
font: inherit;
|
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,
|
button,
|
||||||
.link-button {
|
.link-button {
|
||||||
padding: 10px 14px;
|
padding: 10px 14px;
|
||||||
|
|||||||
@@ -6,16 +6,22 @@ import (
|
|||||||
"flag"
|
"flag"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
|
||||||
"path/filepath"
|
|
||||||
"runtime"
|
"runtime"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"git.julianfamily.org/keepassgo/internal/browserbridge"
|
"git.julianfamily.org/keepassgo/internal/browserbridge"
|
||||||
"git.julianfamily.org/keepassgo/internal/grpcaddr"
|
"git.julianfamily.org/keepassgo/internal/grpcaddr"
|
||||||
|
"google.golang.org/grpc"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
type bridgeConfig struct {
|
||||||
|
grpcAddr string
|
||||||
|
}
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
|
cfg := bridgeConfig{
|
||||||
|
grpcAddr: resolveGlobalGRPCAddr(os.Args[1:]),
|
||||||
|
}
|
||||||
if len(os.Args) > 1 {
|
if len(os.Args) > 1 {
|
||||||
switch strings.TrimSpace(os.Args[1]) {
|
switch strings.TrimSpace(os.Args[1]) {
|
||||||
case "install-native-host":
|
case "install-native-host":
|
||||||
@@ -24,13 +30,13 @@ func main() {
|
|||||||
}
|
}
|
||||||
return
|
return
|
||||||
case "status":
|
case "status":
|
||||||
if err := runStatus(os.Args[2:]); err != nil {
|
if err := runStatus(cfg, stripGlobalGRPCAddrFlags(os.Args[2:])); err != nil {
|
||||||
fail(err)
|
fail(err)
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if err := runNativeMessage(); err != nil {
|
if err := runNativeMessage(cfg); err != nil {
|
||||||
_ = browserbridge.WriteResponse(os.Stdout, browserbridge.Response{Success: false, Error: err.Error()})
|
_ = browserbridge.WriteResponse(os.Stdout, browserbridge.Response{Success: false, Error: err.Error()})
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
@@ -81,60 +87,77 @@ func runInstallNativeHost(args []string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func runStatus(args []string) error {
|
func runStatus(cfg bridgeConfig, args []string) error {
|
||||||
fs := flag.NewFlagSet("status", flag.ContinueOnError)
|
fs := flag.NewFlagSet("status", flag.ContinueOnError)
|
||||||
grpcAddr := fs.String("grpc-addr", grpcaddr.Default(runtime.GOOS), "KeePassGO local gRPC address")
|
|
||||||
token := fs.String("token", "", "KeePassGO API bearer token")
|
token := fs.String("token", "", "KeePassGO API bearer token")
|
||||||
if err := fs.Parse(args); err != nil {
|
if err := fs.Parse(args); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
req := browserbridge.Request{
|
req := browserbridge.Request{
|
||||||
Action: "status",
|
Action: "status",
|
||||||
GRPCAddress: strings.TrimSpace(*grpcAddr),
|
|
||||||
BearerToken: strings.TrimSpace(*token),
|
BearerToken: strings.TrimSpace(*token),
|
||||||
}
|
}
|
||||||
connCfg, err := req.Connection()
|
conn, client, ctx, err := dialBridge(context.Background(), cfg, req)
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
conn, client, ctx, err := browserbridge.Dial(context.Background(), connCfg)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
defer func() { _ = conn.Close() }()
|
defer func() { _ = conn.Close() }()
|
||||||
resp := browserbridge.HandleRequest(ctx, req, client)
|
resp := browserbridge.HandleRequest(ctx, req, cfg.grpcAddr, client)
|
||||||
enc := json.NewEncoder(os.Stdout)
|
enc := json.NewEncoder(os.Stdout)
|
||||||
enc.SetIndent("", " ")
|
enc.SetIndent("", " ")
|
||||||
return enc.Encode(resp)
|
return enc.Encode(resp)
|
||||||
}
|
}
|
||||||
|
|
||||||
func runNativeMessage() error {
|
func runNativeMessage(cfg bridgeConfig) error {
|
||||||
req, err := browserbridge.ReadRequest(os.Stdin)
|
req, err := browserbridge.ReadRequest(os.Stdin)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
connCfg, err := req.Connection()
|
conn, client, ctx, err := dialBridge(context.Background(), cfg, req)
|
||||||
if err != nil {
|
|
||||||
return browserbridge.WriteResponse(os.Stdout, browserbridge.Response{Success: false, Error: err.Error()})
|
|
||||||
}
|
|
||||||
conn, client, ctx, err := browserbridge.Dial(context.Background(), connCfg)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return browserbridge.WriteResponse(os.Stdout, browserbridge.Response{Success: false, Error: err.Error()})
|
return browserbridge.WriteResponse(os.Stdout, browserbridge.Response{Success: false, Error: err.Error()})
|
||||||
}
|
}
|
||||||
defer func() { _ = conn.Close() }()
|
defer func() { _ = conn.Close() }()
|
||||||
return browserbridge.WriteResponse(os.Stdout, browserbridge.HandleRequest(ctx, req, client))
|
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) {
|
func defaultBinaryPath() (string, error) {
|
||||||
self, err := os.Executable()
|
return browserbridge.ResolveBridgeBinaryPath("")
|
||||||
if err == nil && strings.TrimSpace(self) != "" {
|
}
|
||||||
return self, nil
|
|
||||||
|
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="))
|
||||||
}
|
}
|
||||||
self, err = exec.LookPath("keepassgo-browser-bridge")
|
|
||||||
if err == nil {
|
|
||||||
return self, nil
|
|
||||||
}
|
}
|
||||||
return filepath.Abs("./keepassgo-browser-bridge")
|
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) {
|
func fail(err error) {
|
||||||
|
|||||||
@@ -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.
|
||||||
+119
-7
@@ -11,7 +11,7 @@ The browser extension does **not** talk to vault files directly.
|
|||||||
## Security Model
|
## Security Model
|
||||||
|
|
||||||
- KeePassGO remains the source of truth for authentication, authorization, approvals, and audit events.
|
- KeePassGO remains the source of truth for authentication, authorization, approvals, and audit events.
|
||||||
- The browser extension stores the gRPC address and API token in browser extension storage.
|
- 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 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 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 does not persist the token to disk.
|
||||||
@@ -50,6 +50,8 @@ Build the bridge:
|
|||||||
go build ./cmd/keepassgo-browser-bridge
|
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:
|
Install a Firefox native messaging manifest:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -76,15 +78,125 @@ Firefox:
|
|||||||
|
|
||||||
1. Load `browser/extension/manifest.firefox.json` as a temporary add-on or package it as an extension.
|
1. Load `browser/extension/manifest.firefox.json` as a temporary add-on or package it as an extension.
|
||||||
2. Open the extension settings page.
|
2. Open the extension settings page.
|
||||||
3. Leave the gRPC address blank to use the local default Unix socket, or set an explicit address if you overrode the listener.
|
3. Paste an API token scoped for browser login lookup and credential copy.
|
||||||
4. Paste an API token scoped for browser login lookup and credential copy.
|
|
||||||
|
|
||||||
Chromium / Chrome:
|
Chromium / Chrome:
|
||||||
|
|
||||||
1. Load `browser/extension/` with `manifest.chromium.json`.
|
1. Load a Chromium manifest based on `browser/extension/manifest.chromium.json`, or install the published extension when that distribution exists.
|
||||||
2. Note the extension id the browser assigns.
|
2. Start KeePassGO once so it can refresh the native host manifest for the discovered extension id.
|
||||||
3. Install the native host manifest with that extension id.
|
3. Configure the API token in the extension settings page.
|
||||||
4. Configure the gRPC address and 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
|
## Required Token Scope
|
||||||
|
|
||||||
|
|||||||
@@ -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:
|
||||||
|
```
|
||||||
@@ -2,8 +2,12 @@ module git.julianfamily.org/keepassgo
|
|||||||
|
|
||||||
go 1.26
|
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 (
|
require (
|
||||||
gioui.org v0.8.0
|
gioui.org v0.9.0
|
||||||
gioui.org/x v0.8.0
|
gioui.org/x v0.8.0
|
||||||
github.com/atotto/clipboard v0.1.4
|
github.com/atotto/clipboard v0.1.4
|
||||||
github.com/tobischo/gokeepasslib/v3 v3.6.2
|
github.com/tobischo/gokeepasslib/v3 v3.6.2
|
||||||
@@ -193,7 +197,6 @@ require (
|
|||||||
go.uber.org/multierr v1.6.0 // indirect
|
go.uber.org/multierr v1.6.0 // indirect
|
||||||
go.uber.org/zap v1.24.0 // indirect
|
go.uber.org/zap v1.24.0 // indirect
|
||||||
golang.org/x/crypto v0.48.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/exp/typeparams v0.0.0-20250210185358-939b2ce775ac // indirect
|
||||||
golang.org/x/image v0.37.0 // indirect
|
golang.org/x/image v0.37.0 // indirect
|
||||||
golang.org/x/mod v0.33.0 // indirect
|
golang.org/x/mod v0.33.0 // indirect
|
||||||
|
|||||||
@@ -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=
|
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 h1:ARo7NCVvN2NdhLlJE9xAbKweuI9L6UgfTbYb0YwPacY=
|
||||||
eliasnaur.com/font v0.0.0-20230308162249-dd43949cb42d/go.mod h1:OYVuxibdk9OSLX8vAqydtRPP87PyTFcT9uH3MlEGBQA=
|
eliasnaur.com/font v0.0.0-20230308162249-dd43949cb42d/go.mod h1:OYVuxibdk9OSLX8vAqydtRPP87PyTFcT9uH3MlEGBQA=
|
||||||
gioui.org v0.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/cpu v0.0.0-20210808092351-bfe733dd3334/go.mod h1:A8M0Cn5o+vY5LTMlnRoK3O5kG+rH0kWfJjeKd9QpBmQ=
|
||||||
gioui.org/shader v1.0.8 h1:6ks0o/A+b0ne7RzEqRZK5f4Gboz2CfG+mVliciy6+qA=
|
gioui.org/shader v1.0.8 h1:6ks0o/A+b0ne7RzEqRZK5f4Gboz2CfG+mVliciy6+qA=
|
||||||
gioui.org/shader v1.0.8/go.mod h1:mWdiME581d/kV7/iEhLmUgUK5iZ09XR5XpduXzbePVM=
|
gioui.org/shader v1.0.8/go.mod h1:mWdiME581d/kV7/iEhLmUgUK5iZ09XR5XpduXzbePVM=
|
||||||
gioui.org/x v0.8.0 h1:RhIlQNOFKKn8D8FeaKKaXCo7vB3x+fq4VcD10HW/YpA=
|
gioui.org/x v0.8.0 h1:RhIlQNOFKKn8D8FeaKKaXCo7vB3x+fq4VcD10HW/YpA=
|
||||||
gioui.org/x v0.8.0/go.mod h1:aXtQb+kyqoUOjDl5/uMqAopjzVzMkeHBbMQOGT5KnSE=
|
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 h1:bGG/g4ypjrCJoSvFrP5hafr9PPB5aw8SjcOWWila7ZI=
|
||||||
git.wow.st/gmp/jni v0.0.0-20210610011705-34026c7e22d0/go.mod h1:+axXBRUTIDlCeE73IKeD/os7LoEnTKdkp8/gQOFjqyo=
|
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=
|
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/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 h1:2JHa3hbYf5D9dsgseMKAmc/MZ109otzgNFk5s87H9Pc=
|
||||||
github.com/chavacava/garif v0.1.0/go.mod h1:XMyYCkEL58DF0oyW4qDjjnPWONs2HBqYKI+UIPD+Gww=
|
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-20250429231605-6ed5b53462d4 h1:UZdrvid2JFwnvPlUSEFlE794XZL4Jmrj8fuxfcLECJE=
|
||||||
github.com/chromedp/cdproto v0.0.0-20191114225735-6626966fbae4/go.mod h1:PfAWWKJqjlGFYJEidUM6aVIWPr0EpobeyVWEEmplX7g=
|
github.com/chromedp/cdproto v0.0.0-20250429231605-6ed5b53462d4/go.mod h1:NItd7aLkcfOA/dcMXvl8p1u+lQqioRMq/SqDp71Pb/k=
|
||||||
github.com/chromedp/chromedp v0.5.2 h1:W8xBXQuUnd2dZK0SN/lyVwsQM7KgW+kY5HGnntms194=
|
github.com/chromedp/chromedp v0.13.6 h1:xlNunMyzS5bu3r/QKrb3fzX6ow3WBQ6oao+J65PGZxk=
|
||||||
github.com/chromedp/chromedp v0.5.2/go.mod h1:rsTo/xRo23KZZwFmWk2Ui79rBaVRRATCjLzNQlOFSiA=
|
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/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/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI=
|
||||||
github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU=
|
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 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-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-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.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as=
|
||||||
github.com/go-kit/kit v0.9.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=
|
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/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 h1:A4xDbljILXROh+kObIiy5kIaPYD8e96x1tgBhUI5J+Y=
|
||||||
github.com/gobwas/glob v0.2.3/go.mod h1:d3Ez4x06l9bZtSvzIay5+Yzi0fmZzPgnTbPcKjJAkT8=
|
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.1.0 h1:exrUm0f4YX0L7EBwZHuCF4GDp8aJfVeBrlLQrs6NqWU=
|
||||||
github.com/gobwas/httphead v0.0.0-20180130184737-2c6c146eadee/go.mod h1:L0fX3K22YWvt/FAX9NnzrNzcI4wNYi9Yku4O0LKYflo=
|
github.com/gobwas/httphead v0.1.0/go.mod h1:O/RXo79gxV8G+RqlR/otEwx4Q36zl9rqC5u12GKvMCM=
|
||||||
github.com/gobwas/pool v0.2.0 h1:QEmUOlnSjWtnpRGHF3SauEiOsy82Cup83Vf2LcMlnc8=
|
github.com/gobwas/pool v0.2.1 h1:xfeeEhW7pwmX8nuLVlqbzVc7udMDrwetjEv+TZIz1og=
|
||||||
github.com/gobwas/pool v0.2.0/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw=
|
github.com/gobwas/pool v0.2.1/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw=
|
||||||
github.com/gobwas/ws v1.0.2 h1:CoAavW/wd/kulfZmSIBt6p24n4j7tHgNVCjsfHVNUbo=
|
github.com/gobwas/ws v1.4.0 h1:CTaoG1tojrh4ucGPcoJFiAQUAsEWekEWvLy7GsVNqGs=
|
||||||
github.com/gobwas/ws v1.0.2/go.mod h1:szmBTxLgaFppYjEmNtny/v3w89xOydFnnZMcgRRu/EM=
|
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 h1:mkgN1ofwASrYnJ5W6U/BxG15eXXXjirgZc7CLqkcaro=
|
||||||
github.com/godbus/dbus/v5 v5.0.6/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
|
github.com/godbus/dbus/v5 v5.0.6/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
|
||||||
github.com/gofrs/flock v0.12.1 h1:MTLVXXHf8ekldpJk3AKicLij9MdwOWkZ+a/jHHZby9E=
|
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/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 h1:7HIyRcnyzxL9Lz06NGhiKvenXq7Zw6Q0UQu/ttjfJCE=
|
||||||
github.com/kkHAIKE/contextcheck v1.1.6/go.mod h1:3dDbMRNBFaq8HFXWC1JyvDSPm43CmE6IuHam8Wr0rkg=
|
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.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/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=
|
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/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 h1:5ibWZ6iY0NctNGWo87LalDlEZ6R41TqbbDamhfG/Qzo=
|
||||||
github.com/magiconair/properties v1.8.6/go.mod h1:y3VJvCyxH9uVvJTWEGAELF3aiYNyPKd5NZ3oSwXrF60=
|
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 h1:dU5alXRrD8WKSjOUnmJZuzdxWOEQ57+7s93SLMxb2vI=
|
||||||
github.com/maratori/testableexamples v1.0.0/go.mod h1:4rhjL1n20TUTT4vdh3RDqSizKLyXp7K2u6HgraZCGzE=
|
github.com/maratori/testableexamples v1.0.0/go.mod h1:4rhjL1n20TUTT4vdh3RDqSizKLyXp7K2u6HgraZCGzE=
|
||||||
github.com/maratori/testpackage v1.1.1 h1:S58XVV5AD7HADMmD0fNnziNHqKvSdDuEKdPD1rNTU04=
|
github.com/maratori/testpackage v1.1.1 h1:S58XVV5AD7HADMmD0fNnziNHqKvSdDuEKdPD1rNTU04=
|
||||||
|
|||||||
@@ -101,6 +101,9 @@ func (h *Host) Stop() error {
|
|||||||
h.started = false
|
h.started = false
|
||||||
h.grpcServer.Stop()
|
h.grpcServer.Stop()
|
||||||
err := h.listener.Close()
|
err := h.listener.Close()
|
||||||
|
if errors.Is(err, net.ErrClosed) {
|
||||||
|
err = nil
|
||||||
|
}
|
||||||
if h.socketPath != "" {
|
if h.socketPath != "" {
|
||||||
if removeErr := os.Remove(h.socketPath); removeErr != nil && !errors.Is(removeErr, os.ErrNotExist) && err == nil {
|
if removeErr := os.Remove(h.socketPath); removeErr != nil && !errors.Is(removeErr, os.ErrNotExist) && err == nil {
|
||||||
err = removeErr
|
err = removeErr
|
||||||
|
|||||||
+441
-112
@@ -19,6 +19,7 @@ import (
|
|||||||
"git.julianfamily.org/keepassgo/internal/passwords"
|
"git.julianfamily.org/keepassgo/internal/passwords"
|
||||||
"git.julianfamily.org/keepassgo/internal/session"
|
"git.julianfamily.org/keepassgo/internal/session"
|
||||||
"git.julianfamily.org/keepassgo/internal/vault"
|
"git.julianfamily.org/keepassgo/internal/vault"
|
||||||
|
"git.julianfamily.org/keepassgo/internal/vaultview"
|
||||||
"git.julianfamily.org/keepassgo/internal/webdav"
|
"git.julianfamily.org/keepassgo/internal/webdav"
|
||||||
keepassgov1 "git.julianfamily.org/keepassgo/proto/keepassgo/v1"
|
keepassgov1 "git.julianfamily.org/keepassgo/proto/keepassgo/v1"
|
||||||
"google.golang.org/grpc/codes"
|
"google.golang.org/grpc/codes"
|
||||||
@@ -38,6 +39,7 @@ type Server struct {
|
|||||||
clipboard clipboard.Writer
|
clipboard clipboard.Writer
|
||||||
approvals *apiapproval.Broker
|
approvals *apiapproval.Broker
|
||||||
audit *apiaudit.Log
|
audit *apiaudit.Log
|
||||||
|
notify func()
|
||||||
}
|
}
|
||||||
|
|
||||||
type lifecycleBackend interface {
|
type lifecycleBackend interface {
|
||||||
@@ -54,6 +56,13 @@ type modelReplaceableLifecycle interface {
|
|||||||
Replace(vault.Model)
|
Replace(vault.Model)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type rankedBrowserMatch struct {
|
||||||
|
match *keepassgov1.BrowserLoginMatch
|
||||||
|
score int
|
||||||
|
resource apitokens.Resource
|
||||||
|
decision apitokens.Decision
|
||||||
|
}
|
||||||
|
|
||||||
func NewServer(model vault.Model, profiles map[string]passwords.Profile, clipboardWriter clipboard.Writer) *Server {
|
func NewServer(model vault.Model, profiles map[string]passwords.Profile, clipboardWriter clipboard.Writer) *Server {
|
||||||
return &Server{
|
return &Server{
|
||||||
model: model,
|
model: model,
|
||||||
@@ -78,6 +87,15 @@ func (s *Server) AuditLog() *apiaudit.Log {
|
|||||||
return s.audit
|
return s.audit
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Server) SetChangeNotifier(notify func()) {
|
||||||
|
if s == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
s.notify = notify
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Server) ResolveApproval(id string, outcome apiapproval.Outcome) (apiapproval.Request, *apitokens.PolicyRule, error) {
|
func (s *Server) ResolveApproval(id string, outcome apiapproval.Outcome) (apiapproval.Request, *apitokens.PolicyRule, error) {
|
||||||
return s.approvals.Resolve(id, outcome)
|
return s.approvals.Resolve(id, outcome)
|
||||||
}
|
}
|
||||||
@@ -91,16 +109,29 @@ func (s *Server) SetSessionState(model vault.Model, locked, dirty bool) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) GetSessionStatus(ctx context.Context, _ *keepassgov1.GetSessionStatusRequest) (*keepassgov1.GetSessionStatusResponse, error) {
|
func (s *Server) GetSessionStatus(ctx context.Context, _ *keepassgov1.GetSessionStatusRequest) (*keepassgov1.GetSessionStatusResponse, error) {
|
||||||
if _, err := s.authorizeVaultRequest(ctx, apitokens.OperationManageVault); err != nil {
|
token, err := s.authenticateRequest(ctx)
|
||||||
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
pendingApprovals := s.approvals.Pending()
|
||||||
|
var tokenPending uint32
|
||||||
|
for _, pending := range pendingApprovals {
|
||||||
|
if pending.TokenID == token.ID {
|
||||||
|
tokenPending++
|
||||||
|
}
|
||||||
|
}
|
||||||
s.mu.RLock()
|
s.mu.RLock()
|
||||||
defer s.mu.RUnlock()
|
locked := s.locked
|
||||||
|
dirty := s.dirty
|
||||||
|
entryCount := uint32(len(s.model.Entries))
|
||||||
|
s.mu.RUnlock()
|
||||||
|
|
||||||
return &keepassgov1.GetSessionStatusResponse{
|
return &keepassgov1.GetSessionStatusResponse{
|
||||||
Locked: s.locked,
|
Locked: locked,
|
||||||
Dirty: s.dirty,
|
Dirty: dirty,
|
||||||
EntryCount: uint32(len(s.model.Entries)),
|
EntryCount: entryCount,
|
||||||
|
PendingApprovalCount: uint32(len(pendingApprovals)),
|
||||||
|
TokenPendingApprovalCount: tokenPending,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -232,7 +263,8 @@ func (s *Server) FindBrowserLogins(ctx context.Context, req *keepassgov1.FindBro
|
|||||||
if locked {
|
if locked {
|
||||||
return nil, status.Error(codes.FailedPrecondition, "vault is locked")
|
return nil, status.Error(codes.FailedPrecondition, "vault is locked")
|
||||||
}
|
}
|
||||||
token, err := s.authorizeVaultRequest(ctx, apitokens.OperationListEntries)
|
displayModel := visibleModel(model)
|
||||||
|
token, err := s.authenticateRequest(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -241,17 +273,14 @@ func (s *Server) FindBrowserLogins(ctx context.Context, req *keepassgov1.FindBro
|
|||||||
return nil, status.Error(codes.InvalidArgument, err.Error())
|
return nil, status.Error(codes.InvalidArgument, err.Error())
|
||||||
}
|
}
|
||||||
|
|
||||||
type rankedMatch struct {
|
var matches []rankedBrowserMatch
|
||||||
match *keepassgov1.BrowserLoginMatch
|
for _, entry := range displayModel.Entries {
|
||||||
score int
|
quality, score := classifyBrowserEntry(pageHost, entry)
|
||||||
}
|
|
||||||
var matches []rankedMatch
|
|
||||||
for _, entry := range visibleModel(model).Entries {
|
|
||||||
quality, score := classifyBrowserEntryMatch(pageHost, entry.URL)
|
|
||||||
if score == 0 {
|
if score == 0 {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
matches = append(matches, rankedMatch{
|
resource := apitokens.Resource{Kind: apitokens.ResourceGroup, Path: entry.Path}
|
||||||
|
matches = append(matches, rankedBrowserMatch{
|
||||||
match: &keepassgov1.BrowserLoginMatch{
|
match: &keepassgov1.BrowserLoginMatch{
|
||||||
Id: entry.ID,
|
Id: entry.ID,
|
||||||
Title: entry.Title,
|
Title: entry.Title,
|
||||||
@@ -261,9 +290,11 @@ func (s *Server) FindBrowserLogins(ctx context.Context, req *keepassgov1.FindBro
|
|||||||
Quality: quality,
|
Quality: quality,
|
||||||
},
|
},
|
||||||
score: score,
|
score: score,
|
||||||
|
resource: resource,
|
||||||
|
decision: evaluateAuthorization(model, token, apitokens.OperationListEntries, resource),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
slices.SortFunc(matches, func(a, b rankedMatch) int {
|
slices.SortFunc(matches, func(a, b rankedBrowserMatch) int {
|
||||||
switch {
|
switch {
|
||||||
case a.score != b.score:
|
case a.score != b.score:
|
||||||
return b.score - a.score
|
return b.score - a.score
|
||||||
@@ -275,9 +306,9 @@ func (s *Server) FindBrowserLogins(ctx context.Context, req *keepassgov1.FindBro
|
|||||||
return strings.Compare(a.match.GetId(), b.match.GetId())
|
return strings.Compare(a.match.GetId(), b.match.GetId())
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
out := make([]*keepassgov1.BrowserLoginMatch, 0, len(matches))
|
out, err := s.authorizedBrowserMatches(ctx, token, matches)
|
||||||
for _, match := range matches {
|
if err != nil {
|
||||||
out = append(out, match.match)
|
return nil, err
|
||||||
}
|
}
|
||||||
switch len(out) {
|
switch len(out) {
|
||||||
case 1:
|
case 1:
|
||||||
@@ -302,6 +333,45 @@ func (s *Server) FindBrowserLogins(ctx context.Context, req *keepassgov1.FindBro
|
|||||||
return &keepassgov1.FindBrowserLoginsResponse{Matches: out}, nil
|
return &keepassgov1.FindBrowserLoginsResponse{Matches: out}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Server) authorizedBrowserMatches(ctx context.Context, token apitokens.Token, matches []rankedBrowserMatch) ([]*keepassgov1.BrowserLoginMatch, error) {
|
||||||
|
out := make([]*keepassgov1.BrowserLoginMatch, 0, len(matches))
|
||||||
|
for _, match := range matches {
|
||||||
|
if match.decision == apitokens.DecisionAllow {
|
||||||
|
out = append(out, match.match)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(out) != 0 {
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
for _, match := range matches {
|
||||||
|
if match.decision != apitokens.DecisionPrompt {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, err := s.authorizeResourceRequest(ctx, token, apitokens.OperationListEntries, match.resource); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return s.authorizedBrowserMatchesWithinPath(ctx, token, matches, match.resource.Path)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) authorizedBrowserMatchesWithinPath(_ context.Context, _ apitokens.Token, matches []rankedBrowserMatch, path []string) ([]*keepassgov1.BrowserLoginMatch, error) {
|
||||||
|
out := make([]*keepassgov1.BrowserLoginMatch, 0, len(matches))
|
||||||
|
for _, match := range matches {
|
||||||
|
if len(path) > len(match.resource.Path) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !slices.Equal(path, match.resource.Path[:len(path)]) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if match.decision == apitokens.DecisionDeny {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out = append(out, match.match)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Server) GetBrowserCredential(ctx context.Context, req *keepassgov1.GetBrowserCredentialRequest) (*keepassgov1.GetBrowserCredentialResponse, error) {
|
func (s *Server) GetBrowserCredential(ctx context.Context, req *keepassgov1.GetBrowserCredentialRequest) (*keepassgov1.GetBrowserCredentialResponse, error) {
|
||||||
model, locked := s.snapshotModel()
|
model, locked := s.snapshotModel()
|
||||||
if locked {
|
if locked {
|
||||||
@@ -320,7 +390,7 @@ func (s *Server) GetBrowserCredential(ctx context.Context, req *keepassgov1.GetB
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, status.Error(codes.InvalidArgument, err.Error())
|
return nil, status.Error(codes.InvalidArgument, err.Error())
|
||||||
}
|
}
|
||||||
if _, score := classifyBrowserEntryMatch(pageHost, entry.URL); score == 0 {
|
if _, score := classifyBrowserEntry(pageHost, entry); score == 0 {
|
||||||
return nil, status.Error(codes.InvalidArgument, "entry url does not match requested page")
|
return nil, status.Error(codes.InvalidArgument, "entry url does not match requested page")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -374,107 +444,165 @@ func (s *Server) ListEntries(ctx context.Context, req *keepassgov1.ListEntriesRe
|
|||||||
if locked {
|
if locked {
|
||||||
return nil, status.Error(codes.FailedPrecondition, "vault is locked")
|
return nil, status.Error(codes.FailedPrecondition, "vault is locked")
|
||||||
}
|
}
|
||||||
if _, err := s.authorizePathRequest(ctx, apitokens.OperationListEntries, req.GetPath()); err != nil {
|
displayModel := visibleModel(model)
|
||||||
return nil, err
|
internalPath := expandClientPath(displayModel, req.GetPath())
|
||||||
}
|
model = displayModel
|
||||||
|
|
||||||
model = visibleModel(model)
|
|
||||||
var entries []vault.Entry
|
var entries []vault.Entry
|
||||||
if strings.TrimSpace(req.GetQuery()) != "" {
|
if strings.TrimSpace(req.GetQuery()) != "" {
|
||||||
|
token, err := s.authenticateRequest(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
results := model.Search(req.GetQuery())
|
results := model.Search(req.GetQuery())
|
||||||
entries = make([]vault.Entry, 0, len(results))
|
entries, err = s.authorizedSearchEntries(ctx, model, token, internalPath, results)
|
||||||
for _, result := range results {
|
if err != nil {
|
||||||
entries = append(entries, result.Entry)
|
return nil, err
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
entries = model.EntriesInPath(req.GetPath())
|
if _, err := s.authorizePathRequest(ctx, apitokens.OperationListEntries, internalPath); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
entries = model.EntriesInPath(internalPath)
|
||||||
}
|
}
|
||||||
|
|
||||||
resp := &keepassgov1.ListEntriesResponse{
|
resp := &keepassgov1.ListEntriesResponse{
|
||||||
Entries: make([]*keepassgov1.Entry, 0, len(entries)),
|
Entries: make([]*keepassgov1.Entry, 0, len(entries)),
|
||||||
}
|
}
|
||||||
for _, entry := range entries {
|
for _, entry := range entries {
|
||||||
resp.Entries = append(resp.Entries, entryToProto(entry))
|
resp.Entries = append(resp.Entries, entryToProtoWithModel(model, entry))
|
||||||
}
|
}
|
||||||
|
|
||||||
return resp, nil
|
return resp, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Server) authorizedSearchEntries(ctx context.Context, model vault.Model, token apitokens.Token, path []string, results []vault.SearchResult) ([]vault.Entry, error) {
|
||||||
|
entries := make([]vault.Entry, 0, len(results))
|
||||||
|
var promptResource *apitokens.Resource
|
||||||
|
for _, result := range results {
|
||||||
|
entry := result.Entry
|
||||||
|
if !hasPathPrefix(path, entry.Path) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
resource := apitokens.Resource{Kind: apitokens.ResourceGroup, Path: entry.Path}
|
||||||
|
switch evaluateAuthorization(model, token, apitokens.OperationListEntries, resource) {
|
||||||
|
case apitokens.DecisionAllow:
|
||||||
|
entries = append(entries, entry)
|
||||||
|
case apitokens.DecisionPrompt:
|
||||||
|
if promptResource == nil {
|
||||||
|
candidate := resource
|
||||||
|
promptResource = &candidate
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(entries) != 0 || promptResource == nil {
|
||||||
|
return entries, nil
|
||||||
|
}
|
||||||
|
if _, err := s.authorizeResourceRequest(ctx, token, apitokens.OperationListEntries, *promptResource); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return authorizedSearchEntriesWithinPath(path, promptResource.Path, results), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func authorizedSearchEntriesWithinPath(requestPath, approvedPath []string, results []vault.SearchResult) []vault.Entry {
|
||||||
|
entries := make([]vault.Entry, 0, len(results))
|
||||||
|
for _, result := range results {
|
||||||
|
entry := result.Entry
|
||||||
|
if !hasPathPrefix(requestPath, entry.Path) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !hasPathPrefix(approvedPath, entry.Path) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
entries = append(entries, entry)
|
||||||
|
}
|
||||||
|
return entries
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Server) ListGroups(ctx context.Context, req *keepassgov1.ListGroupsRequest) (*keepassgov1.ListGroupsResponse, error) {
|
func (s *Server) ListGroups(ctx context.Context, req *keepassgov1.ListGroupsRequest) (*keepassgov1.ListGroupsResponse, error) {
|
||||||
model, locked := s.snapshotModel()
|
model, locked := s.snapshotModel()
|
||||||
if locked {
|
if locked {
|
||||||
return nil, status.Error(codes.FailedPrecondition, "vault is locked")
|
return nil, status.Error(codes.FailedPrecondition, "vault is locked")
|
||||||
}
|
}
|
||||||
if _, err := s.authorizePathRequest(ctx, apitokens.OperationListGroups, req.GetPath()); err != nil {
|
displayModel := visibleModel(model)
|
||||||
|
internalPath := expandClientPath(displayModel, req.GetPath())
|
||||||
|
if _, err := s.authorizePathRequest(ctx, apitokens.OperationListGroups, internalPath); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
return &keepassgov1.ListGroupsResponse{
|
return &keepassgov1.ListGroupsResponse{
|
||||||
Names: visibleModel(model).ChildGroups(req.GetPath()),
|
Names: displayModel.ChildGroups(internalPath),
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) CreateGroup(ctx context.Context, req *keepassgov1.CreateGroupRequest) (*keepassgov1.CreateGroupResponse, error) {
|
func (s *Server) mutateAuthorizedVisiblePath(ctx context.Context, clientPath []string, op apitokens.Operation, mutate func(*vault.Model, []string) error) error {
|
||||||
if _, err := s.authorizePathRequest(ctx, apitokens.OperationMutateGroup, req.GetParentPath()); err != nil {
|
model, locked := s.snapshotModel()
|
||||||
return nil, err
|
if locked {
|
||||||
|
return status.Error(codes.FailedPrecondition, "vault is locked")
|
||||||
}
|
}
|
||||||
|
internalPath := expandClientPath(visibleModel(model), clientPath)
|
||||||
|
if _, err := s.authorizePathRequest(ctx, op, internalPath); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return s.mutateAuthorizedModel(func() error { return nil }, func(model *vault.Model) error {
|
||||||
|
return mutate(model, internalPath)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) mutateAuthorizedModel(authorize func() error, mutate func(*vault.Model) error) error {
|
||||||
|
if err := authorize(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
defer s.mu.Unlock()
|
defer s.mu.Unlock()
|
||||||
if s.locked {
|
if err := mutate(&s.model); err != nil {
|
||||||
return nil, status.Error(codes.FailedPrecondition, "vault is locked")
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
s.model.CreateGroup(req.GetParentPath(), req.GetName())
|
|
||||||
s.dirty = true
|
s.dirty = true
|
||||||
|
s.syncMutationLocked()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) CreateGroup(ctx context.Context, req *keepassgov1.CreateGroupRequest) (*keepassgov1.CreateGroupResponse, error) {
|
||||||
|
if err := s.mutateAuthorizedVisiblePath(ctx, req.GetParentPath(), apitokens.OperationMutateGroup, func(model *vault.Model, parentPath []string) error {
|
||||||
|
model.CreateGroup(parentPath, req.GetName())
|
||||||
|
return nil
|
||||||
|
}); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
return &keepassgov1.CreateGroupResponse{}, nil
|
return &keepassgov1.CreateGroupResponse{}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) RenameGroup(ctx context.Context, req *keepassgov1.RenameGroupRequest) (*keepassgov1.RenameGroupResponse, error) {
|
func (s *Server) RenameGroup(ctx context.Context, req *keepassgov1.RenameGroupRequest) (*keepassgov1.RenameGroupResponse, error) {
|
||||||
if _, err := s.authorizePathRequest(ctx, apitokens.OperationMutateGroup, req.GetPath()); err != nil {
|
if err := s.mutateAuthorizedVisiblePath(ctx, req.GetPath(), apitokens.OperationMutateGroup, func(model *vault.Model, groupPath []string) error {
|
||||||
|
if err := model.RenameGroup(groupPath, req.GetNewName()); err != nil {
|
||||||
|
if errors.Is(err, vault.ErrEntryNotFound) {
|
||||||
|
return status.Error(codes.NotFound, err.Error())
|
||||||
|
}
|
||||||
|
return status.Errorf(codes.Internal, "rename group: %v", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
s.mu.Lock()
|
|
||||||
defer s.mu.Unlock()
|
|
||||||
if s.locked {
|
|
||||||
return nil, status.Error(codes.FailedPrecondition, "vault is locked")
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := s.model.RenameGroup(req.GetPath(), req.GetNewName()); err != nil {
|
|
||||||
if errors.Is(err, vault.ErrEntryNotFound) {
|
|
||||||
return nil, status.Error(codes.NotFound, err.Error())
|
|
||||||
}
|
|
||||||
return nil, status.Errorf(codes.Internal, "rename group: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
s.dirty = true
|
|
||||||
return &keepassgov1.RenameGroupResponse{}, nil
|
return &keepassgov1.RenameGroupResponse{}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) DeleteGroup(ctx context.Context, req *keepassgov1.DeleteGroupRequest) (*keepassgov1.DeleteGroupResponse, error) {
|
func (s *Server) DeleteGroup(ctx context.Context, req *keepassgov1.DeleteGroupRequest) (*keepassgov1.DeleteGroupResponse, error) {
|
||||||
if _, err := s.authorizePathRequest(ctx, apitokens.OperationMutateGroup, req.GetPath()); err != nil {
|
if err := s.mutateAuthorizedVisiblePath(ctx, req.GetPath(), apitokens.OperationMutateGroup, func(model *vault.Model, groupPath []string) error {
|
||||||
return nil, err
|
if err := model.DeleteGroup(groupPath); err != nil {
|
||||||
}
|
|
||||||
|
|
||||||
s.mu.Lock()
|
|
||||||
defer s.mu.Unlock()
|
|
||||||
if s.locked {
|
|
||||||
return nil, status.Error(codes.FailedPrecondition, "vault is locked")
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := s.model.DeleteGroup(req.GetPath()); err != nil {
|
|
||||||
switch {
|
switch {
|
||||||
case errors.Is(err, vault.ErrEntryNotFound):
|
case errors.Is(err, vault.ErrEntryNotFound):
|
||||||
return nil, status.Error(codes.NotFound, err.Error())
|
return status.Error(codes.NotFound, err.Error())
|
||||||
case errors.Is(err, vault.ErrGroupNotEmpty):
|
case errors.Is(err, vault.ErrGroupNotEmpty):
|
||||||
return nil, status.Error(codes.FailedPrecondition, err.Error())
|
return status.Error(codes.FailedPrecondition, err.Error())
|
||||||
default:
|
default:
|
||||||
return nil, status.Errorf(codes.Internal, "delete group: %v", err)
|
return status.Errorf(codes.Internal, "delete group: %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
return nil
|
||||||
s.dirty = true
|
}); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
return &keepassgov1.DeleteGroupResponse{}, nil
|
return &keepassgov1.DeleteGroupResponse{}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -483,21 +611,22 @@ func (s *Server) UpsertEntry(ctx context.Context, req *keepassgov1.UpsertEntryRe
|
|||||||
return nil, status.Error(codes.InvalidArgument, "missing entry")
|
return nil, status.Error(codes.InvalidArgument, "missing entry")
|
||||||
}
|
}
|
||||||
|
|
||||||
entry := entryFromProto(req.GetEntry())
|
model, locked := s.snapshotModel()
|
||||||
if _, err := s.authorizeEntryRequest(ctx, apitokens.OperationMutateEntry, entry); err != nil {
|
if locked {
|
||||||
|
return nil, status.Error(codes.FailedPrecondition, "vault is locked")
|
||||||
|
}
|
||||||
|
entry := entryFromProtoWithModel(visibleModel(model), req.GetEntry())
|
||||||
|
if _, err := s.authorizeUpsertEntryRequest(ctx, entry); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := s.mutateAuthorizedModel(func() error { return nil }, func(model *vault.Model) error {
|
||||||
|
model.UpsertEntry(entry)
|
||||||
|
return nil
|
||||||
|
}); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
s.mu.Lock()
|
return &keepassgov1.UpsertEntryResponse{Entry: entryToProtoWithModel(visibleModel(model), entry)}, nil
|
||||||
if s.locked {
|
|
||||||
s.mu.Unlock()
|
|
||||||
return nil, status.Error(codes.FailedPrecondition, "vault is locked")
|
|
||||||
}
|
|
||||||
s.model.UpsertEntry(entry)
|
|
||||||
s.dirty = true
|
|
||||||
s.mu.Unlock()
|
|
||||||
|
|
||||||
return &keepassgov1.UpsertEntryResponse{Entry: entryToProto(entry)}, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) DeleteEntry(ctx context.Context, req *keepassgov1.DeleteEntryRequest) (*keepassgov1.DeleteEntryResponse, error) {
|
func (s *Server) DeleteEntry(ctx context.Context, req *keepassgov1.DeleteEntryRequest) (*keepassgov1.DeleteEntryResponse, error) {
|
||||||
@@ -522,6 +651,7 @@ func (s *Server) DeleteEntry(ctx context.Context, req *keepassgov1.DeleteEntryRe
|
|||||||
}
|
}
|
||||||
|
|
||||||
s.dirty = true
|
s.dirty = true
|
||||||
|
s.syncMutationLocked()
|
||||||
return &keepassgov1.DeleteEntryResponse{}, nil
|
return &keepassgov1.DeleteEntryResponse{}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -555,7 +685,8 @@ func (s *Server) RestoreEntry(ctx context.Context, req *keepassgov1.RestoreEntry
|
|||||||
}
|
}
|
||||||
|
|
||||||
s.dirty = true
|
s.dirty = true
|
||||||
return &keepassgov1.RestoreEntryResponse{Entry: entryToProto(restored)}, nil
|
s.syncMutationLocked()
|
||||||
|
return &keepassgov1.RestoreEntryResponse{Entry: entryToProtoWithModel(visibleModel(model), restored)}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) ListEntryHistory(ctx context.Context, req *keepassgov1.ListEntryHistoryRequest) (*keepassgov1.ListEntryHistoryResponse, error) {
|
func (s *Server) ListEntryHistory(ctx context.Context, req *keepassgov1.ListEntryHistoryRequest) (*keepassgov1.ListEntryHistoryResponse, error) {
|
||||||
@@ -576,7 +707,7 @@ func (s *Server) ListEntryHistory(ctx context.Context, req *keepassgov1.ListEntr
|
|||||||
Entries: make([]*keepassgov1.Entry, 0, len(entry.History)),
|
Entries: make([]*keepassgov1.Entry, 0, len(entry.History)),
|
||||||
}
|
}
|
||||||
for _, historical := range entry.History {
|
for _, historical := range entry.History {
|
||||||
resp.Entries = append(resp.Entries, entryToProto(historical))
|
resp.Entries = append(resp.Entries, entryToProtoWithModel(visibleModel(model), historical))
|
||||||
}
|
}
|
||||||
return resp, nil
|
return resp, nil
|
||||||
}
|
}
|
||||||
@@ -608,7 +739,8 @@ func (s *Server) RestoreEntryHistory(ctx context.Context, req *keepassgov1.Resto
|
|||||||
return nil, status.Error(codes.NotFound, err.Error())
|
return nil, status.Error(codes.NotFound, err.Error())
|
||||||
}
|
}
|
||||||
s.dirty = true
|
s.dirty = true
|
||||||
return &keepassgov1.RestoreEntryHistoryResponse{Entry: entryToProto(entry)}, nil
|
s.syncMutationLocked()
|
||||||
|
return &keepassgov1.RestoreEntryHistoryResponse{Entry: entryToProtoWithModel(visibleModel(s.model), entry)}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) ListTemplates(ctx context.Context, _ *keepassgov1.ListTemplatesRequest) (*keepassgov1.ListTemplatesResponse, error) {
|
func (s *Server) ListTemplates(ctx context.Context, _ *keepassgov1.ListTemplatesRequest) (*keepassgov1.ListTemplatesResponse, error) {
|
||||||
@@ -626,7 +758,7 @@ func (s *Server) ListTemplates(ctx context.Context, _ *keepassgov1.ListTemplates
|
|||||||
Templates: make([]*keepassgov1.Entry, 0, len(s.model.Templates)),
|
Templates: make([]*keepassgov1.Entry, 0, len(s.model.Templates)),
|
||||||
}
|
}
|
||||||
for _, template := range s.model.Templates {
|
for _, template := range s.model.Templates {
|
||||||
resp.Templates = append(resp.Templates, entryToProto(template))
|
resp.Templates = append(resp.Templates, entryToProtoWithModel(visibleModel(s.model), template))
|
||||||
}
|
}
|
||||||
|
|
||||||
return resp, nil
|
return resp, nil
|
||||||
@@ -647,11 +779,12 @@ func (s *Server) UpsertTemplate(ctx context.Context, req *keepassgov1.UpsertTemp
|
|||||||
return nil, status.Error(codes.FailedPrecondition, "vault is locked")
|
return nil, status.Error(codes.FailedPrecondition, "vault is locked")
|
||||||
}
|
}
|
||||||
|
|
||||||
entry := entryFromProto(req.GetTemplate())
|
entry := entryFromProtoWithModel(visibleModel(s.model), req.GetTemplate())
|
||||||
s.model.UpsertTemplate(entry)
|
s.model.UpsertTemplate(entry)
|
||||||
s.dirty = true
|
s.dirty = true
|
||||||
|
s.syncMutationLocked()
|
||||||
|
|
||||||
return &keepassgov1.UpsertTemplateResponse{Template: entryToProto(entry)}, nil
|
return &keepassgov1.UpsertTemplateResponse{Template: entryToProtoWithModel(visibleModel(s.model), entry)}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) DeleteTemplate(ctx context.Context, req *keepassgov1.DeleteTemplateRequest) (*keepassgov1.DeleteTemplateResponse, error) {
|
func (s *Server) DeleteTemplate(ctx context.Context, req *keepassgov1.DeleteTemplateRequest) (*keepassgov1.DeleteTemplateResponse, error) {
|
||||||
@@ -672,6 +805,7 @@ func (s *Server) DeleteTemplate(ctx context.Context, req *keepassgov1.DeleteTemp
|
|||||||
return nil, status.Errorf(codes.Internal, "delete template: %v", err)
|
return nil, status.Errorf(codes.Internal, "delete template: %v", err)
|
||||||
}
|
}
|
||||||
s.dirty = true
|
s.dirty = true
|
||||||
|
s.syncMutationLocked()
|
||||||
|
|
||||||
return &keepassgov1.DeleteTemplateResponse{}, nil
|
return &keepassgov1.DeleteTemplateResponse{}, nil
|
||||||
}
|
}
|
||||||
@@ -683,7 +817,8 @@ func (s *Server) InstantiateTemplate(ctx context.Context, req *keepassgov1.Insta
|
|||||||
if _, err := s.authorizeTemplateRequest(ctx, apitokens.OperationListTemplates, req.GetTemplateId()); err != nil {
|
if _, err := s.authorizeTemplateRequest(ctx, apitokens.OperationListTemplates, req.GetTemplateId()); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if _, err := s.authorizePathRequest(ctx, apitokens.OperationMutateEntry, req.GetOverrides().GetPath()); err != nil {
|
overridePath := expandClientPath(visibleModel(s.model), req.GetOverrides().GetPath())
|
||||||
|
if _, err := s.authorizePathRequest(ctx, apitokens.OperationMutateEntry, overridePath); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -694,7 +829,8 @@ func (s *Server) InstantiateTemplate(ctx context.Context, req *keepassgov1.Insta
|
|||||||
return nil, status.Error(codes.FailedPrecondition, "vault is locked")
|
return nil, status.Error(codes.FailedPrecondition, "vault is locked")
|
||||||
}
|
}
|
||||||
|
|
||||||
entry, err := s.model.InstantiateTemplate(req.GetTemplateId(), entryFromProto(req.GetOverrides()))
|
overrides := entryFromProtoWithModel(visibleModel(s.model), req.GetOverrides())
|
||||||
|
entry, err := s.model.InstantiateTemplate(req.GetTemplateId(), overrides)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, vault.ErrEntryNotFound) {
|
if errors.Is(err, vault.ErrEntryNotFound) {
|
||||||
return nil, status.Error(codes.NotFound, err.Error())
|
return nil, status.Error(codes.NotFound, err.Error())
|
||||||
@@ -703,7 +839,8 @@ func (s *Server) InstantiateTemplate(ctx context.Context, req *keepassgov1.Insta
|
|||||||
}
|
}
|
||||||
|
|
||||||
s.dirty = true
|
s.dirty = true
|
||||||
return &keepassgov1.InstantiateTemplateResponse{Entry: entryToProto(entry)}, nil
|
s.syncMutationLocked()
|
||||||
|
return &keepassgov1.InstantiateTemplateResponse{Entry: entryToProtoWithModel(visibleModel(s.model), entry)}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) ListAttachments(ctx context.Context, req *keepassgov1.ListAttachmentsRequest) (*keepassgov1.ListAttachmentsResponse, error) {
|
func (s *Server) ListAttachments(ctx context.Context, req *keepassgov1.ListAttachmentsRequest) (*keepassgov1.ListAttachmentsResponse, error) {
|
||||||
@@ -755,6 +892,7 @@ func (s *Server) UploadAttachment(ctx context.Context, req *keepassgov1.UploadAt
|
|||||||
entry.Attachments[req.GetName()] = append([]byte(nil), req.GetContent()...)
|
entry.Attachments[req.GetName()] = append([]byte(nil), req.GetContent()...)
|
||||||
s.model.Entries[index] = entry
|
s.model.Entries[index] = entry
|
||||||
s.dirty = true
|
s.dirty = true
|
||||||
|
s.syncMutationLocked()
|
||||||
|
|
||||||
return &keepassgov1.UploadAttachmentResponse{}, nil
|
return &keepassgov1.UploadAttachmentResponse{}, nil
|
||||||
}
|
}
|
||||||
@@ -813,6 +951,7 @@ func (s *Server) DeleteAttachment(ctx context.Context, req *keepassgov1.DeleteAt
|
|||||||
}
|
}
|
||||||
s.model.Entries[index] = entry
|
s.model.Entries[index] = entry
|
||||||
s.dirty = true
|
s.dirty = true
|
||||||
|
s.syncMutationLocked()
|
||||||
|
|
||||||
return &keepassgov1.DeleteAttachmentResponse{}, nil
|
return &keepassgov1.DeleteAttachmentResponse{}, nil
|
||||||
}
|
}
|
||||||
@@ -869,7 +1008,7 @@ func (s *Server) GeneratePassword(ctx context.Context, req *keepassgov1.Generate
|
|||||||
return &keepassgov1.GeneratePasswordResponse{Password: password}, nil
|
return &keepassgov1.GeneratePasswordResponse{Password: password}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func entryToProto(entry vault.Entry) *keepassgov1.Entry {
|
func entryToProtoWithModel(model vault.Model, entry vault.Entry) *keepassgov1.Entry {
|
||||||
return &keepassgov1.Entry{
|
return &keepassgov1.Entry{
|
||||||
Id: entry.ID,
|
Id: entry.ID,
|
||||||
Title: entry.Title,
|
Title: entry.Title,
|
||||||
@@ -878,12 +1017,12 @@ func entryToProto(entry vault.Entry) *keepassgov1.Entry {
|
|||||||
Url: entry.URL,
|
Url: entry.URL,
|
||||||
Notes: entry.Notes,
|
Notes: entry.Notes,
|
||||||
Tags: append([]string(nil), entry.Tags...),
|
Tags: append([]string(nil), entry.Tags...),
|
||||||
Path: append([]string(nil), entry.Path...),
|
Path: collapseInternalPath(model, entry.Path),
|
||||||
Fields: maps.Clone(entry.Fields),
|
Fields: maps.Clone(entry.Fields),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func entryFromProto(entry *keepassgov1.Entry) vault.Entry {
|
func entryFromProtoWithModel(model vault.Model, entry *keepassgov1.Entry) vault.Entry {
|
||||||
return vault.Entry{
|
return vault.Entry{
|
||||||
ID: entry.GetId(),
|
ID: entry.GetId(),
|
||||||
Title: entry.GetTitle(),
|
Title: entry.GetTitle(),
|
||||||
@@ -892,11 +1031,33 @@ func entryFromProto(entry *keepassgov1.Entry) vault.Entry {
|
|||||||
URL: entry.GetUrl(),
|
URL: entry.GetUrl(),
|
||||||
Notes: entry.GetNotes(),
|
Notes: entry.GetNotes(),
|
||||||
Tags: append([]string(nil), entry.GetTags()...),
|
Tags: append([]string(nil), entry.GetTags()...),
|
||||||
Path: append([]string(nil), entry.GetPath()...),
|
Path: expandClientPath(model, entry.GetPath()),
|
||||||
Fields: maps.Clone(entry.GetFields()),
|
Fields: maps.Clone(entry.GetFields()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func expandClientPath(model vault.Model, path []string) []string {
|
||||||
|
root := vaultview.HiddenRoot(model)
|
||||||
|
if root == "" {
|
||||||
|
return append([]string(nil), path...)
|
||||||
|
}
|
||||||
|
if len(path) == 0 {
|
||||||
|
return []string{root}
|
||||||
|
}
|
||||||
|
if path[0] == root {
|
||||||
|
return append([]string(nil), path...)
|
||||||
|
}
|
||||||
|
return append([]string{root}, path...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func collapseInternalPath(model vault.Model, path []string) []string {
|
||||||
|
root := vaultview.HiddenRoot(model)
|
||||||
|
if root == "" || len(path) == 0 || path[0] != root {
|
||||||
|
return append([]string(nil), path...)
|
||||||
|
}
|
||||||
|
return append([]string(nil), path[1:]...)
|
||||||
|
}
|
||||||
|
|
||||||
func findEntryByID(model vault.Model, id string) (vault.Entry, error) {
|
func findEntryByID(model vault.Model, id string) (vault.Entry, error) {
|
||||||
for _, entry := range model.Entries {
|
for _, entry := range model.Entries {
|
||||||
if entry.ID == id {
|
if entry.ID == id {
|
||||||
@@ -928,12 +1089,74 @@ func normalizedBrowserHost(raw string) (string, error) {
|
|||||||
return host, nil
|
return host, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func classifyBrowserEntryMatch(pageHost, rawEntryURL string) (string, int) {
|
func normalizedBrowserEntryHost(raw string) string {
|
||||||
parsed, err := url.Parse(strings.TrimSpace(rawEntryURL))
|
raw = strings.TrimSpace(raw)
|
||||||
if err != nil {
|
if raw == "" {
|
||||||
return "", 0
|
return ""
|
||||||
}
|
}
|
||||||
entryHost := strings.ToLower(parsed.Hostname())
|
parsed, err := url.Parse(raw)
|
||||||
|
if err == nil {
|
||||||
|
if host := strings.ToLower(parsed.Hostname()); host != "" {
|
||||||
|
return host
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !strings.Contains(raw, "://") {
|
||||||
|
parsed, err = url.Parse("https://" + raw)
|
||||||
|
if err == nil {
|
||||||
|
return strings.ToLower(parsed.Hostname())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func browserURLFieldKey(key string) bool {
|
||||||
|
if len(key) <= len("URL") || !strings.EqualFold(key[:len("URL")], "URL") {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, r := range key[len("URL"):] {
|
||||||
|
if r < '0' || r > '9' {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func browserEntryURLs(entry vault.Entry) []string {
|
||||||
|
urls := make([]string, 0, 1+len(entry.Fields))
|
||||||
|
if raw := strings.TrimSpace(entry.URL); raw != "" {
|
||||||
|
urls = append(urls, raw)
|
||||||
|
}
|
||||||
|
if len(entry.Fields) == 0 {
|
||||||
|
return urls
|
||||||
|
}
|
||||||
|
keys := slices.Collect(maps.Keys(entry.Fields))
|
||||||
|
slices.Sort(keys)
|
||||||
|
for _, key := range keys {
|
||||||
|
if !browserURLFieldKey(key) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if raw := strings.TrimSpace(entry.Fields[key]); raw != "" {
|
||||||
|
urls = append(urls, raw)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return urls
|
||||||
|
}
|
||||||
|
|
||||||
|
func classifyBrowserEntry(pageHost string, entry vault.Entry) (string, int) {
|
||||||
|
bestQuality := ""
|
||||||
|
bestScore := 0
|
||||||
|
for _, rawURL := range browserEntryURLs(entry) {
|
||||||
|
quality, score := classifyBrowserEntryMatch(pageHost, rawURL)
|
||||||
|
if score > bestScore {
|
||||||
|
bestQuality = quality
|
||||||
|
bestScore = score
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return bestQuality, bestScore
|
||||||
|
}
|
||||||
|
|
||||||
|
func classifyBrowserEntryMatch(pageHost, rawEntryURL string) (string, int) {
|
||||||
|
entryHost := normalizedBrowserEntryHost(rawEntryURL)
|
||||||
if entryHost == "" {
|
if entryHost == "" {
|
||||||
return "", 0
|
return "", 0
|
||||||
}
|
}
|
||||||
@@ -942,13 +1165,18 @@ func classifyBrowserEntryMatch(pageHost, rawEntryURL string) (string, int) {
|
|||||||
return "exact-host", 3
|
return "exact-host", 3
|
||||||
case strings.HasSuffix(pageHost, "."+entryHost):
|
case strings.HasSuffix(pageHost, "."+entryHost):
|
||||||
return "subdomain", 2
|
return "subdomain", 2
|
||||||
case strings.HasSuffix(entryHost, "."+pageHost):
|
|
||||||
return "parent-domain", 1
|
|
||||||
default:
|
default:
|
||||||
return "", 0
|
return "", 0
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func hasPathPrefix(prefix, path []string) bool {
|
||||||
|
if len(prefix) > len(path) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return slices.Equal(prefix, path[:len(prefix)])
|
||||||
|
}
|
||||||
|
|
||||||
func visibleModel(model vault.Model) vault.Model {
|
func visibleModel(model vault.Model) vault.Model {
|
||||||
out := model
|
out := model
|
||||||
out.Entries = nil
|
out.Entries = nil
|
||||||
@@ -1040,6 +1268,44 @@ func (s *Server) authorizeEntryRequest(ctx context.Context, op apitokens.Operati
|
|||||||
return s.authorizeResourceRequest(ctx, token, op, apitokens.Resource{Kind: apitokens.ResourceEntry, EntryID: entry.ID, Path: entry.Path})
|
return s.authorizeResourceRequest(ctx, token, op, apitokens.Resource{Kind: apitokens.ResourceEntry, EntryID: entry.ID, Path: entry.Path})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Server) authorizeUpsertEntryRequest(ctx context.Context, entry vault.Entry) (apitokens.Token, error) {
|
||||||
|
token, err := s.authenticateRequest(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return apitokens.Token{}, err
|
||||||
|
}
|
||||||
|
model, locked := s.snapshotModel()
|
||||||
|
if locked {
|
||||||
|
return apitokens.Token{}, status.Error(codes.FailedPrecondition, "vault is locked")
|
||||||
|
}
|
||||||
|
existing, err := findEntryByID(model, entry.ID)
|
||||||
|
switch {
|
||||||
|
case err == nil:
|
||||||
|
if _, err := s.authorizeResourceRequest(ctx, token, apitokens.OperationMutateEntry, apitokens.Resource{
|
||||||
|
Kind: apitokens.ResourceEntry,
|
||||||
|
EntryID: existing.ID,
|
||||||
|
Path: existing.Path,
|
||||||
|
}); err != nil {
|
||||||
|
return apitokens.Token{}, err
|
||||||
|
}
|
||||||
|
if !slices.Equal(existing.Path, entry.Path) {
|
||||||
|
if _, err := s.authorizeResourceRequest(ctx, token, apitokens.OperationMutateEntry, apitokens.Resource{
|
||||||
|
Kind: apitokens.ResourceGroup,
|
||||||
|
Path: entry.Path,
|
||||||
|
}); err != nil {
|
||||||
|
return apitokens.Token{}, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return token, nil
|
||||||
|
case errors.Is(err, vault.ErrEntryNotFound):
|
||||||
|
return s.authorizeResourceRequest(ctx, token, apitokens.OperationMutateEntry, apitokens.Resource{
|
||||||
|
Kind: apitokens.ResourceGroup,
|
||||||
|
Path: entry.Path,
|
||||||
|
})
|
||||||
|
default:
|
||||||
|
return apitokens.Token{}, status.Errorf(codes.Internal, "lookup existing entry: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Server) authorizeTemplateRequest(ctx context.Context, op apitokens.Operation, templateID string) (apitokens.Token, error) {
|
func (s *Server) authorizeTemplateRequest(ctx context.Context, op apitokens.Operation, templateID string) (apitokens.Token, error) {
|
||||||
token, err := s.authenticateRequest(ctx)
|
token, err := s.authenticateRequest(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -1053,7 +1319,9 @@ func (s *Server) authorizeTemplateRequest(ctx context.Context, op apitokens.Oper
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) authorizeResourceRequest(ctx context.Context, token apitokens.Token, op apitokens.Operation, resource apitokens.Resource) (apitokens.Token, error) {
|
func (s *Server) authorizeResourceRequest(ctx context.Context, token apitokens.Token, op apitokens.Operation, resource apitokens.Resource) (apitokens.Token, error) {
|
||||||
switch apitokens.Evaluate(token, op, resource) {
|
model, _ := s.snapshotModel()
|
||||||
|
displayResource := displayAuthorizationResource(resource)
|
||||||
|
switch evaluateAuthorization(model, token, op, resource) {
|
||||||
case apitokens.DecisionAllow:
|
case apitokens.DecisionAllow:
|
||||||
return token, nil
|
return token, nil
|
||||||
case apitokens.DecisionDeny:
|
case apitokens.DecisionDeny:
|
||||||
@@ -1065,9 +1333,9 @@ func (s *Server) authorizeResourceRequest(ctx context.Context, token apitokens.T
|
|||||||
TokenName: token.Name,
|
TokenName: token.Name,
|
||||||
ClientName: token.ClientName,
|
ClientName: token.ClientName,
|
||||||
Operation: op,
|
Operation: op,
|
||||||
Resource: resource,
|
Resource: displayResource,
|
||||||
})
|
})
|
||||||
result, err := s.approvals.Request(ctx, token, op, resource)
|
result, err := s.approvals.Request(ctx, token, op, displayResource)
|
||||||
if result.Rule != nil {
|
if result.Rule != nil {
|
||||||
if persistErr := s.persistApprovalRule(token.ID, *result.Rule); persistErr != nil {
|
if persistErr := s.persistApprovalRule(token.ID, *result.Rule); persistErr != nil {
|
||||||
return apitokens.Token{}, status.Errorf(codes.Internal, "persist approval decision: %v", persistErr)
|
return apitokens.Token{}, status.Errorf(codes.Internal, "persist approval decision: %v", persistErr)
|
||||||
@@ -1081,7 +1349,7 @@ func (s *Server) authorizeResourceRequest(ctx context.Context, token apitokens.T
|
|||||||
TokenName: token.Name,
|
TokenName: token.Name,
|
||||||
ClientName: token.ClientName,
|
ClientName: token.ClientName,
|
||||||
Operation: op,
|
Operation: op,
|
||||||
Resource: resource,
|
Resource: displayResource,
|
||||||
})
|
})
|
||||||
return token, nil
|
return token, nil
|
||||||
case errors.Is(err, apiapproval.ErrRequestDenied):
|
case errors.Is(err, apiapproval.ErrRequestDenied):
|
||||||
@@ -1091,7 +1359,7 @@ func (s *Server) authorizeResourceRequest(ctx context.Context, token apitokens.T
|
|||||||
TokenName: token.Name,
|
TokenName: token.Name,
|
||||||
ClientName: token.ClientName,
|
ClientName: token.ClientName,
|
||||||
Operation: op,
|
Operation: op,
|
||||||
Resource: resource,
|
Resource: displayResource,
|
||||||
})
|
})
|
||||||
return apitokens.Token{}, status.Error(codes.PermissionDenied, "access denied by user approval")
|
return apitokens.Token{}, status.Error(codes.PermissionDenied, "access denied by user approval")
|
||||||
case errors.Is(err, apiapproval.ErrRequestCanceled):
|
case errors.Is(err, apiapproval.ErrRequestCanceled):
|
||||||
@@ -1101,7 +1369,7 @@ func (s *Server) authorizeResourceRequest(ctx context.Context, token apitokens.T
|
|||||||
TokenName: token.Name,
|
TokenName: token.Name,
|
||||||
ClientName: token.ClientName,
|
ClientName: token.ClientName,
|
||||||
Operation: op,
|
Operation: op,
|
||||||
Resource: resource,
|
Resource: displayResource,
|
||||||
})
|
})
|
||||||
return apitokens.Token{}, status.Error(codes.Unauthenticated, "authorization request canceled")
|
return apitokens.Token{}, status.Error(codes.Unauthenticated, "authorization request canceled")
|
||||||
case errors.Is(err, apiapproval.ErrRequestTimedOut):
|
case errors.Is(err, apiapproval.ErrRequestTimedOut):
|
||||||
@@ -1111,7 +1379,7 @@ func (s *Server) authorizeResourceRequest(ctx context.Context, token apitokens.T
|
|||||||
TokenName: token.Name,
|
TokenName: token.Name,
|
||||||
ClientName: token.ClientName,
|
ClientName: token.ClientName,
|
||||||
Operation: op,
|
Operation: op,
|
||||||
Resource: resource,
|
Resource: displayResource,
|
||||||
})
|
})
|
||||||
return apitokens.Token{}, status.Error(codes.DeadlineExceeded, "authorization request timed out")
|
return apitokens.Token{}, status.Error(codes.DeadlineExceeded, "authorization request timed out")
|
||||||
case errors.Is(err, context.Canceled):
|
case errors.Is(err, context.Canceled):
|
||||||
@@ -1140,14 +1408,21 @@ func (s *Server) persistApprovalRule(tokenID string, rule apitokens.PolicyRule)
|
|||||||
}
|
}
|
||||||
s.model.Entries[i] = token.Entry(entry.Path)
|
s.model.Entries[i] = token.Entry(entry.Path)
|
||||||
s.dirty = true
|
s.dirty = true
|
||||||
if lifecycle, ok := s.lifecycle.(modelReplaceableLifecycle); ok {
|
s.syncMutationLocked()
|
||||||
lifecycle.Replace(s.model)
|
|
||||||
}
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
return status.Error(codes.NotFound, "api token entry not found")
|
return status.Error(codes.NotFound, "api token entry not found")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Server) syncMutationLocked() {
|
||||||
|
if lifecycle, ok := s.lifecycle.(modelReplaceableLifecycle); ok {
|
||||||
|
lifecycle.Replace(s.model)
|
||||||
|
}
|
||||||
|
if s.notify != nil {
|
||||||
|
s.notify()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func hasPolicyRule(rules []apitokens.PolicyRule, target apitokens.PolicyRule) bool {
|
func hasPolicyRule(rules []apitokens.PolicyRule, target apitokens.PolicyRule) bool {
|
||||||
for _, rule := range rules {
|
for _, rule := range rules {
|
||||||
if rule.Effect != target.Effect || rule.Operation != target.Operation {
|
if rule.Effect != target.Effect || rule.Operation != target.Operation {
|
||||||
@@ -1163,6 +1438,60 @@ func hasPolicyRule(rules []apitokens.PolicyRule, target apitokens.PolicyRule) bo
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func evaluateAuthorization(model vault.Model, token apitokens.Token, op apitokens.Operation, resource apitokens.Resource) apitokens.Decision {
|
||||||
|
return apitokens.Evaluate(canonicalizeTokenForAuthorization(model, token), op, canonicalizeAuthorizationResource(model, resource))
|
||||||
|
}
|
||||||
|
|
||||||
|
func canonicalizeTokenForAuthorization(model vault.Model, token apitokens.Token) apitokens.Token {
|
||||||
|
token.Policies = append([]apitokens.PolicyRule(nil), token.Policies...)
|
||||||
|
for i := range token.Policies {
|
||||||
|
token.Policies[i].Resource = canonicalizeAuthorizationResource(model, token.Policies[i].Resource)
|
||||||
|
}
|
||||||
|
return token
|
||||||
|
}
|
||||||
|
|
||||||
|
func canonicalizeAuthorizationResource(model vault.Model, resource apitokens.Resource) apitokens.Resource {
|
||||||
|
resource.Path = canonicalAuthorizationPath(model, resource.Path)
|
||||||
|
return resource
|
||||||
|
}
|
||||||
|
|
||||||
|
func displayAuthorizationResource(resource apitokens.Resource) apitokens.Resource {
|
||||||
|
resource.Path = displayAuthorizationPath(resource.Path)
|
||||||
|
return resource
|
||||||
|
}
|
||||||
|
|
||||||
|
func canonicalAuthorizationPath(model vault.Model, path []string) []string {
|
||||||
|
if len(path) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if path[0] == vaultview.KeepassRoot {
|
||||||
|
return append([]string(nil), path...)
|
||||||
|
}
|
||||||
|
if path[0] == "Root" {
|
||||||
|
if len(path) > 1 && (path[1] == "Templates" || path[1] == "API Tokens") {
|
||||||
|
return append([]string(nil), path...)
|
||||||
|
}
|
||||||
|
return vaultview.VaultRoot(model).ToPhysicalPath(path[1:])
|
||||||
|
}
|
||||||
|
if path[0] == "Templates" || path[0] == "API Tokens" {
|
||||||
|
return append([]string(nil), path...)
|
||||||
|
}
|
||||||
|
return vaultview.VaultRoot(model).ToPhysicalPath(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
func displayAuthorizationPath(path []string) []string {
|
||||||
|
if len(path) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if path[0] == vaultview.KeepassRoot {
|
||||||
|
return append([]string{"Root"}, append([]string(nil), path[1:]...)...)
|
||||||
|
}
|
||||||
|
if path[0] == "Root" {
|
||||||
|
return append([]string(nil), path...)
|
||||||
|
}
|
||||||
|
return append([]string(nil), path...)
|
||||||
|
}
|
||||||
|
|
||||||
func copyOperation(target string) apitokens.Operation {
|
func copyOperation(target string) apitokens.Operation {
|
||||||
switch clipboard.Target(target) {
|
switch clipboard.Target(target) {
|
||||||
case clipboard.TargetUsername:
|
case clipboard.TargetUsername:
|
||||||
|
|||||||
+634
-4
@@ -5,8 +5,10 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"crypto/sha256"
|
"crypto/sha256"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
|
"errors"
|
||||||
"net"
|
"net"
|
||||||
"os"
|
"os"
|
||||||
|
"slices"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -99,7 +101,7 @@ func TestVaultServiceRejectsUnauthorizedEntryAccess(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestVaultServiceRejectsUnauthorizedVaultManagement(t *testing.T) {
|
func TestVaultServiceAllowsSessionStatusWithoutManageVault(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
client, _, cleanup := newTestClientForModel(t, vault.Model{
|
client, _, cleanup := newTestClientForModel(t, vault.Model{
|
||||||
@@ -111,9 +113,84 @@ func TestVaultServiceRejectsUnauthorizedVaultManagement(t *testing.T) {
|
|||||||
})
|
})
|
||||||
defer cleanup()
|
defer cleanup()
|
||||||
|
|
||||||
_, err := client.GetSessionStatus(tokenContext(defaultTestTokenSecret), &keepassgov1.GetSessionStatusRequest{})
|
resp, err := client.GetSessionStatus(tokenContext(defaultTestTokenSecret), &keepassgov1.GetSessionStatusRequest{})
|
||||||
if status.Code(err) != codes.PermissionDenied {
|
if err != nil {
|
||||||
t.Fatalf("GetSessionStatus() code = %v, want %v", status.Code(err), codes.PermissionDenied)
|
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -183,6 +260,417 @@ func TestVaultServiceFindsBrowserLoginsForAuthorizedClients(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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) {
|
func TestVaultServiceGetsBrowserCredentialForAuthorizedClients(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -764,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) {
|
func TestVaultServiceListsCreatesAndRenamesGroupsForAuthorizedClients(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -940,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) {
|
func TestVaultServiceDeletesAndRestoresEntriesForAuthorizedClients(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ var (
|
|||||||
ErrRequestCanceled = errors.New("authorization request canceled")
|
ErrRequestCanceled = errors.New("authorization request canceled")
|
||||||
ErrRequestTimedOut = errors.New("authorization request timed out")
|
ErrRequestTimedOut = errors.New("authorization request timed out")
|
||||||
ErrRequestNotFound = errors.New("authorization request not found")
|
ErrRequestNotFound = errors.New("authorization request not found")
|
||||||
|
ErrBrokerNotConfigured = errors.New("authorization broker is not configured")
|
||||||
)
|
)
|
||||||
|
|
||||||
type Outcome string
|
type Outcome string
|
||||||
@@ -120,7 +121,7 @@ func (b *Broker) SetChangeNotifier(notify func()) {
|
|||||||
|
|
||||||
func (b *Broker) Request(ctx context.Context, token apitokens.Token, op apitokens.Operation, resource apitokens.Resource) (Result, error) {
|
func (b *Broker) Request(ctx context.Context, token apitokens.Token, op apitokens.Operation, resource apitokens.Resource) (Result, error) {
|
||||||
if b == nil {
|
if b == nil {
|
||||||
return Result{}, ErrRequestTimedOut
|
return Result{}, ErrBrokerNotConfigured
|
||||||
}
|
}
|
||||||
|
|
||||||
pending := &pendingRequest{
|
pending := &pendingRequest{
|
||||||
|
|||||||
@@ -121,6 +121,16 @@ 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) {
|
func TestBrokerNotifiesWhenPendingRequestsChange(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
|
|||||||
+353
-141
@@ -11,6 +11,7 @@ import (
|
|||||||
"git.julianfamily.org/keepassgo/internal/apiaudit"
|
"git.julianfamily.org/keepassgo/internal/apiaudit"
|
||||||
"git.julianfamily.org/keepassgo/internal/apitokens"
|
"git.julianfamily.org/keepassgo/internal/apitokens"
|
||||||
"git.julianfamily.org/keepassgo/internal/vault"
|
"git.julianfamily.org/keepassgo/internal/vault"
|
||||||
|
"git.julianfamily.org/keepassgo/internal/vaultview"
|
||||||
"git.julianfamily.org/keepassgo/internal/webdav"
|
"git.julianfamily.org/keepassgo/internal/webdav"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -30,6 +31,9 @@ const (
|
|||||||
SectionAbout Section = "about"
|
SectionAbout Section = "about"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const entriesRootLabel = "Root"
|
||||||
|
const templatesRootLabel = "Templates"
|
||||||
|
|
||||||
type CurrentSession interface {
|
type CurrentSession interface {
|
||||||
Current() (vault.Model, error)
|
Current() (vault.Model, error)
|
||||||
}
|
}
|
||||||
@@ -55,6 +59,15 @@ type SaveableSession interface {
|
|||||||
Save() error
|
Save() error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type AutoSaveableSession interface {
|
||||||
|
SaveableSession
|
||||||
|
HasSaveTarget() bool
|
||||||
|
}
|
||||||
|
|
||||||
|
type RemoteAwareSession interface {
|
||||||
|
IsRemote() bool
|
||||||
|
}
|
||||||
|
|
||||||
type SynchronizableSession interface {
|
type SynchronizableSession interface {
|
||||||
CurrentSession
|
CurrentSession
|
||||||
Synchronize() error
|
Synchronize() error
|
||||||
@@ -89,6 +102,10 @@ type RemoteOpenableSession interface {
|
|||||||
OpenRemote(webdav.Client, string, vault.MasterKey) error
|
OpenRemote(webdav.Client, string, vault.MasterKey) error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type WarningSession interface {
|
||||||
|
ConsumeWarning() string
|
||||||
|
}
|
||||||
|
|
||||||
type SecurityConfigurableSession interface {
|
type SecurityConfigurableSession interface {
|
||||||
ConfigureSecurity(vault.SecuritySettings) error
|
ConfigureSecurity(vault.SecuritySettings) error
|
||||||
SecuritySettings() vault.SecuritySettings
|
SecuritySettings() vault.SecuritySettings
|
||||||
@@ -103,6 +120,7 @@ type State struct {
|
|||||||
Session CurrentSession
|
Session CurrentSession
|
||||||
Approvals ApprovalManager
|
Approvals ApprovalManager
|
||||||
AuditLog *apiaudit.Log
|
AuditLog *apiaudit.Log
|
||||||
|
AutoSaveRemote bool
|
||||||
Section Section
|
Section Section
|
||||||
CurrentPath []string
|
CurrentPath []string
|
||||||
SearchQuery string
|
SearchQuery string
|
||||||
@@ -182,127 +200,111 @@ func (s *State) RemoteCredentialEntries() ([]vault.Entry, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *State) IssueAPIToken(name, clientName string, expiresAt *time.Time, now time.Time) (apitokens.Token, string, error) {
|
func (s *State) IssueAPIToken(name, clientName string, expiresAt *time.Time, now time.Time) (apitokens.Token, string, error) {
|
||||||
session, ok := s.Session.(MutableSession)
|
result, err := s.mutateAPITokens(apiaudit.EventTokenIssued, "issued API token", func(model *vault.Model) (tokenMutationResult, error) {
|
||||||
if !ok {
|
|
||||||
return apitokens.Token{}, "", fmt.Errorf("session is not mutable")
|
|
||||||
}
|
|
||||||
model, err := session.Current()
|
|
||||||
if err != nil {
|
|
||||||
return apitokens.Token{}, "", err
|
|
||||||
}
|
|
||||||
token, secret, err := apitokens.Issue(name, clientName, expiresAt, now)
|
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 {
|
if err != nil {
|
||||||
return apitokens.Token{}, "", err
|
return apitokens.Token{}, "", err
|
||||||
}
|
}
|
||||||
apitokens.Upsert(&model, token)
|
return result.token, result.secret, nil
|
||||||
session.Replace(model)
|
|
||||||
s.Dirty = true
|
|
||||||
s.recordTokenAudit(apiaudit.EventTokenIssued, token, "issued API token")
|
|
||||||
return token, secret, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *State) RotateAPIToken(id string, now time.Time) (apitokens.Token, string, error) {
|
func (s *State) RotateAPIToken(id string, now time.Time) (apitokens.Token, string, error) {
|
||||||
session, ok := s.Session.(MutableSession)
|
result, err := s.mutateAPITokens(apiaudit.EventTokenRotated, "rotated API token", func(model *vault.Model) (tokenMutationResult, error) {
|
||||||
if !ok {
|
token, err := apitokens.Find(*model, id)
|
||||||
return apitokens.Token{}, "", fmt.Errorf("session is not mutable")
|
|
||||||
}
|
|
||||||
model, err := session.Current()
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return apitokens.Token{}, "", err
|
return tokenMutationResult{}, err
|
||||||
}
|
|
||||||
token, err := apitokens.Find(model, id)
|
|
||||||
if err != nil {
|
|
||||||
return apitokens.Token{}, "", err
|
|
||||||
}
|
}
|
||||||
token, secret, err := apitokens.Rotate(token, now)
|
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 {
|
if err != nil {
|
||||||
return apitokens.Token{}, "", err
|
return apitokens.Token{}, "", err
|
||||||
}
|
}
|
||||||
apitokens.Upsert(&model, token)
|
return result.token, result.secret, nil
|
||||||
session.Replace(model)
|
|
||||||
s.Dirty = true
|
|
||||||
s.recordTokenAudit(apiaudit.EventTokenRotated, token, "rotated API token")
|
|
||||||
return token, secret, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *State) UpsertAPIToken(token apitokens.Token) error {
|
func (s *State) UpsertAPIToken(token apitokens.Token) error {
|
||||||
session, ok := s.Session.(MutableSession)
|
_, err := s.mutateAPITokens(apiaudit.EventTokenUpdated, "updated API token", func(model *vault.Model) (tokenMutationResult, error) {
|
||||||
if !ok {
|
apitokens.Upsert(model, token)
|
||||||
return fmt.Errorf("session is not mutable")
|
return tokenMutationResult{token: token}, nil
|
||||||
}
|
})
|
||||||
model, err := session.Current()
|
|
||||||
if err != nil {
|
|
||||||
return err
|
return err
|
||||||
}
|
|
||||||
apitokens.Upsert(&model, token)
|
|
||||||
session.Replace(model)
|
|
||||||
s.Dirty = true
|
|
||||||
s.recordTokenAudit(apiaudit.EventTokenUpdated, token, "updated API token")
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *State) DisableAPIToken(id string) error {
|
func (s *State) DisableAPIToken(id string) error {
|
||||||
session, ok := s.Session.(MutableSession)
|
_, err := s.mutateAPITokens(apiaudit.EventTokenDisabled, "disabled API token", func(model *vault.Model) (tokenMutationResult, error) {
|
||||||
if !ok {
|
token, err := apitokens.Find(*model, id)
|
||||||
return fmt.Errorf("session is not mutable")
|
|
||||||
}
|
|
||||||
model, err := session.Current()
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return tokenMutationResult{}, err
|
||||||
}
|
|
||||||
token, err := apitokens.Find(model, id)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
}
|
||||||
token = apitokens.Disable(token)
|
token = apitokens.Disable(token)
|
||||||
apitokens.Upsert(&model, token)
|
apitokens.Upsert(model, token)
|
||||||
session.Replace(model)
|
return tokenMutationResult{token: token}, nil
|
||||||
s.Dirty = true
|
})
|
||||||
s.recordTokenAudit(apiaudit.EventTokenDisabled, token, "disabled API token")
|
return err
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *State) RevokeAPIToken(id string, when time.Time) error {
|
func (s *State) RevokeAPIToken(id string, when time.Time) error {
|
||||||
session, ok := s.Session.(MutableSession)
|
_, err := s.mutateAPITokens(apiaudit.EventTokenRevoked, "revoked API token", func(model *vault.Model) (tokenMutationResult, error) {
|
||||||
if !ok {
|
token, err := apitokens.Find(*model, id)
|
||||||
return fmt.Errorf("session is not mutable")
|
|
||||||
}
|
|
||||||
model, err := session.Current()
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return tokenMutationResult{}, err
|
||||||
}
|
|
||||||
token, err := apitokens.Find(model, id)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
}
|
||||||
token = apitokens.Revoke(token, when)
|
token = apitokens.Revoke(token, when)
|
||||||
apitokens.Upsert(&model, token)
|
apitokens.Upsert(model, token)
|
||||||
session.Replace(model)
|
return tokenMutationResult{token: token}, nil
|
||||||
s.Dirty = true
|
})
|
||||||
s.recordTokenAudit(apiaudit.EventTokenRevoked, token, "revoked API token")
|
return err
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *State) DeleteAPIToken(id string) error {
|
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)
|
session, ok := s.Session.(MutableSession)
|
||||||
if !ok {
|
if !ok {
|
||||||
return fmt.Errorf("session is not mutable")
|
return tokenMutationResult{}, fmt.Errorf("session is not mutable")
|
||||||
}
|
}
|
||||||
model, err := session.Current()
|
model, err := session.Current()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return tokenMutationResult{}, err
|
||||||
}
|
}
|
||||||
token, err := apitokens.Find(model, id)
|
result, err := mutate(&model)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return tokenMutationResult{}, err
|
||||||
}
|
|
||||||
if err := apitokens.Delete(&model, id); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
}
|
||||||
session.Replace(model)
|
session.Replace(model)
|
||||||
s.Dirty = true
|
if err := s.markDirtyAndAutoSave(); err != nil {
|
||||||
s.recordTokenAudit(apiaudit.EventTokenDeleted, token, "deleted API token")
|
return tokenMutationResult{}, err
|
||||||
return nil
|
}
|
||||||
|
s.recordTokenAudit(eventType, result.token, message)
|
||||||
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *State) recordTokenAudit(eventType apiaudit.EventType, token apitokens.Token, message string) {
|
func (s *State) recordTokenAudit(eventType apiaudit.EventType, token apitokens.Token, message string) {
|
||||||
@@ -339,8 +341,7 @@ func (s *State) ConfigureSecurity(settings vault.SecuritySettings) error {
|
|||||||
if err := security.ConfigureSecurity(settings); err != nil {
|
if err := security.ConfigureSecurity(settings); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
s.Dirty = true
|
return s.markDirtyAndAutoSave()
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *State) ShowSection(section Section) {
|
func (s *State) ShowSection(section Section) {
|
||||||
@@ -382,7 +383,7 @@ func (s *State) VisibleEntries() ([]vault.Entry, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if s.Section == SectionEntries {
|
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 {
|
if s.Section == SectionRecycleBin || len(s.CurrentPath) == 0 {
|
||||||
return entries, nil
|
return entries, nil
|
||||||
@@ -402,13 +403,13 @@ func (s *State) ChildGroups() ([]string, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if s.Section != SectionEntries {
|
if s.Section != SectionEntries {
|
||||||
if s.Section == SectionTemplates && len(s.CurrentPath) == 0 {
|
if s.Section == SectionTemplates {
|
||||||
return childGroups(s.entriesForSection(model), []string{"Templates"}), nil
|
return vaultview.VaultTemplates(model).ChildGroups(templatesViewPath(s.CurrentPath)), nil
|
||||||
}
|
}
|
||||||
return childGroups(s.entriesForSection(model), 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 {
|
func (s *State) SelectVisibleIndex(index int) error {
|
||||||
@@ -452,13 +453,13 @@ func (s *State) currentModel() (vault.Model, error) {
|
|||||||
func (s *State) entriesForSection(model vault.Model) []vault.Entry {
|
func (s *State) entriesForSection(model vault.Model) []vault.Entry {
|
||||||
switch s.Section {
|
switch s.Section {
|
||||||
case SectionTemplates:
|
case SectionTemplates:
|
||||||
return slices.Clone(model.Templates)
|
return logicalTemplateEntries(vaultview.VaultTemplates(model).EntriesUnderPath(nil))
|
||||||
case SectionRecycleBin:
|
case SectionRecycleBin:
|
||||||
return slices.Clone(model.RecycleBin)
|
return logicalEntries(vaultview.VaultRecycleBin(model).EntriesUnderPath(nil))
|
||||||
case SectionAPITokens, SectionAPIAudit, SectionAbout:
|
case SectionAPITokens, SectionAPIAudit, SectionAbout:
|
||||||
return nil
|
return nil
|
||||||
default:
|
default:
|
||||||
return slices.Clone(model.Entries)
|
return logicalEntries(vaultview.VaultRoot(model).EntriesUnderPath(nil))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -466,11 +467,11 @@ func (s State) SearchPathContext(entry vault.Entry) string {
|
|||||||
path := slices.Clone(entry.Path)
|
path := slices.Clone(entry.Path)
|
||||||
switch s.Section {
|
switch s.Section {
|
||||||
case SectionTemplates:
|
case SectionTemplates:
|
||||||
if len(path) == 0 || path[0] != "Templates" {
|
path = logicalTemplatePath(path)
|
||||||
path = append([]string{"Templates"}, path...)
|
|
||||||
}
|
|
||||||
case SectionRecycleBin:
|
case SectionRecycleBin:
|
||||||
path = append([]string{"Recycle Bin"}, path...)
|
path = append([]string{"Recycle Bin"}, logicalEntriesPath(path)...)
|
||||||
|
case SectionEntries:
|
||||||
|
path = logicalEntriesPath(path)
|
||||||
}
|
}
|
||||||
return strings.Join(path, " / ")
|
return strings.Join(path, " / ")
|
||||||
}
|
}
|
||||||
@@ -527,6 +528,163 @@ func filterEntries(entries []vault.Entry, query string) []vault.Entry {
|
|||||||
return out
|
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 {
|
func childGroups(entries []vault.Entry, path []string) []string {
|
||||||
seen := map[string]bool{}
|
seen := map[string]bool{}
|
||||||
var groups []string
|
var groups []string
|
||||||
@@ -551,6 +709,33 @@ func childGroups(entries []vault.Entry, path []string) []string {
|
|||||||
return groups
|
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 {
|
func (s *State) DeleteSelectedEntry() error {
|
||||||
session, ok := s.Session.(MutableSession)
|
session, ok := s.Session.(MutableSession)
|
||||||
if !ok {
|
if !ok {
|
||||||
@@ -568,8 +753,7 @@ func (s *State) DeleteSelectedEntry() error {
|
|||||||
|
|
||||||
session.Replace(model)
|
session.Replace(model)
|
||||||
s.SelectedEntryID = ""
|
s.SelectedEntryID = ""
|
||||||
s.Dirty = true
|
return s.markDirtyAndAutoSave()
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *State) RestoreEntry(id string) error {
|
func (s *State) RestoreEntry(id string) error {
|
||||||
@@ -588,8 +772,7 @@ func (s *State) RestoreEntry(id string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
session.Replace(model)
|
session.Replace(model)
|
||||||
s.Dirty = true
|
return s.markDirtyAndAutoSave()
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *State) UpsertEntry(entry vault.Entry) error {
|
func (s *State) UpsertEntry(entry vault.Entry) error {
|
||||||
@@ -603,11 +786,10 @@ func (s *State) UpsertEntry(entry vault.Entry) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
model.UpsertEntry(entry)
|
model.UpsertEntry(vaultview.VaultRoot(model).ToPhysicalEntry(entryForModel(model, entry)))
|
||||||
session.Replace(model)
|
session.Replace(model)
|
||||||
s.SelectedEntryID = entry.ID
|
s.SelectedEntryID = entry.ID
|
||||||
s.Dirty = true
|
return s.markDirtyAndAutoSave()
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *State) UpsertTemplate(entry vault.Entry) error {
|
func (s *State) UpsertTemplate(entry vault.Entry) error {
|
||||||
@@ -621,11 +803,10 @@ func (s *State) UpsertTemplate(entry vault.Entry) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
model.UpsertTemplate(entry)
|
model.UpsertTemplate(vaultview.VaultTemplates(model).ToPhysicalEntry(templateEntryForModel(entry)))
|
||||||
session.Replace(model)
|
session.Replace(model)
|
||||||
s.SelectedEntryID = entry.ID
|
s.SelectedEntryID = entry.ID
|
||||||
s.Dirty = true
|
return s.markDirtyAndAutoSave()
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *State) InstantiateTemplate(templateID string, overrides vault.Entry) (vault.Entry, error) {
|
func (s *State) InstantiateTemplate(templateID string, overrides vault.Entry) (vault.Entry, error) {
|
||||||
@@ -639,15 +820,17 @@ func (s *State) InstantiateTemplate(templateID string, overrides vault.Entry) (v
|
|||||||
return vault.Entry{}, err
|
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 {
|
if err != nil {
|
||||||
return vault.Entry{}, err
|
return vault.Entry{}, err
|
||||||
}
|
}
|
||||||
|
|
||||||
session.Replace(model)
|
session.Replace(model)
|
||||||
s.SelectedEntryID = entry.ID
|
s.SelectedEntryID = entry.ID
|
||||||
s.Dirty = true
|
if err := s.markDirtyAndAutoSave(); err != nil {
|
||||||
return entry, nil
|
return vault.Entry{}, err
|
||||||
|
}
|
||||||
|
return logicalEntry(entry), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *State) DeleteTemplate(id string) error {
|
func (s *State) DeleteTemplate(id string) error {
|
||||||
@@ -669,8 +852,7 @@ func (s *State) DeleteTemplate(id string) error {
|
|||||||
if s.SelectedEntryID == id {
|
if s.SelectedEntryID == id {
|
||||||
s.SelectedEntryID = ""
|
s.SelectedEntryID = ""
|
||||||
}
|
}
|
||||||
s.Dirty = true
|
return s.markDirtyAndAutoSave()
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *State) DuplicateSelectedEntry(duplicateID string) (vault.Entry, error) {
|
func (s *State) DuplicateSelectedEntry(duplicateID string) (vault.Entry, error) {
|
||||||
@@ -691,7 +873,9 @@ func (s *State) DuplicateSelectedEntry(duplicateID string) (vault.Entry, error)
|
|||||||
|
|
||||||
session.Replace(model)
|
session.Replace(model)
|
||||||
s.SelectedEntryID = duplicate.ID
|
s.SelectedEntryID = duplicate.ID
|
||||||
s.Dirty = true
|
if err := s.markDirtyAndAutoSave(); err != nil {
|
||||||
|
return vault.Entry{}, err
|
||||||
|
}
|
||||||
return duplicate, nil
|
return duplicate, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -711,8 +895,7 @@ func (s *State) RestoreSelectedEntryVersion(historyIndex int) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
session.Replace(model)
|
session.Replace(model)
|
||||||
s.Dirty = true
|
return s.markDirtyAndAutoSave()
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *State) Lock() error {
|
func (s *State) Lock() error {
|
||||||
@@ -735,7 +918,13 @@ func (s *State) Unlock(key vault.MasterKey) error {
|
|||||||
return fmt.Errorf("session is not lockable")
|
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 {
|
func (s *State) ChangeMasterKey(key vault.MasterKey) error {
|
||||||
@@ -748,8 +937,7 @@ func (s *State) ChangeMasterKey(key vault.MasterKey) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
s.Dirty = true
|
return s.markDirtyAndAutoSave()
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *State) EnterGroup(name string) {
|
func (s *State) EnterGroup(name string) {
|
||||||
@@ -776,6 +964,25 @@ func (s *State) Save() error {
|
|||||||
return nil
|
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 {
|
func (s *State) Synchronize() error {
|
||||||
session, ok := s.Session.(SynchronizableSession)
|
session, ok := s.Session.(SynchronizableSession)
|
||||||
if !ok {
|
if !ok {
|
||||||
@@ -879,6 +1086,9 @@ func (s *State) OpenVault(path string, key vault.MasterKey) error {
|
|||||||
s.CurrentPath = nil
|
s.CurrentPath = nil
|
||||||
s.SelectedEntryID = ""
|
s.SelectedEntryID = ""
|
||||||
s.Dirty = false
|
s.Dirty = false
|
||||||
|
if warningSession, ok := s.Session.(WarningSession); ok {
|
||||||
|
s.StatusMessage = warningSession.ConsumeWarning()
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -909,6 +1119,9 @@ func (s *State) OpenRemoteVault(client webdav.Client, path string, key vault.Mas
|
|||||||
s.CurrentPath = nil
|
s.CurrentPath = nil
|
||||||
s.SelectedEntryID = ""
|
s.SelectedEntryID = ""
|
||||||
s.Dirty = false
|
s.Dirty = false
|
||||||
|
if warningSession, ok := s.Session.(WarningSession); ok {
|
||||||
|
s.StatusMessage = warningSession.ConsumeWarning()
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -948,7 +1161,9 @@ func (s *State) ConfigureRemoteBinding(input RemoteBindingInput) (RemoteBinding,
|
|||||||
}
|
}
|
||||||
|
|
||||||
session.Replace(model)
|
session.Replace(model)
|
||||||
s.Dirty = true
|
if err := s.markDirtyAndAutoSave(); err != nil {
|
||||||
|
return RemoteBinding{}, err
|
||||||
|
}
|
||||||
return binding, nil
|
return binding, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -968,8 +1183,7 @@ func (s *State) RemoveRemoteBinding(binding RemoteBinding) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
session.Replace(model)
|
session.Replace(model)
|
||||||
s.Dirty = true
|
return s.markDirtyAndAutoSave()
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *State) CreateGroup(name string) error {
|
func (s *State) CreateGroup(name string) error {
|
||||||
@@ -983,10 +1197,10 @@ func (s *State) CreateGroup(name string) error {
|
|||||||
return err
|
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)
|
session.Replace(model)
|
||||||
s.Dirty = true
|
return s.markDirtyAndAutoSave()
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *State) MoveCurrentGroup(parent []string) error {
|
func (s *State) MoveCurrentGroup(parent []string) error {
|
||||||
@@ -998,16 +1212,18 @@ func (s *State) MoveCurrentGroup(parent []string) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
current := append([]string(nil), s.CurrentPath...)
|
view := sectionGroupView(model, s.Section)
|
||||||
if err := model.MoveGroup(current, parent); err != nil {
|
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
|
return err
|
||||||
}
|
}
|
||||||
session.Replace(model)
|
session.Replace(model)
|
||||||
if len(current) > 0 {
|
if len(currentViewPath) > 0 {
|
||||||
s.CurrentPath = append(append([]string(nil), parent...), current[len(current)-1])
|
s.CurrentPath = sectionGroupLogicalPath(model, s.Section, append(append([]string(nil), parentViewPath...), currentViewPath[len(currentViewPath)-1]))
|
||||||
}
|
}
|
||||||
s.Dirty = true
|
return s.markDirtyAndAutoSave()
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *State) RenameCurrentGroup(newName string) error {
|
func (s *State) RenameCurrentGroup(newName string) error {
|
||||||
@@ -1021,7 +1237,8 @@ func (s *State) RenameCurrentGroup(newName string) error {
|
|||||||
return err
|
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
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1029,8 +1246,7 @@ func (s *State) RenameCurrentGroup(newName string) error {
|
|||||||
if len(s.CurrentPath) > 0 {
|
if len(s.CurrentPath) > 0 {
|
||||||
s.CurrentPath = append(append([]string(nil), s.CurrentPath[:len(s.CurrentPath)-1]...), newName)
|
s.CurrentPath = append(append([]string(nil), s.CurrentPath[:len(s.CurrentPath)-1]...), newName)
|
||||||
}
|
}
|
||||||
s.Dirty = true
|
return s.markDirtyAndAutoSave()
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *State) MoveSelectedEntry(path []string) error {
|
func (s *State) MoveSelectedEntry(path []string) error {
|
||||||
@@ -1044,13 +1260,12 @@ func (s *State) MoveSelectedEntry(path []string) error {
|
|||||||
return err
|
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
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
session.Replace(model)
|
session.Replace(model)
|
||||||
s.Dirty = true
|
return s.markDirtyAndAutoSave()
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *State) DeleteCurrentGroup() error {
|
func (s *State) DeleteCurrentGroup() error {
|
||||||
@@ -1064,7 +1279,8 @@ func (s *State) DeleteCurrentGroup() error {
|
|||||||
return err
|
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
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1073,8 +1289,7 @@ func (s *State) DeleteCurrentGroup() error {
|
|||||||
s.CurrentPath = append([]string(nil), s.CurrentPath[:len(s.CurrentPath)-1]...)
|
s.CurrentPath = append([]string(nil), s.CurrentPath[:len(s.CurrentPath)-1]...)
|
||||||
}
|
}
|
||||||
s.SelectedEntryID = ""
|
s.SelectedEntryID = ""
|
||||||
s.Dirty = true
|
return s.markDirtyAndAutoSave()
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *State) AddAttachmentToSelectedEntry(name string, content []byte) error {
|
func (s *State) AddAttachmentToSelectedEntry(name string, content []byte) error {
|
||||||
@@ -1100,8 +1315,7 @@ func (s *State) AddAttachmentToSelectedEntry(name string, content []byte) error
|
|||||||
}
|
}
|
||||||
model.Entries[i].Attachments[name] = append([]byte(nil), content...)
|
model.Entries[i].Attachments[name] = append([]byte(nil), content...)
|
||||||
session.Replace(model)
|
session.Replace(model)
|
||||||
s.Dirty = true
|
return s.markDirtyAndAutoSave()
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return vault.ErrEntryNotFound
|
return vault.ErrEntryNotFound
|
||||||
@@ -1127,8 +1341,7 @@ func (s *State) ReplaceAttachmentOnSelectedEntry(name string, content []byte) er
|
|||||||
}
|
}
|
||||||
model.Entries[i].Attachments[name] = append([]byte(nil), content...)
|
model.Entries[i].Attachments[name] = append([]byte(nil), content...)
|
||||||
session.Replace(model)
|
session.Replace(model)
|
||||||
s.Dirty = true
|
return s.markDirtyAndAutoSave()
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return vault.ErrEntryNotFound
|
return vault.ErrEntryNotFound
|
||||||
@@ -1157,8 +1370,7 @@ func (s *State) DeleteAttachmentFromSelectedEntry(name string) error {
|
|||||||
model.Entries[i].Attachments = nil
|
model.Entries[i].Attachments = nil
|
||||||
}
|
}
|
||||||
session.Replace(model)
|
session.Replace(model)
|
||||||
s.Dirty = true
|
return s.markDirtyAndAutoSave()
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return vault.ErrEntryNotFound
|
return vault.ErrEntryNotFound
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ func TestVisibleEntriesFollowsCurrentPathWithoutSearch(t *testing.T) {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
CurrentPath: []string{"Crew", "Internet"},
|
CurrentPath: []string{"Root", "Crew", "Internet"},
|
||||||
}
|
}
|
||||||
|
|
||||||
got, err := state.VisibleEntries()
|
got, err := state.VisibleEntries()
|
||||||
@@ -184,6 +184,93 @@ func TestIssueRotateDisableRevokeAndDeleteAPIToken(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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) {
|
func TestRemoteProfilesReturnsVaultProfiles(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -496,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) {
|
func TestChildGroupsUsesCurrentModelAndCurrentPath(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -1480,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) {
|
func TestCreateGroupSupportsNestedGroupPath(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -1493,11 +1703,11 @@ func TestCreateGroupSupportsNestedGroupPath(t *testing.T) {
|
|||||||
t.Fatalf("CreateGroup() error = %v", err)
|
t.Fatalf("CreateGroup() error = %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if got := session.model.ChildGroups([]string{"Root"}); !slices.Equal(got, []string{"Infrastructure"}) {
|
if got := session.model.ChildGroups([]string{"keepass"}); !slices.Equal(got, []string{"Infrastructure"}) {
|
||||||
t.Fatalf("ChildGroups(Root) = %v, want [Infrastructure]", got)
|
t.Fatalf("ChildGroups(keepass) = %v, want [Infrastructure]", got)
|
||||||
}
|
}
|
||||||
if got := session.model.ChildGroups([]string{"Root", "Infrastructure"}); !slices.Equal(got, []string{"Prod"}) {
|
if got := session.model.ChildGroups([]string{"keepass", "Infrastructure"}); !slices.Equal(got, []string{"Prod"}) {
|
||||||
t.Fatalf("ChildGroups(Root/Infrastructure) = %v, want [Prod]", got)
|
t.Fatalf("ChildGroups(keepass/Infrastructure) = %v, want [Prod]", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1816,6 +2026,39 @@ func (s *saveableStubSession) Save() error {
|
|||||||
return nil
|
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 {
|
type lifecycleStubSession struct {
|
||||||
createCalls int
|
createCalls int
|
||||||
model vault.Model
|
model vault.Model
|
||||||
|
|||||||
@@ -71,7 +71,7 @@ func AuditEventSearchTerms(event apiaudit.Event) string {
|
|||||||
event.ClientName,
|
event.ClientName,
|
||||||
string(event.Operation),
|
string(event.Operation),
|
||||||
AuditOperationLabel(event.Operation),
|
AuditOperationLabel(event.Operation),
|
||||||
strings.Join(event.Resource.Path, " / "),
|
FormatResourcePath(event.Resource.Path),
|
||||||
event.Resource.EntryID,
|
event.Resource.EntryID,
|
||||||
event.Message,
|
event.Message,
|
||||||
}
|
}
|
||||||
@@ -91,3 +91,17 @@ func AuditEventSearchTerms(event apiaudit.Event) string {
|
|||||||
}
|
}
|
||||||
return strings.ToLower(strings.Join(parts, " "))
|
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), " / ")
|
||||||
|
}
|
||||||
|
|||||||
@@ -336,7 +336,7 @@ func (u *ui) editAPIPolicyRuleAction(index int) error {
|
|||||||
}
|
}
|
||||||
u.apiPolicyGroupScope = true
|
u.apiPolicyGroupScope = true
|
||||||
u.apiPolicyGroupScopeW.Value = true
|
u.apiPolicyGroupScopeW.Value = true
|
||||||
u.apiPolicyPath.SetText(strings.Join(rule.Resource.Path, " / "))
|
u.apiPolicyPath.SetText(apiui.FormatResourcePath(rule.Resource.Path))
|
||||||
u.apiPolicyEntryID.SetText("")
|
u.apiPolicyEntryID.SetText("")
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -476,7 +476,7 @@ func policyRuleParts(rule apitokens.PolicyRule) (string, string, string) {
|
|||||||
if rule.Resource.Kind == apitokens.ResourceEntry {
|
if rule.Resource.Kind == apitokens.ResourceEntry {
|
||||||
resource = "Entry: " + rule.Resource.EntryID
|
resource = "Entry: " + rule.Resource.EntryID
|
||||||
} else if len(rule.Resource.Path) > 0 {
|
} else if len(rule.Resource.Path) > 0 {
|
||||||
resource = strings.Join(rule.Resource.Path, " / ")
|
resource = apiui.FormatResourcePath(rule.Resource.Path)
|
||||||
}
|
}
|
||||||
return effect, operation, resource
|
return effect, operation, resource
|
||||||
}
|
}
|
||||||
@@ -836,8 +836,8 @@ func (u *ui) auditQuickFilterButton(gtx layout.Context, click *widget.Clickable,
|
|||||||
|
|
||||||
func (u *ui) apiTokenDetailPanel(gtx layout.Context) layout.Dimensions {
|
func (u *ui) apiTokenDetailPanel(gtx layout.Context) layout.Dimensions {
|
||||||
token, ok := u.selectedAPIToken()
|
token, ok := u.selectedAPIToken()
|
||||||
editClicks := u.ensureAPIPolicyEditClickables(0)
|
var editClicks []widget.Clickable
|
||||||
removeClicks := u.ensureAPIPolicyRemoveClickables(0)
|
var removeClicks []widget.Clickable
|
||||||
if ok {
|
if ok {
|
||||||
editClicks = u.ensureAPIPolicyEditClickables(len(token.Policies))
|
editClicks = u.ensureAPIPolicyEditClickables(len(token.Policies))
|
||||||
removeClicks = u.ensureAPIPolicyRemoveClickables(len(token.Policies))
|
removeClicks = u.ensureAPIPolicyRemoveClickables(len(token.Policies))
|
||||||
@@ -1211,5 +1211,5 @@ func formatAuditResource(resource apitokens.Resource) string {
|
|||||||
if len(resource.Path) == 0 {
|
if len(resource.Path) == 0 {
|
||||||
return "/"
|
return "/"
|
||||||
}
|
}
|
||||||
return strings.Join(resource.Path, " / ")
|
return apiui.FormatResourcePath(resource.Path)
|
||||||
}
|
}
|
||||||
|
|||||||
+25
-3
@@ -27,6 +27,7 @@ import (
|
|||||||
"git.julianfamily.org/keepassgo/internal/apiaudit"
|
"git.julianfamily.org/keepassgo/internal/apiaudit"
|
||||||
"git.julianfamily.org/keepassgo/internal/apitokens"
|
"git.julianfamily.org/keepassgo/internal/apitokens"
|
||||||
"git.julianfamily.org/keepassgo/internal/appstate"
|
"git.julianfamily.org/keepassgo/internal/appstate"
|
||||||
|
apiui "git.julianfamily.org/keepassgo/internal/appui/api"
|
||||||
detailmodel "git.julianfamily.org/keepassgo/internal/appui/detail"
|
detailmodel "git.julianfamily.org/keepassgo/internal/appui/detail"
|
||||||
detaillayout "git.julianfamily.org/keepassgo/internal/appui/detail/layout"
|
detaillayout "git.julianfamily.org/keepassgo/internal/appui/detail/layout"
|
||||||
lifecyclemodel "git.julianfamily.org/keepassgo/internal/appui/lifecycle"
|
lifecyclemodel "git.julianfamily.org/keepassgo/internal/appui/lifecycle"
|
||||||
@@ -139,6 +140,7 @@ type statePaths struct {
|
|||||||
AutofillCachePath string
|
AutofillCachePath string
|
||||||
PendingSharedVaultPath string
|
PendingSharedVaultPath string
|
||||||
PendingSharedVaultNamePath string
|
PendingSharedVaultNamePath string
|
||||||
|
PendingSharedLookupPath string
|
||||||
}
|
}
|
||||||
|
|
||||||
type recentVaultRecord struct {
|
type recentVaultRecord struct {
|
||||||
@@ -380,6 +382,7 @@ type ui struct {
|
|||||||
settingsHistory widget.Bool
|
settingsHistory widget.Bool
|
||||||
settingsDenseLayout widget.Bool
|
settingsDenseLayout widget.Bool
|
||||||
settingsDebugHeaderBounds widget.Bool
|
settingsDebugHeaderBounds widget.Bool
|
||||||
|
settingsAutoSaveRemote widget.Bool
|
||||||
entryClicks []widget.Clickable
|
entryClicks []widget.Clickable
|
||||||
apiTokenClicks []widget.Clickable
|
apiTokenClicks []widget.Clickable
|
||||||
apiPolicyEdits []widget.Clickable
|
apiPolicyEdits []widget.Clickable
|
||||||
@@ -472,9 +475,12 @@ type ui struct {
|
|||||||
autofillCachePath string
|
autofillCachePath string
|
||||||
pendingSharedVaultPath string
|
pendingSharedVaultPath string
|
||||||
pendingSharedVaultNamePath string
|
pendingSharedVaultNamePath string
|
||||||
|
pendingSharedLookupPath string
|
||||||
|
pendingSharedLookupQuery string
|
||||||
editingEntry bool
|
editingEntry bool
|
||||||
syncDefaultSourceMode syncSourceMode
|
syncDefaultSourceMode syncSourceMode
|
||||||
syncDefaultDirection syncDirection
|
syncDefaultDirection syncDirection
|
||||||
|
autoSaveRemote bool
|
||||||
groupControlsHidden bool
|
groupControlsHidden bool
|
||||||
lifecycleAdvancedHidden bool
|
lifecycleAdvancedHidden bool
|
||||||
historyHidden bool
|
historyHidden bool
|
||||||
@@ -653,6 +659,7 @@ func newUIWithState(mode string, sess appstate.CurrentSession, paths statePaths)
|
|||||||
autofillCachePath: paths.AutofillCachePath,
|
autofillCachePath: paths.AutofillCachePath,
|
||||||
pendingSharedVaultPath: paths.PendingSharedVaultPath,
|
pendingSharedVaultPath: paths.PendingSharedVaultPath,
|
||||||
pendingSharedVaultNamePath: paths.PendingSharedVaultNamePath,
|
pendingSharedVaultNamePath: paths.PendingSharedVaultNamePath,
|
||||||
|
pendingSharedLookupPath: paths.PendingSharedLookupPath,
|
||||||
recentVaultGroups: map[string][]string{},
|
recentVaultGroups: map[string][]string{},
|
||||||
recentVaultUsedAt: map[string]time.Time{},
|
recentVaultUsedAt: map[string]time.Time{},
|
||||||
lifecycleAdvancedHidden: true,
|
lifecycleAdvancedHidden: true,
|
||||||
@@ -665,6 +672,7 @@ func newUIWithState(mode string, sess appstate.CurrentSession, paths statePaths)
|
|||||||
syncDirection: syncDirectionPull,
|
syncDirection: syncDirectionPull,
|
||||||
syncDefaultSourceMode: syncSourceLocal,
|
syncDefaultSourceMode: syncSourceLocal,
|
||||||
syncDefaultDirection: syncDirectionPull,
|
syncDefaultDirection: syncDirectionPull,
|
||||||
|
autoSaveRemote: false,
|
||||||
apiPolicyGroupScope: true,
|
apiPolicyGroupScope: true,
|
||||||
autofillNoticePreference: autofillNoticeAll,
|
autofillNoticePreference: autofillNoticeAll,
|
||||||
vaultSharer: platform.NewVaultSharer(runtime.GOOS),
|
vaultSharer: platform.NewVaultSharer(runtime.GOOS),
|
||||||
@@ -678,6 +686,7 @@ func newUIWithState(mode string, sess appstate.CurrentSession, paths statePaths)
|
|||||||
u.apiPolicyAllow.Value = true
|
u.apiPolicyAllow.Value = true
|
||||||
u.apiPolicyGroupScopeW.Value = true
|
u.apiPolicyGroupScopeW.Value = true
|
||||||
u.state.Session = sess
|
u.state.Session = sess
|
||||||
|
u.state.AutoSaveRemote = u.autoSaveRemote
|
||||||
u.phoneSplit.Value = 0.46
|
u.phoneSplit.Value = 0.46
|
||||||
u.eyeIcon, _ = widget.NewIcon(icons.ActionVisibility)
|
u.eyeIcon, _ = widget.NewIcon(icons.ActionVisibility)
|
||||||
u.eyeOffIcon, _ = widget.NewIcon(icons.ActionVisibilityOff)
|
u.eyeOffIcon, _ = widget.NewIcon(icons.ActionVisibilityOff)
|
||||||
@@ -699,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.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.consumePendingSharedVaultImport()
|
||||||
|
u.consumePendingSharedLookup()
|
||||||
u.restoreStartupLifecycleTarget()
|
u.restoreStartupLifecycleTarget()
|
||||||
u.requestMasterPassFocus = u.hasSelectedLifecycleTarget()
|
u.requestMasterPassFocus = u.hasSelectedLifecycleTarget()
|
||||||
u.loadUIPreferences()
|
u.loadUIPreferences()
|
||||||
@@ -780,6 +790,7 @@ func defaultStatePaths(stateDir string) statePaths {
|
|||||||
AutofillCachePath: filepath.Join(baseDir, "autofill-cache.json"),
|
AutofillCachePath: filepath.Join(baseDir, "autofill-cache.json"),
|
||||||
PendingSharedVaultPath: filepath.Join(baseDir, "pending-shared-vault.kdbx"),
|
PendingSharedVaultPath: filepath.Join(baseDir, "pending-shared-vault.kdbx"),
|
||||||
PendingSharedVaultNamePath: filepath.Join(baseDir, "pending-shared-vault-name.txt"),
|
PendingSharedVaultNamePath: filepath.Join(baseDir, "pending-shared-vault-name.txt"),
|
||||||
|
PendingSharedLookupPath: filepath.Join(baseDir, "pending-shared-lookup.txt"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1211,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)
|
return syncDialogSummaryCard(gtx, u.theme, syncDialogPurposeAdvanced, u.settingsDraft.Sync.SourceDefault, u.settingsDraft.Sync.DirectionDefault)
|
||||||
},
|
},
|
||||||
layout.Spacer{Height: unit.Dp(8)}.Layout,
|
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 {
|
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 := 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
|
lbl.Color = mutedColor
|
||||||
@@ -1496,7 +1518,7 @@ func approvalResourceText(request apiapproval.Request) string {
|
|||||||
}
|
}
|
||||||
case apitokens.ResourceGroup:
|
case apitokens.ResourceGroup:
|
||||||
if len(request.Resource.Path) > 0 {
|
if len(request.Resource.Path) > 0 {
|
||||||
return strings.Join(request.Resource.Path, " / ")
|
return apiui.FormatResourcePath(request.Resource.Path)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return "Vault root"
|
return "Vault root"
|
||||||
@@ -2856,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 {
|
return layout.Inset{Bottom: unit.Dp(6)}.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
|
||||||
for u.groupClicks[idx].Clicked(gtx) {
|
for u.groupClicks[idx].Clicked(gtx) {
|
||||||
u.state.EnterGroup(name)
|
u.state.EnterGroup(name)
|
||||||
u.currentPath = append([]string(nil), u.state.CurrentPath...)
|
u.adoptStateCurrentPath()
|
||||||
u.filter()
|
u.filter()
|
||||||
}
|
}
|
||||||
return tonedButton(gtx, u.theme, &u.groupClicks[idx], name)
|
return tonedButton(gtx, u.theme, &u.groupClicks[idx], name)
|
||||||
@@ -2887,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 {
|
return layout.Inset{Bottom: unit.Dp(6)}.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
|
||||||
for u.groupClicks[idx].Clicked(gtx) {
|
for u.groupClicks[idx].Clicked(gtx) {
|
||||||
u.state.EnterGroup(name)
|
u.state.EnterGroup(name)
|
||||||
u.currentPath = append([]string(nil), u.state.CurrentPath...)
|
u.adoptStateCurrentPath()
|
||||||
u.filter()
|
u.filter()
|
||||||
}
|
}
|
||||||
return tonedButton(gtx, u.theme, &u.groupClicks[idx], name)
|
return tonedButton(gtx, u.theme, &u.groupClicks[idx], name)
|
||||||
|
|||||||
@@ -275,8 +275,7 @@ func (u *ui) deleteCurrentGroupAction() error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
u.clearDeleteGroupConfirmation()
|
u.clearDeleteGroupConfirmation()
|
||||||
u.currentPath = append([]string(nil), u.state.CurrentPath...)
|
u.adoptStateCurrentPath()
|
||||||
u.syncedPath = append([]string(nil), u.state.CurrentPath...)
|
|
||||||
u.filter()
|
u.filter()
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
+80
-7
@@ -23,6 +23,8 @@ import (
|
|||||||
detaillayout "git.julianfamily.org/keepassgo/internal/appui/detail/layout"
|
detaillayout "git.julianfamily.org/keepassgo/internal/appui/detail/layout"
|
||||||
"git.julianfamily.org/keepassgo/internal/clipboard"
|
"git.julianfamily.org/keepassgo/internal/clipboard"
|
||||||
"git.julianfamily.org/keepassgo/internal/session"
|
"git.julianfamily.org/keepassgo/internal/session"
|
||||||
|
"git.julianfamily.org/keepassgo/internal/vault"
|
||||||
|
"git.julianfamily.org/keepassgo/internal/vaultview"
|
||||||
)
|
)
|
||||||
|
|
||||||
func (u *ui) bannerSurface() uiBanner {
|
func (u *ui) bannerSurface() uiBanner {
|
||||||
@@ -552,10 +554,80 @@ func (u *ui) setCurrentPath(path []string) {
|
|||||||
u.clearDeleteGroupConfirmation()
|
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() {
|
func (u *ui) syncCurrentPath() {
|
||||||
switch {
|
switch {
|
||||||
case slices.Equal(u.currentPath, u.syncedPath) && !slices.Equal(u.state.CurrentPath, u.syncedPath):
|
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):
|
case !slices.Equal(u.currentPath, u.syncedPath) && slices.Equal(u.state.CurrentPath, u.syncedPath):
|
||||||
u.state.CurrentPath = append([]string(nil), u.currentPath...)
|
u.state.CurrentPath = append([]string(nil), u.currentPath...)
|
||||||
case !slices.Equal(u.currentPath, u.syncedPath) && !slices.Equal(u.state.CurrentPath, u.syncedPath):
|
case !slices.Equal(u.currentPath, u.syncedPath) && !slices.Equal(u.state.CurrentPath, u.syncedPath):
|
||||||
@@ -1007,14 +1079,16 @@ func (u *ui) handleAPIPolicyClicks(gtx layout.Context) {
|
|||||||
for u.clearAPIPolicyTarget.Clicked(gtx) {
|
for u.clearAPIPolicyTarget.Clicked(gtx) {
|
||||||
u.runAction("clear API policy target", u.clearAPIPolicyTargetAction)
|
u.runAction("clear API policy target", u.clearAPIPolicyTargetAction)
|
||||||
}
|
}
|
||||||
for i := range u.apiPolicyEdits {
|
editClicks := u.apiPolicyEdits
|
||||||
for u.apiPolicyEdits[i].Clicked(gtx) {
|
for i := range editClicks {
|
||||||
|
for editClicks[i].Clicked(gtx) {
|
||||||
index := i
|
index := i
|
||||||
u.runAction("edit API policy rule", func() error { return u.editAPIPolicyRuleAction(index) })
|
u.runAction("edit API policy rule", func() error { return u.editAPIPolicyRuleAction(index) })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for i := range u.apiPolicyRemoves {
|
removeClicks := u.apiPolicyRemoves
|
||||||
for u.apiPolicyRemoves[i].Clicked(gtx) {
|
for i := range removeClicks {
|
||||||
|
for removeClicks[i].Clicked(gtx) {
|
||||||
index := i
|
index := i
|
||||||
u.runAction("remove API policy rule", func() error { return u.removeAPIPolicyRuleAction(index) })
|
u.runAction("remove API policy rule", func() error { return u.removeAPIPolicyRuleAction(index) })
|
||||||
}
|
}
|
||||||
@@ -1215,8 +1289,7 @@ func (u *ui) handleGroupClicks(gtx layout.Context) {
|
|||||||
for u.moveGroup.Clicked(gtx) {
|
for u.moveGroup.Clicked(gtx) {
|
||||||
u.clearDeleteGroupConfirmation()
|
u.clearDeleteGroupConfirmation()
|
||||||
u.runAction("move group", u.moveCurrentGroupAction)
|
u.runAction("move group", u.moveCurrentGroupAction)
|
||||||
u.currentPath = append([]string(nil), u.state.CurrentPath...)
|
u.adoptStateCurrentPath()
|
||||||
u.syncedPath = append([]string(nil), u.state.CurrentPath...)
|
|
||||||
u.filter()
|
u.filter()
|
||||||
}
|
}
|
||||||
for u.toggleGroupControls.Clicked(gtx) {
|
for u.toggleGroupControls.Clicked(gtx) {
|
||||||
|
|||||||
@@ -4,8 +4,10 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
"runtime"
|
"runtime"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
@@ -17,6 +19,8 @@ import (
|
|||||||
"git.julianfamily.org/keepassgo/internal/webdav"
|
"git.julianfamily.org/keepassgo/internal/webdav"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
var pendingSharedLookupURLPattern = regexp.MustCompile(`https?://[^\s<>"']+`)
|
||||||
|
|
||||||
func (u *ui) createVaultAction() error {
|
func (u *ui) createVaultAction() error {
|
||||||
key, err := u.currentMasterKey()
|
key, err := u.currentMasterKey()
|
||||||
defer u.clearMasterPassword()
|
defer u.clearMasterPassword()
|
||||||
@@ -47,7 +51,7 @@ func (u *ui) createVaultAction() error {
|
|||||||
u.noteRecentVault(u.saveAsTargetPath())
|
u.noteRecentVault(u.saveAsTargetPath())
|
||||||
}
|
}
|
||||||
u.resetPasswordPeek()
|
u.resetPasswordPeek()
|
||||||
u.currentPath = append([]string(nil), u.state.CurrentPath...)
|
u.adoptStateCurrentPath()
|
||||||
u.loadSecuritySettingsFromSession()
|
u.loadSecuritySettingsFromSession()
|
||||||
u.editingEntry = false
|
u.editingEntry = false
|
||||||
u.filter()
|
u.filter()
|
||||||
@@ -69,7 +73,7 @@ func (u *ui) openVaultAction() error {
|
|||||||
}
|
}
|
||||||
u.noteRecentVault(path)
|
u.noteRecentVault(path)
|
||||||
u.resetPasswordPeek()
|
u.resetPasswordPeek()
|
||||||
u.currentPath = append([]string(nil), u.state.CurrentPath...)
|
u.adoptStateCurrentPath()
|
||||||
u.restoreRecentVaultGroup(path)
|
u.restoreRecentVaultGroup(path)
|
||||||
u.syncSavedRemoteBindingSelection()
|
u.syncSavedRemoteBindingSelection()
|
||||||
if err := u.synchronizeSelectedRemoteBindingOnOpen(); err != nil {
|
if err := u.synchronizeSelectedRemoteBindingOnOpen(); err != nil {
|
||||||
@@ -78,6 +82,7 @@ func (u *ui) openVaultAction() error {
|
|||||||
u.loadSecuritySettingsFromSession()
|
u.loadSecuritySettingsFromSession()
|
||||||
u.editingEntry = false
|
u.editingEntry = false
|
||||||
u.filter()
|
u.filter()
|
||||||
|
u.applyPendingSharedLookup()
|
||||||
u.applyPendingLifecycleOpenIntent()
|
u.applyPendingLifecycleOpenIntent()
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -111,7 +116,7 @@ func (u *ui) startOpenVaultAction() {
|
|||||||
manager.ApplyPreparedLocalOpen(prepared)
|
manager.ApplyPreparedLocalOpen(prepared)
|
||||||
u.noteRecentVault(path)
|
u.noteRecentVault(path)
|
||||||
u.resetPasswordPeek()
|
u.resetPasswordPeek()
|
||||||
u.currentPath = append([]string(nil), u.state.CurrentPath...)
|
u.adoptStateCurrentPath()
|
||||||
u.restoreRecentVaultGroup(path)
|
u.restoreRecentVaultGroup(path)
|
||||||
u.syncSavedRemoteBindingSelection()
|
u.syncSavedRemoteBindingSelection()
|
||||||
if err := u.synchronizeSelectedRemoteBindingOnOpen(); err != nil {
|
if err := u.synchronizeSelectedRemoteBindingOnOpen(); err != nil {
|
||||||
@@ -120,6 +125,7 @@ func (u *ui) startOpenVaultAction() {
|
|||||||
u.loadSecuritySettingsFromSession()
|
u.loadSecuritySettingsFromSession()
|
||||||
u.editingEntry = false
|
u.editingEntry = false
|
||||||
u.filter()
|
u.filter()
|
||||||
|
u.applyPendingSharedLookup()
|
||||||
u.applyPendingLifecycleOpenIntent()
|
u.applyPendingLifecycleOpenIntent()
|
||||||
return nil
|
return nil
|
||||||
}, nil
|
}, nil
|
||||||
@@ -329,7 +335,7 @@ func (u *ui) lockAction() error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
u.requestMasterPassFocus = true
|
u.requestMasterPassFocus = true
|
||||||
u.currentPath = append([]string(nil), u.state.CurrentPath...)
|
u.adoptStateCurrentPath()
|
||||||
u.resetPasswordPeek()
|
u.resetPasswordPeek()
|
||||||
u.editingEntry = false
|
u.editingEntry = false
|
||||||
u.filter()
|
u.filter()
|
||||||
@@ -346,7 +352,7 @@ func (u *ui) unlockAction() error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
u.resetPasswordPeek()
|
u.resetPasswordPeek()
|
||||||
u.currentPath = append([]string(nil), u.state.CurrentPath...)
|
u.adoptStateCurrentPath()
|
||||||
u.loadSecuritySettingsFromSession()
|
u.loadSecuritySettingsFromSession()
|
||||||
u.editingEntry = false
|
u.editingEntry = false
|
||||||
u.filter()
|
u.filter()
|
||||||
@@ -375,7 +381,7 @@ func (u *ui) startUnlockAction() {
|
|||||||
return func() error {
|
return func() error {
|
||||||
manager.ApplyPreparedUnlock(prepared)
|
manager.ApplyPreparedUnlock(prepared)
|
||||||
u.resetPasswordPeek()
|
u.resetPasswordPeek()
|
||||||
u.currentPath = append([]string(nil), u.state.CurrentPath...)
|
u.adoptStateCurrentPath()
|
||||||
u.loadSecuritySettingsFromSession()
|
u.loadSecuritySettingsFromSession()
|
||||||
u.editingEntry = false
|
u.editingEntry = false
|
||||||
u.filter()
|
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 {
|
func (u *ui) importSharedVaultBytesAction(name string, content []byte) error {
|
||||||
target := u.importedVaultDestination(name)
|
target := u.importedVaultDestination(name)
|
||||||
if err := os.MkdirAll(filepath.Dir(target), 0o700); err != nil {
|
if err := os.MkdirAll(filepath.Dir(target), 0o700); err != nil {
|
||||||
|
|||||||
+354
-28
@@ -861,6 +861,72 @@ func TestUIAPITokenPolicyRulesCanBeCreatedUpdatedAndRemoved(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) {
|
func TestAPITokenStatusSummary(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -2375,8 +2441,8 @@ func TestUIOpenRemoteActionBootstrapsFromLocalVaultBinding(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Session.Current() error = %v", err)
|
t.Fatalf("Session.Current() error = %v", err)
|
||||||
}
|
}
|
||||||
if got := current.EntriesInPath([]string{"Root", "Internet"}); len(got) != 1 || got[0].Title != "Vault Console" {
|
if got := current.EntriesInPath([]string{"keepass", "Internet"}); len(got) != 1 || got[0].Title != "Vault Console" {
|
||||||
t.Fatalf("EntriesInPath(Root/Internet) = %#v, want Vault Console", got)
|
t.Fatalf("EntriesInPath(keepass/Internet) = %#v, want Vault Console", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2609,8 +2675,8 @@ func TestUIStartOpenRemoteActionBootstrapsFromLocalVaultBinding(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Session.Current() error = %v", err)
|
t.Fatalf("Session.Current() error = %v", err)
|
||||||
}
|
}
|
||||||
if got := current.EntriesInPath([]string{"Root", "Internet"}); len(got) != 1 || got[0].Title != "Vault Console" {
|
if got := current.EntriesInPath([]string{"keepass", "Internet"}); len(got) != 1 || got[0].Title != "Vault Console" {
|
||||||
t.Fatalf("EntriesInPath(Root/Internet) = %#v, want Vault Console", got)
|
t.Fatalf("EntriesInPath(keepass/Internet) = %#v, want Vault Console", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3114,8 +3180,8 @@ func TestUIAdvancedSynchronizeFromLocalMergesIntoCurrentVault(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("reopened Current() error = %v", err)
|
t.Fatalf("reopened Current() error = %v", err)
|
||||||
}
|
}
|
||||||
if got := len(model.EntriesInPath([]string{"Root", "Internet"})); got != 2 {
|
if got := len(model.EntriesInPath([]string{"keepass", "Internet"})); got != 2 {
|
||||||
t.Fatalf("len(EntriesInPath(Root/Internet)) = %d, want 2", got)
|
t.Fatalf("len(EntriesInPath(keepass/Internet)) = %d, want 2", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3175,8 +3241,8 @@ func TestUIAdvancedSynchronizeFromImportedLocalVaultMergesIntoCurrentVault(t *te
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("reopened Current() error = %v", err)
|
t.Fatalf("reopened Current() error = %v", err)
|
||||||
}
|
}
|
||||||
if got := len(model.EntriesInPath([]string{"Root", "Internet"})); got != 2 {
|
if got := len(model.EntriesInPath([]string{"keepass", "Internet"})); got != 2 {
|
||||||
t.Fatalf("len(EntriesInPath(Root/Internet)) = %d, want 2", got)
|
t.Fatalf("len(EntriesInPath(keepass/Internet)) = %d, want 2", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3340,8 +3406,8 @@ func TestUIAdvancedSynchronizeToRemoteWritesMergedVaultToTarget(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("reopened Current() error = %v", err)
|
t.Fatalf("reopened Current() error = %v", err)
|
||||||
}
|
}
|
||||||
if got := len(model.EntriesInPath([]string{"Root", "Internet"})); got != 2 {
|
if got := len(model.EntriesInPath([]string{"keepass", "Internet"})); got != 2 {
|
||||||
t.Fatalf("len(EntriesInPath(Root/Internet)) = %d, want 2", got)
|
t.Fatalf("len(EntriesInPath(keepass/Internet)) = %d, want 2", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3540,11 +3606,11 @@ func TestUICreateGroupActionSupportsNestedSubgroups(t *testing.T) {
|
|||||||
t.Fatalf("createGroupAction() error = %v", err)
|
t.Fatalf("createGroupAction() error = %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if got := u.state.Session.(*uiSession).model.ChildGroups([]string{"Root"}); !slices.Equal(got, []string{"Infrastructure"}) {
|
if got := u.state.Session.(*uiSession).model.ChildGroups([]string{"keepass"}); !slices.Equal(got, []string{"Infrastructure"}) {
|
||||||
t.Fatalf("ChildGroups(Root) = %v, want [Infrastructure]", got)
|
t.Fatalf("ChildGroups(keepass) = %v, want [Infrastructure]", got)
|
||||||
}
|
}
|
||||||
if got := u.state.Session.(*uiSession).model.ChildGroups([]string{"Root", "Infrastructure"}); !slices.Equal(got, []string{"Prod"}) {
|
if got := u.state.Session.(*uiSession).model.ChildGroups([]string{"keepass", "Infrastructure"}); !slices.Equal(got, []string{"Prod"}) {
|
||||||
t.Fatalf("ChildGroups(Root/Infrastructure) = %v, want [Prod]", got)
|
t.Fatalf("ChildGroups(keepass/Infrastructure) = %v, want [Prod]", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -5059,8 +5125,68 @@ func TestUIAutoEntersSingleVaultRootGroupAndDisplaysSlashRoot(t *testing.T) {
|
|||||||
t.Fatalf("openVaultAction() error = %v", err)
|
t.Fatalf("openVaultAction() error = %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if got := u.currentPath; !slices.Equal(got, []string{"keepass"}) {
|
if got := u.currentPath; !slices.Equal(got, []string{"Root"}) {
|
||||||
t.Fatalf("currentPath = %v, want [keepass]", got)
|
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 {
|
if got := u.displayPath(); len(got) != 0 {
|
||||||
t.Fatalf("displayPath() = %v, want root slash path", got)
|
t.Fatalf("displayPath() = %v, want root slash path", got)
|
||||||
@@ -5081,15 +5207,15 @@ func TestUIShowEntriesSectionRestoresHiddenRootAfterLeavingEntries(t *testing.T)
|
|||||||
})
|
})
|
||||||
|
|
||||||
u.showEntriesSection()
|
u.showEntriesSection()
|
||||||
if got := u.currentPath; !slices.Equal(got, []string{"keepass"}) {
|
if got := u.currentPath; !slices.Equal(got, []string{"Root"}) {
|
||||||
t.Fatalf("currentPath after initial entries section = %v, want [keepass]", got)
|
t.Fatalf("currentPath after initial entries section = %v, want [Root]", got)
|
||||||
}
|
}
|
||||||
|
|
||||||
u.showAPITokensSection()
|
u.showAPITokensSection()
|
||||||
u.showEntriesSection()
|
u.showEntriesSection()
|
||||||
|
|
||||||
if got := u.currentPath; !slices.Equal(got, []string{"keepass"}) {
|
if got := u.currentPath; !slices.Equal(got, []string{"Root"}) {
|
||||||
t.Fatalf("currentPath after returning to entries = %v, want [keepass]", got)
|
t.Fatalf("currentPath after returning to entries = %v, want [Root]", got)
|
||||||
}
|
}
|
||||||
if got := u.displayPath(); len(got) != 0 {
|
if got := u.displayPath(); len(got) != 0 {
|
||||||
t.Fatalf("displayPath() after returning to entries = %v, want root slash path", got)
|
t.Fatalf("displayPath() after returning to entries = %v, want root slash path", got)
|
||||||
@@ -5099,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) {
|
func TestUIShowEntriesSectionRestoresEntriesViewState(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -5111,7 +5268,7 @@ func TestUIShowEntriesSectionRestoresEntriesViewState(t *testing.T) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
u.showEntriesSection()
|
u.showEntriesSection()
|
||||||
u.setCurrentPath([]string{"keepass", "Crew", "Internet"})
|
u.setCurrentPath([]string{"Root", "Crew", "Internet"})
|
||||||
u.search.SetText("amazon")
|
u.search.SetText("amazon")
|
||||||
u.filter()
|
u.filter()
|
||||||
u.state.SelectedEntryID = "amazon"
|
u.state.SelectedEntryID = "amazon"
|
||||||
@@ -5121,8 +5278,8 @@ func TestUIShowEntriesSectionRestoresEntriesViewState(t *testing.T) {
|
|||||||
u.showAPITokensSection()
|
u.showAPITokensSection()
|
||||||
u.showEntriesSection()
|
u.showEntriesSection()
|
||||||
|
|
||||||
if got := u.currentPath; !slices.Equal(got, []string{"keepass", "Crew", "Internet"}) {
|
if got := u.currentPath; !slices.Equal(got, []string{"Root", "Crew", "Internet"}) {
|
||||||
t.Fatalf("currentPath after returning to entries = %v, want [keepass Crew Internet]", got)
|
t.Fatalf("currentPath after returning to entries = %v, want [Root Crew Internet]", got)
|
||||||
}
|
}
|
||||||
if got := u.search.Text(); got != "amazon" {
|
if got := u.search.Text(); got != "amazon" {
|
||||||
t.Fatalf("search text after returning to entries = %q, want amazon", got)
|
t.Fatalf("search text after returning to entries = %q, want amazon", got)
|
||||||
@@ -5618,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) {
|
func TestUIDebugHeaderBoundsPersistInSettings(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -5714,6 +5898,7 @@ func TestUISaveSecuritySettingsPersistsSyncDefaults(t *testing.T) {
|
|||||||
u.loadSettingsDraft()
|
u.loadSettingsDraft()
|
||||||
u.settingsDraft.Sync.SourceDefault = syncSourceRemote
|
u.settingsDraft.Sync.SourceDefault = syncSourceRemote
|
||||||
u.settingsDraft.Sync.DirectionDefault = syncDirectionPush
|
u.settingsDraft.Sync.DirectionDefault = syncDirectionPush
|
||||||
|
u.settingsAutoSaveRemote.Value = true
|
||||||
|
|
||||||
if err := u.saveSecuritySettingsAction(); err != nil {
|
if err := u.saveSecuritySettingsAction(); err != nil {
|
||||||
t.Fatalf("saveSecuritySettingsAction() error = %v", err)
|
t.Fatalf("saveSecuritySettingsAction() error = %v", err)
|
||||||
@@ -5730,6 +5915,12 @@ func TestUISaveSecuritySettingsPersistsSyncDefaults(t *testing.T) {
|
|||||||
if got := reloaded.syncDefaultDirection; got != syncDirectionPush {
|
if got := reloaded.syncDefaultDirection; got != syncDirectionPush {
|
||||||
t.Fatalf("reloaded syncDefaultDirection = %q, want push", got)
|
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) {
|
func TestUISaveSecuritySettingsPersistsDebugHeaderBounds(t *testing.T) {
|
||||||
@@ -7915,7 +8106,7 @@ func TestUISelectedRemoteCardUsesLocalCacheSummaryForBoundRemote(t *testing.T) {
|
|||||||
wantDetails := []string{
|
wantDetails := []string{
|
||||||
"/vaults/cache",
|
"/vaults/cache",
|
||||||
"Sync target: home.kdbx · dav.example.invalid",
|
"Sync target: home.kdbx · dav.example.invalid",
|
||||||
"Last group: Root / Internet",
|
"Last group: Internet",
|
||||||
}
|
}
|
||||||
if !slices.Equal(gotDetails, wantDetails) {
|
if !slices.Equal(gotDetails, wantDetails) {
|
||||||
t.Fatalf("selectedRemoteCardDetailLines() = %v, want %v", gotDetails, wantDetails)
|
t.Fatalf("selectedRemoteCardDetailLines() = %v, want %v", gotDetails, wantDetails)
|
||||||
@@ -7947,7 +8138,7 @@ func TestUISelectedRemoteCardUsesConnectionSummaryWithoutLocalCache(t *testing.T
|
|||||||
wantDetails := []string{
|
wantDetails := []string{
|
||||||
"Path: vaults/home.kdbx",
|
"Path: vaults/home.kdbx",
|
||||||
"Server: https://dav.example.invalid",
|
"Server: https://dav.example.invalid",
|
||||||
"Last group: Root / Internet",
|
"Last group: Internet",
|
||||||
}
|
}
|
||||||
if !slices.Equal(gotDetails, wantDetails) {
|
if !slices.Equal(gotDetails, wantDetails) {
|
||||||
t.Fatalf("selectedRemoteCardDetailLines() = %v, want %v", gotDetails, wantDetails)
|
t.Fatalf("selectedRemoteCardDetailLines() = %v, want %v", gotDetails, wantDetails)
|
||||||
@@ -8199,6 +8390,9 @@ func TestDefaultStatePathsUsesProvidedStateDir(t *testing.T) {
|
|||||||
if got := paths.PendingSharedVaultNamePath; got != filepath.Join(base, "pending-shared-vault-name.txt") {
|
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"))
|
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) {
|
func TestImportedVaultDestinationUsesIncomingFilenameInsideDefaultDirectory(t *testing.T) {
|
||||||
@@ -8322,13 +8516,102 @@ func TestUIConsumesPendingSharedVaultImportOnStartup(t *testing.T) {
|
|||||||
if err := reopened.openVaultAction(); err != nil {
|
if err := reopened.openVaultAction(); err != nil {
|
||||||
t.Fatalf("openVaultAction(imported) error = %v", err)
|
t.Fatalf("openVaultAction(imported) error = %v", err)
|
||||||
}
|
}
|
||||||
reopened.state.NavigateToPath([]string{"Crew", "Internet"})
|
reopened.state.NavigateToPath([]string{"Root", "Crew", "Internet"})
|
||||||
reopened.filter()
|
reopened.filter()
|
||||||
if got := reopened.filteredTitles(); !slices.Equal(got, []string{"Bellagio"}) {
|
if got := reopened.filteredTitles(); !slices.Equal(got, []string{"Bellagio"}) {
|
||||||
t.Fatalf("filteredTitles() = %v, want [Bellagio]", got)
|
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) {
|
func TestUICurrentShareableVaultPathUsesSelectedVaultPath(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -9169,8 +9452,8 @@ func TestUIAPIPolicyTargetActionsUseCurrentContext(t *testing.T) {
|
|||||||
if err := u.useCurrentGroupForPolicyAction(); err != nil {
|
if err := u.useCurrentGroupForPolicyAction(); err != nil {
|
||||||
t.Fatalf("useCurrentGroupForPolicyAction() error = %v", err)
|
t.Fatalf("useCurrentGroupForPolicyAction() error = %v", err)
|
||||||
}
|
}
|
||||||
if got := u.apiPolicyPath.Text(); got != "bashertarr" {
|
if got := u.apiPolicyPath.Text(); got != "Crew / bashertarr" {
|
||||||
t.Fatalf("apiPolicyPath.Text() = %q, want %q", got, "bashertarr")
|
t.Fatalf("apiPolicyPath.Text() = %q, want %q", got, "Crew / bashertarr")
|
||||||
}
|
}
|
||||||
if !u.apiPolicyGroupScopeW.Value {
|
if !u.apiPolicyGroupScopeW.Value {
|
||||||
t.Fatal("apiPolicyGroupScopeW.Value = false, want true")
|
t.Fatal("apiPolicyGroupScopeW.Value = false, want true")
|
||||||
@@ -9197,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) {
|
func TestUIVisibleBreadcrumbsCompressesAggressivelyOnPhone(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import (
|
|||||||
"git.julianfamily.org/keepassgo/internal/autofillcache"
|
"git.julianfamily.org/keepassgo/internal/autofillcache"
|
||||||
"git.julianfamily.org/keepassgo/internal/session"
|
"git.julianfamily.org/keepassgo/internal/session"
|
||||||
"git.julianfamily.org/keepassgo/internal/vault"
|
"git.julianfamily.org/keepassgo/internal/vault"
|
||||||
|
"git.julianfamily.org/keepassgo/internal/vaultview"
|
||||||
"git.julianfamily.org/keepassgo/internal/webdav"
|
"git.julianfamily.org/keepassgo/internal/webdav"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -1259,21 +1260,10 @@ func (u *ui) recentVaultGroup(path string) []string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (u *ui) hiddenVaultRoot() string {
|
func (u *ui) hiddenVaultRoot() string {
|
||||||
if u.state.Section != appstate.SectionEntries {
|
if u.state.Section == appstate.SectionEntries {
|
||||||
return ""
|
return "Root"
|
||||||
}
|
}
|
||||||
model, err := u.state.Session.Current()
|
|
||||||
if err != nil {
|
|
||||||
return ""
|
return ""
|
||||||
}
|
|
||||||
if len(model.EntriesInPath(nil)) != 0 {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
groups := model.ChildGroups(nil)
|
|
||||||
if len(groups) != 1 {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
return groups[0]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (u *ui) enterHiddenVaultRoot() {
|
func (u *ui) enterHiddenVaultRoot() {
|
||||||
@@ -1300,7 +1290,7 @@ func (u *ui) restoreRecentVaultGroup(path string) {
|
|||||||
u.setCurrentPath(saved)
|
u.setCurrentPath(saved)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if len(model.EntriesInPath(saved)) > 0 || len(model.ChildGroups(saved)) > 0 || hasExactGroup(model, saved) {
|
if pathExistsInModel(model, saved) {
|
||||||
u.setCurrentPath(saved)
|
u.setCurrentPath(saved)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -1323,7 +1313,7 @@ func (u *ui) restoreRecentRemoteGroup(baseURL, path string) {
|
|||||||
u.setCurrentPath(saved)
|
u.setCurrentPath(saved)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if len(model.EntriesInPath(saved)) > 0 || len(model.ChildGroups(saved)) > 0 || hasExactGroup(model, saved) {
|
if pathExistsInModel(model, saved) {
|
||||||
u.setCurrentPath(saved)
|
u.setCurrentPath(saved)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -1345,7 +1335,7 @@ func (u *ui) restoreEntriesPath(path []string) {
|
|||||||
u.setCurrentPath(path)
|
u.setCurrentPath(path)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if len(model.EntriesInPath(path)) > 0 || len(model.ChildGroups(path)) > 0 || hasExactGroup(model, path) {
|
if pathExistsInModel(model, path) {
|
||||||
u.setCurrentPath(path)
|
u.setCurrentPath(path)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -1421,6 +1411,22 @@ func pathHasPrefix(path, prefix []string) bool {
|
|||||||
return slices.Equal(path[:len(prefix)], prefix)
|
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 {
|
func hasExactGroup(model vault.Model, path []string) bool {
|
||||||
for _, group := range model.Groups {
|
for _, group := range model.Groups {
|
||||||
if slices.Equal(group, path) {
|
if slices.Equal(group, path) {
|
||||||
@@ -1439,12 +1445,14 @@ func (u *ui) currentGroupDeletionState() (bool, string) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return false, ""
|
return false, ""
|
||||||
}
|
}
|
||||||
path := append([]string(nil), u.currentPath...)
|
view := vaultview.VaultRoot(model)
|
||||||
if len(model.ChildGroups(path)) > 0 {
|
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."
|
return false, "This group contains child groups. Move or delete them before removing the group."
|
||||||
}
|
}
|
||||||
for _, item := range model.Entries {
|
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."
|
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."
|
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 {
|
func (u *ui) deleteGroupPendingConfirmation() bool {
|
||||||
return len(u.deleteGroupPath) > 0 && slices.Equal(u.deleteGroupPath, u.currentPath)
|
return len(u.deleteGroupPath) > 0 && slices.Equal(u.deleteGroupPath, u.currentPath)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import (
|
|||||||
"git.julianfamily.org/keepassgo/internal/apiapproval"
|
"git.julianfamily.org/keepassgo/internal/apiapproval"
|
||||||
"git.julianfamily.org/keepassgo/internal/apitokens"
|
"git.julianfamily.org/keepassgo/internal/apitokens"
|
||||||
"git.julianfamily.org/keepassgo/internal/appui/platform"
|
"git.julianfamily.org/keepassgo/internal/appui/platform"
|
||||||
|
"git.julianfamily.org/keepassgo/internal/browserbridge"
|
||||||
"git.julianfamily.org/keepassgo/internal/grpcaddr"
|
"git.julianfamily.org/keepassgo/internal/grpcaddr"
|
||||||
"git.julianfamily.org/keepassgo/internal/passwords"
|
"git.julianfamily.org/keepassgo/internal/passwords"
|
||||||
"git.julianfamily.org/keepassgo/internal/session"
|
"git.julianfamily.org/keepassgo/internal/session"
|
||||||
@@ -61,6 +62,7 @@ func defaultGRPCAddr(goos string) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func run(w *app.Window, mode string, paths statePaths, grpcAddr string) error {
|
func run(w *app.Window, mode string, paths statePaths, grpcAddr string) error {
|
||||||
|
ensureBrowserNativeHosts()
|
||||||
var ops op.Ops
|
var ops op.Ops
|
||||||
manager := &session.Manager{}
|
manager := &session.Manager{}
|
||||||
ui := newUIWithSession(mode, manager, paths)
|
ui := newUIWithSession(mode, manager, paths)
|
||||||
@@ -76,6 +78,10 @@ func run(w *app.Window, mode string, paths statePaths, grpcAddr string) error {
|
|||||||
ui.state.AuditLog = ui.auditLog
|
ui.state.AuditLog = ui.auditLog
|
||||||
ui.grpcAddress = host.Address()
|
ui.grpcAddress = host.Address()
|
||||||
ui.state.Approvals = &uiApprovalManager{server: host.Server()}
|
ui.state.Approvals = &uiApprovalManager{server: host.Server()}
|
||||||
|
host.Server().SetChangeNotifier(func() {
|
||||||
|
ui.state.Dirty = true
|
||||||
|
ui.invalidate()
|
||||||
|
})
|
||||||
host.Server().ApprovalBroker().SetChangeNotifier(ui.invalidate)
|
host.Server().ApprovalBroker().SetChangeNotifier(ui.invalidate)
|
||||||
defer func() { _ = host.Stop() }()
|
defer func() { _ = host.Stop() }()
|
||||||
}
|
}
|
||||||
@@ -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 {
|
type uiApprovalManager struct {
|
||||||
server *api.Server
|
server *api.Server
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ type settingsFile struct {
|
|||||||
type syncSettings struct {
|
type syncSettings struct {
|
||||||
SourceDefault string `json:"sourceDefault,omitempty"`
|
SourceDefault string `json:"sourceDefault,omitempty"`
|
||||||
DirectionDefault string `json:"directionDefault,omitempty"`
|
DirectionDefault string `json:"directionDefault,omitempty"`
|
||||||
|
AutoSaveRemote bool `json:"autoSaveRemote,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type debugSettings struct {
|
type debugSettings struct {
|
||||||
@@ -53,6 +54,7 @@ type debugSettings struct {
|
|||||||
type syncSettingsDraft struct {
|
type syncSettingsDraft struct {
|
||||||
SourceDefault syncSourceMode
|
SourceDefault syncSourceMode
|
||||||
DirectionDefault syncDirection
|
DirectionDefault syncDirection
|
||||||
|
AutoSaveRemote bool
|
||||||
}
|
}
|
||||||
|
|
||||||
type settingsDraft struct {
|
type settingsDraft struct {
|
||||||
@@ -198,12 +200,14 @@ func (u *ui) loadSettingsDraft() {
|
|||||||
Sync: syncSettingsDraft{
|
Sync: syncSettingsDraft{
|
||||||
SourceDefault: u.syncDefaultSourceMode,
|
SourceDefault: u.syncDefaultSourceMode,
|
||||||
DirectionDefault: u.syncDefaultDirection,
|
DirectionDefault: u.syncDefaultDirection,
|
||||||
|
AutoSaveRemote: u.autoSaveRemote,
|
||||||
},
|
},
|
||||||
Debug: debugSettings{
|
Debug: debugSettings{
|
||||||
LogHeaderBounds: u.debugLogHeaderBounds,
|
LogHeaderBounds: u.debugLogHeaderBounds,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
u.settingsDebugHeaderBounds.Value = u.settingsDraft.Debug.LogHeaderBounds
|
u.settingsDebugHeaderBounds.Value = u.settingsDraft.Debug.LogHeaderBounds
|
||||||
|
u.settingsAutoSaveRemote.Value = u.settingsDraft.Sync.AutoSaveRemote
|
||||||
}
|
}
|
||||||
|
|
||||||
func (u *ui) saveSecuritySettingsAction() error {
|
func (u *ui) saveSecuritySettingsAction() error {
|
||||||
@@ -226,9 +230,12 @@ func (u *ui) applySecuritySettingsLive() error {
|
|||||||
u.settingsDraft.Accessibility.DisplayDensity = displayDensityForDenseLayout(u.settingsDenseLayout.Value)
|
u.settingsDraft.Accessibility.DisplayDensity = displayDensityForDenseLayout(u.settingsDenseLayout.Value)
|
||||||
}
|
}
|
||||||
u.settingsDraft.Debug.LogHeaderBounds = u.settingsDebugHeaderBounds.Value
|
u.settingsDraft.Debug.LogHeaderBounds = u.settingsDebugHeaderBounds.Value
|
||||||
|
u.settingsDraft.Sync.AutoSaveRemote = u.settingsAutoSaveRemote.Value
|
||||||
u.settingsDenseLayout.Value = u.settingsDraft.Accessibility.DisplayDensity == displayDensityDense
|
u.settingsDenseLayout.Value = u.settingsDraft.Accessibility.DisplayDensity == displayDensityDense
|
||||||
u.syncDefaultSourceMode = sanitizeSyncSourceMode(u.settingsDraft.Sync.SourceDefault)
|
u.syncDefaultSourceMode = sanitizeSyncSourceMode(u.settingsDraft.Sync.SourceDefault)
|
||||||
u.syncDefaultDirection = sanitizeSyncDirection(u.settingsDraft.Sync.DirectionDefault)
|
u.syncDefaultDirection = sanitizeSyncDirection(u.settingsDraft.Sync.DirectionDefault)
|
||||||
|
u.autoSaveRemote = u.settingsDraft.Sync.AutoSaveRemote
|
||||||
|
u.state.AutoSaveRemote = u.autoSaveRemote
|
||||||
u.debugLogHeaderBounds = u.settingsDraft.Debug.LogHeaderBounds
|
u.debugLogHeaderBounds = u.settingsDraft.Debug.LogHeaderBounds
|
||||||
if !u.debugLogHeaderBounds {
|
if !u.debugLogHeaderBounds {
|
||||||
u.lastHeaderBoundsLog = ""
|
u.lastHeaderBoundsLog = ""
|
||||||
@@ -243,6 +250,7 @@ func (u *ui) applySecuritySettingsLive() error {
|
|||||||
func (u *ui) loadSettings() {
|
func (u *ui) loadSettings() {
|
||||||
u.syncDefaultSourceMode = syncSourceLocal
|
u.syncDefaultSourceMode = syncSourceLocal
|
||||||
u.syncDefaultDirection = syncDirectionPull
|
u.syncDefaultDirection = syncDirectionPull
|
||||||
|
u.autoSaveRemote = false
|
||||||
|
|
||||||
if strings.TrimSpace(u.settingsPath) != "" {
|
if strings.TrimSpace(u.settingsPath) != "" {
|
||||||
content, err := os.ReadFile(u.settingsPath)
|
content, err := os.ReadFile(u.settingsPath)
|
||||||
@@ -251,6 +259,8 @@ func (u *ui) loadSettings() {
|
|||||||
if json.Unmarshal(content, &settings) == nil {
|
if json.Unmarshal(content, &settings) == nil {
|
||||||
u.syncDefaultSourceMode = sanitizeSyncSourceMode(syncSourceMode(settings.Sync.SourceDefault))
|
u.syncDefaultSourceMode = sanitizeSyncSourceMode(syncSourceMode(settings.Sync.SourceDefault))
|
||||||
u.syncDefaultDirection = sanitizeSyncDirection(syncDirection(settings.Sync.DirectionDefault))
|
u.syncDefaultDirection = sanitizeSyncDirection(syncDirection(settings.Sync.DirectionDefault))
|
||||||
|
u.autoSaveRemote = settings.Sync.AutoSaveRemote
|
||||||
|
u.state.AutoSaveRemote = u.autoSaveRemote
|
||||||
u.debugLogHeaderBounds = settings.Debug.LogHeaderBounds
|
u.debugLogHeaderBounds = settings.Debug.LogHeaderBounds
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -258,6 +268,7 @@ func (u *ui) loadSettings() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
u.loadLegacySyncDefaultsFromUIPreferences()
|
u.loadLegacySyncDefaultsFromUIPreferences()
|
||||||
|
u.state.AutoSaveRemote = u.autoSaveRemote
|
||||||
}
|
}
|
||||||
|
|
||||||
func (u *ui) loadLegacySyncDefaultsFromUIPreferences() {
|
func (u *ui) loadLegacySyncDefaultsFromUIPreferences() {
|
||||||
@@ -287,6 +298,7 @@ func (u *ui) saveSettings() {
|
|||||||
Sync: syncSettings{
|
Sync: syncSettings{
|
||||||
SourceDefault: string(u.syncDefaultSourceMode),
|
SourceDefault: string(u.syncDefaultSourceMode),
|
||||||
DirectionDefault: string(u.syncDefaultDirection),
|
DirectionDefault: string(u.syncDefaultDirection),
|
||||||
|
AutoSaveRemote: u.autoSaveRemote,
|
||||||
},
|
},
|
||||||
Debug: debugSettings{
|
Debug: debugSettings{
|
||||||
LogHeaderBounds: u.debugLogHeaderBounds,
|
LogHeaderBounds: u.debugLogHeaderBounds,
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,12 +2,15 @@ package browserbridge
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"crypto/rand"
|
||||||
"crypto/sha256"
|
"crypto/sha256"
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"encoding/binary"
|
"encoding/binary"
|
||||||
|
"encoding/hex"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"runtime"
|
"runtime"
|
||||||
@@ -15,6 +18,8 @@ import (
|
|||||||
|
|
||||||
"git.julianfamily.org/keepassgo/internal/grpcaddr"
|
"git.julianfamily.org/keepassgo/internal/grpcaddr"
|
||||||
keepassgov1 "git.julianfamily.org/keepassgo/proto/keepassgo/v1"
|
keepassgov1 "git.julianfamily.org/keepassgo/proto/keepassgo/v1"
|
||||||
|
gcodes "google.golang.org/grpc/codes"
|
||||||
|
gstatus "google.golang.org/grpc/status"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -22,14 +27,19 @@ const (
|
|||||||
defaultFirefoxID = "browser@keepassgo.com"
|
defaultFirefoxID = "browser@keepassgo.com"
|
||||||
maxNativeMessageSize = 1024 * 1024
|
maxNativeMessageSize = 1024 * 1024
|
||||||
chromiumIDBytes = 16
|
chromiumIDBytes = 16
|
||||||
|
responseVersion = "1"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Request struct {
|
type Request struct {
|
||||||
Action string `json:"action"`
|
Action string `json:"action"`
|
||||||
GRPCAddress string `json:"grpcAddress,omitempty"`
|
|
||||||
BearerToken string `json:"bearerToken,omitempty"`
|
BearerToken string `json:"bearerToken,omitempty"`
|
||||||
URL string `json:"url,omitempty"`
|
URL string `json:"url,omitempty"`
|
||||||
EntryID string `json:"entryId,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 {
|
type Response struct {
|
||||||
@@ -37,6 +47,7 @@ type Response struct {
|
|||||||
Error string `json:"error,omitempty"`
|
Error string `json:"error,omitempty"`
|
||||||
Status *Status `json:"status,omitempty"`
|
Status *Status `json:"status,omitempty"`
|
||||||
Matches []Match `json:"matches,omitempty"`
|
Matches []Match `json:"matches,omitempty"`
|
||||||
|
SearchResults []Match `json:"searchResults,omitempty"`
|
||||||
Credential *Credential `json:"credential,omitempty"`
|
Credential *Credential `json:"credential,omitempty"`
|
||||||
Version string `json:"version,omitempty"`
|
Version string `json:"version,omitempty"`
|
||||||
}
|
}
|
||||||
@@ -46,6 +57,8 @@ type Status struct {
|
|||||||
Locked bool `json:"locked"`
|
Locked bool `json:"locked"`
|
||||||
Dirty bool `json:"dirty,omitempty"`
|
Dirty bool `json:"dirty,omitempty"`
|
||||||
EntryCount uint32 `json:"entryCount,omitempty"`
|
EntryCount uint32 `json:"entryCount,omitempty"`
|
||||||
|
PendingApprovalCount uint32 `json:"pendingApprovalCount,omitempty"`
|
||||||
|
TokenPendingApprovalCount uint32 `json:"tokenPendingApprovalCount,omitempty"`
|
||||||
GRPCAddress string `json:"grpcAddress,omitempty"`
|
GRPCAddress string `json:"grpcAddress,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -73,11 +86,15 @@ type Connection struct {
|
|||||||
type Client interface {
|
type Client interface {
|
||||||
Status(context.Context) (*keepassgov1.GetSessionStatusResponse, error)
|
Status(context.Context) (*keepassgov1.GetSessionStatusResponse, error)
|
||||||
FindBrowserLogins(context.Context, string) ([]*keepassgov1.BrowserLoginMatch, error)
|
FindBrowserLogins(context.Context, string) ([]*keepassgov1.BrowserLoginMatch, error)
|
||||||
|
ListEntries(context.Context, []string, string) ([]*keepassgov1.Entry, error)
|
||||||
GetBrowserCredential(context.Context, string, string) (*keepassgov1.GetBrowserCredentialResponse, error)
|
GetBrowserCredential(context.Context, string, string) (*keepassgov1.GetBrowserCredentialResponse, error)
|
||||||
|
UpsertEntry(context.Context, *keepassgov1.Entry) (*keepassgov1.Entry, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
type Browser string
|
type Browser string
|
||||||
|
|
||||||
|
type actionHandler func(context.Context, Client, Request, string) Response
|
||||||
|
|
||||||
const (
|
const (
|
||||||
BrowserFirefox Browser = "firefox"
|
BrowserFirefox Browser = "firefox"
|
||||||
BrowserChrome Browser = "chrome"
|
BrowserChrome Browser = "chrome"
|
||||||
@@ -134,68 +151,116 @@ func WriteResponse(w io.Writer, resp Response) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r Request) Connection() (Connection, error) {
|
func (r Request) Connection(grpcAddr string) (Connection, error) {
|
||||||
conn := Connection{
|
return normalizeConnection(Connection{
|
||||||
GRPCAddress: strings.TrimSpace(r.GRPCAddress),
|
GRPCAddress: strings.TrimSpace(grpcAddr),
|
||||||
BearerToken: strings.TrimSpace(r.BearerToken),
|
BearerToken: strings.TrimSpace(r.BearerToken),
|
||||||
}
|
})
|
||||||
if conn.GRPCAddress == "" {
|
}
|
||||||
|
|
||||||
|
func normalizeConnection(conn Connection) (Connection, error) {
|
||||||
|
if strings.TrimSpace(conn.GRPCAddress) == "" {
|
||||||
conn.GRPCAddress = grpcaddr.Default(runtime.GOOS)
|
conn.GRPCAddress = grpcaddr.Default(runtime.GOOS)
|
||||||
}
|
}
|
||||||
if conn.BearerToken == "" {
|
if strings.TrimSpace(conn.BearerToken) == "" {
|
||||||
return Connection{}, fmt.Errorf("browser bridge bearer token is required")
|
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
|
return conn, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func HandleRequest(ctx context.Context, req Request, client Client) Response {
|
func HandleRequest(ctx context.Context, req Request, grpcAddr string, client Client) Response {
|
||||||
conn, err := req.Connection()
|
conn, err := req.Connection(grpcAddr)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return Response{Success: false, Error: err.Error()}
|
return Response{Success: false, Error: err.Error()}
|
||||||
}
|
}
|
||||||
action := strings.TrimSpace(req.Action)
|
action := strings.TrimSpace(req.Action)
|
||||||
switch action {
|
handler, ok := actionHandlers[action]
|
||||||
case "status":
|
if !ok {
|
||||||
status, err := statusResponse(ctx, client, conn.GRPCAddress)
|
|
||||||
if err != nil {
|
|
||||||
return Response{Success: false, Error: err.Error(), Status: disconnectedStatus(conn.GRPCAddress)}
|
|
||||||
}
|
|
||||||
return Response{Success: true, Status: status, Version: "1"}
|
|
||||||
case "find-logins":
|
|
||||||
status, err := statusResponse(ctx, client, conn.GRPCAddress)
|
|
||||||
if err != nil {
|
|
||||||
return Response{Success: false, Error: err.Error(), Status: disconnectedStatus(conn.GRPCAddress)}
|
|
||||||
}
|
|
||||||
if status.Locked {
|
|
||||||
return Response{Success: true, Status: status, Matches: nil, Version: "1"}
|
|
||||||
}
|
|
||||||
matches, err := findMatches(ctx, client, req.URL)
|
|
||||||
if err != nil {
|
|
||||||
return Response{Success: false, Error: err.Error(), Status: status}
|
|
||||||
}
|
|
||||||
return Response{Success: true, Status: status, Matches: matches, Version: "1"}
|
|
||||||
case "get-login":
|
|
||||||
status, err := statusResponse(ctx, client, conn.GRPCAddress)
|
|
||||||
if err != nil {
|
|
||||||
return Response{Success: false, Error: err.Error(), Status: disconnectedStatus(conn.GRPCAddress)}
|
|
||||||
}
|
|
||||||
if status.Locked {
|
|
||||||
return Response{Success: false, Error: "vault is locked", Status: status}
|
|
||||||
}
|
|
||||||
credential, err := loadCredential(ctx, client, req.EntryID, req.URL)
|
|
||||||
if err != nil {
|
|
||||||
return Response{Success: false, Error: err.Error(), Status: status}
|
|
||||||
}
|
|
||||||
return Response{Success: true, Status: status, Credential: credential, Version: "1"}
|
|
||||||
default:
|
|
||||||
return Response{Success: false, Error: fmt.Sprintf("unsupported action %q", action)}
|
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 {
|
func disconnectedStatus(addr string) *Status {
|
||||||
return &Status{Connected: false, GRPCAddress: strings.TrimSpace(addr)}
|
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) {
|
func statusResponse(ctx context.Context, client Client, addr string) (*Status, error) {
|
||||||
resp, err := client.Status(ctx)
|
resp, err := client.Status(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -206,6 +271,8 @@ func statusResponse(ctx context.Context, client Client, addr string) (*Status, e
|
|||||||
Locked: resp.GetLocked(),
|
Locked: resp.GetLocked(),
|
||||||
Dirty: resp.GetDirty(),
|
Dirty: resp.GetDirty(),
|
||||||
EntryCount: resp.GetEntryCount(),
|
EntryCount: resp.GetEntryCount(),
|
||||||
|
PendingApprovalCount: resp.GetPendingApprovalCount(),
|
||||||
|
TokenPendingApprovalCount: resp.GetTokenPendingApprovalCount(),
|
||||||
GRPCAddress: strings.TrimSpace(addr),
|
GRPCAddress: strings.TrimSpace(addr),
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
@@ -246,6 +313,113 @@ func loadCredential(ctx context.Context, client Client, entryID, rawURL string)
|
|||||||
}, nil
|
}, 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) {
|
func Manifest(browser Browser, binaryPath, extensionID string) (NativeHostManifest, error) {
|
||||||
path := strings.TrimSpace(binaryPath)
|
path := strings.TrimSpace(binaryPath)
|
||||||
if path == "" {
|
if path == "" {
|
||||||
@@ -324,27 +498,5 @@ func DefaultManifestPath(browser Browser) (string, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func InstallManifest(browser Browser, binaryPath, extensionID, outputPath string) (string, error) {
|
func InstallManifest(browser Browser, binaryPath, extensionID, outputPath string) (string, error) {
|
||||||
manifest, err := Manifest(browser, binaryPath, extensionID)
|
return InstallManifestSet(browser, binaryPath, []string{strings.TrimSpace(extensionID)}, outputPath)
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,10 +8,13 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"runtime"
|
"runtime"
|
||||||
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
keepassgov1 "git.julianfamily.org/keepassgo/proto/keepassgo/v1"
|
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) {
|
func TestReadRequestAndWriteResponse(t *testing.T) {
|
||||||
@@ -20,7 +23,6 @@ func TestReadRequestAndWriteResponse(t *testing.T) {
|
|||||||
var input bytes.Buffer
|
var input bytes.Buffer
|
||||||
body, err := json.Marshal(Request{
|
body, err := json.Marshal(Request{
|
||||||
Action: "find-logins",
|
Action: "find-logins",
|
||||||
GRPCAddress: "127.0.0.1:47777",
|
|
||||||
BearerToken: "secret",
|
BearerToken: "secret",
|
||||||
URL: "https://example.invalid/login",
|
URL: "https://example.invalid/login",
|
||||||
})
|
})
|
||||||
@@ -41,8 +43,8 @@ func TestReadRequestAndWriteResponse(t *testing.T) {
|
|||||||
if req.Action != "find-logins" || req.BearerToken != "secret" {
|
if req.Action != "find-logins" || req.BearerToken != "secret" {
|
||||||
t.Fatalf("ReadRequest() = %#v, want action and token preserved", req)
|
t.Fatalf("ReadRequest() = %#v, want action and token preserved", req)
|
||||||
}
|
}
|
||||||
if conn, err := req.Connection(); err != nil || conn.GRPCAddress != "127.0.0.1:47777" {
|
if conn, err := req.Connection("127.0.0.1:47777"); err != nil || conn.GRPCAddress != "127.0.0.1:47777" {
|
||||||
t.Fatalf("req.Connection() = (%#v, %v), want explicit tcp address preserved", conn, err)
|
t.Fatalf("req.Connection(127.0.0.1:47777) = (%#v, %v), want explicit tcp address preserved", conn, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
var output bytes.Buffer
|
var output bytes.Buffer
|
||||||
@@ -69,8 +71,7 @@ func TestReadRequestAndWriteResponse(t *testing.T) {
|
|||||||
func TestHandleRequestFindLogins(t *testing.T) {
|
func TestHandleRequestFindLogins(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
client := fakeClient{
|
client := &fakeClient{
|
||||||
status: &keepassgov1.GetSessionStatusResponse{Locked: false, EntryCount: 2},
|
|
||||||
matches: []*keepassgov1.BrowserLoginMatch{
|
matches: []*keepassgov1.BrowserLoginMatch{
|
||||||
{Id: "vault-console", Title: "Vault Console", Username: "dannyocean", Url: "https://vault.example.invalid", Quality: "exact-host"},
|
{Id: "vault-console", Title: "Vault Console", Username: "dannyocean", Url: "https://vault.example.invalid", Quality: "exact-host"},
|
||||||
},
|
},
|
||||||
@@ -79,20 +80,51 @@ func TestHandleRequestFindLogins(t *testing.T) {
|
|||||||
Action: "find-logins",
|
Action: "find-logins",
|
||||||
BearerToken: "secret",
|
BearerToken: "secret",
|
||||||
URL: "https://vault.example.invalid/login",
|
URL: "https://vault.example.invalid/login",
|
||||||
}, client)
|
}, "", client)
|
||||||
if !resp.Success {
|
if !resp.Success {
|
||||||
t.Fatalf("HandleRequest() success = false, error = %q", resp.Error)
|
t.Fatalf("HandleRequest() success = false, error = %q", resp.Error)
|
||||||
}
|
}
|
||||||
if len(resp.Matches) != 1 || resp.Matches[0].ID != "vault-console" {
|
if len(resp.Matches) != 1 || resp.Matches[0].ID != "vault-console" {
|
||||||
t.Fatalf("HandleRequest().Matches = %#v, want vault-console", resp.Matches)
|
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) {
|
func TestHandleRequestGetLogin(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
client := fakeClient{
|
client := &fakeClient{
|
||||||
status: &keepassgov1.GetSessionStatusResponse{Locked: false, EntryCount: 1},
|
|
||||||
credential: &keepassgov1.GetBrowserCredentialResponse{
|
credential: &keepassgov1.GetBrowserCredentialResponse{
|
||||||
Id: "vault-console",
|
Id: "vault-console",
|
||||||
Username: "dannyocean",
|
Username: "dannyocean",
|
||||||
@@ -105,19 +137,146 @@ func TestHandleRequestGetLogin(t *testing.T) {
|
|||||||
BearerToken: "secret",
|
BearerToken: "secret",
|
||||||
EntryID: "vault-console",
|
EntryID: "vault-console",
|
||||||
URL: "https://vault.example.invalid/login",
|
URL: "https://vault.example.invalid/login",
|
||||||
}, client)
|
}, "", client)
|
||||||
if !resp.Success {
|
if !resp.Success {
|
||||||
t.Fatalf("HandleRequest() success = false, error = %q", resp.Error)
|
t.Fatalf("HandleRequest() success = false, error = %q", resp.Error)
|
||||||
}
|
}
|
||||||
if resp.Credential == nil || resp.Credential.ID != "vault-console" {
|
if resp.Credential == nil || resp.Credential.ID != "vault-console" {
|
||||||
t.Fatalf("HandleRequest().Credential = %#v, want vault-console", resp.Credential)
|
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) {
|
func TestHandleRequestRequiresBearerToken(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
resp := HandleRequest(context.Background(), Request{Action: "status"}, fakeClient{})
|
resp := HandleRequest(context.Background(), Request{Action: "status"}, "", &fakeClient{})
|
||||||
if resp.Success {
|
if resp.Success {
|
||||||
t.Fatal("HandleRequest().Success = true, want false without token")
|
t.Fatal("HandleRequest().Success = true, want false without token")
|
||||||
}
|
}
|
||||||
@@ -127,9 +286,9 @@ func TestRequestConnectionDefaultsAddress(t *testing.T) {
|
|||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
req := Request{Action: "status", BearerToken: "secret"}
|
req := Request{Action: "status", BearerToken: "secret"}
|
||||||
conn, err := req.Connection()
|
conn, err := req.Connection("")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Connection() error = %v", err)
|
t.Fatalf("Connection(\"\") error = %v", err)
|
||||||
}
|
}
|
||||||
if conn.GRPCAddress == "" {
|
if conn.GRPCAddress == "" {
|
||||||
t.Fatal("Connection().GRPCAddress = empty, want default address")
|
t.Fatal("Connection().GRPCAddress = empty, want default address")
|
||||||
@@ -181,14 +340,137 @@ func TestChromiumExtensionIDFromManifestKey(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 {
|
type fakeClient struct {
|
||||||
status *keepassgov1.GetSessionStatusResponse
|
status *keepassgov1.GetSessionStatusResponse
|
||||||
matches []*keepassgov1.BrowserLoginMatch
|
matches []*keepassgov1.BrowserLoginMatch
|
||||||
|
entries []*keepassgov1.Entry
|
||||||
credential *keepassgov1.GetBrowserCredentialResponse
|
credential *keepassgov1.GetBrowserCredentialResponse
|
||||||
|
upserted *keepassgov1.Entry
|
||||||
err error
|
err error
|
||||||
|
matchesErr error
|
||||||
|
entriesErr error
|
||||||
|
credentialErr error
|
||||||
|
upsertErr error
|
||||||
|
statusCalls int
|
||||||
}
|
}
|
||||||
|
|
||||||
func (f fakeClient) Status(context.Context) (*keepassgov1.GetSessionStatusResponse, error) {
|
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 {
|
if f.err != nil {
|
||||||
return nil, f.err
|
return nil, f.err
|
||||||
}
|
}
|
||||||
@@ -198,14 +480,30 @@ func (f fakeClient) Status(context.Context) (*keepassgov1.GetSessionStatusRespon
|
|||||||
return f.status, nil
|
return f.status, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (f fakeClient) FindBrowserLogins(context.Context, string) ([]*keepassgov1.BrowserLoginMatch, error) {
|
func (f *fakeClient) FindBrowserLogins(context.Context, string) ([]*keepassgov1.BrowserLoginMatch, error) {
|
||||||
|
if f.matchesErr != nil {
|
||||||
|
return nil, f.matchesErr
|
||||||
|
}
|
||||||
if f.err != nil {
|
if f.err != nil {
|
||||||
return nil, f.err
|
return nil, f.err
|
||||||
}
|
}
|
||||||
return f.matches, nil
|
return f.matches, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (f fakeClient) GetBrowserCredential(context.Context, string, string) (*keepassgov1.GetBrowserCredentialResponse, error) {
|
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 {
|
if f.err != nil {
|
||||||
return nil, f.err
|
return nil, f.err
|
||||||
}
|
}
|
||||||
@@ -214,3 +512,11 @@ func (f fakeClient) GetBrowserCredential(context.Context, string, string) (*keep
|
|||||||
}
|
}
|
||||||
return f.credential, 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
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net"
|
"net"
|
||||||
"runtime"
|
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"git.julianfamily.org/keepassgo/internal/grpcaddr"
|
"git.julianfamily.org/keepassgo/internal/grpcaddr"
|
||||||
@@ -18,14 +17,20 @@ type GRPCClient struct {
|
|||||||
client keepassgov1.VaultServiceClient
|
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) {
|
func Dial(ctx context.Context, conn Connection) (*grpc.ClientConn, *GRPCClient, context.Context, error) {
|
||||||
if strings.TrimSpace(conn.GRPCAddress) == "" {
|
normalized, err := normalizeConnection(conn)
|
||||||
conn.GRPCAddress = grpcaddr.Default(runtime.GOOS)
|
if err != nil {
|
||||||
|
return nil, nil, nil, err
|
||||||
}
|
}
|
||||||
if strings.TrimSpace(conn.BearerToken) == "" {
|
network, endpoint, err := grpcaddr.Parse(normalized.GRPCAddress)
|
||||||
return nil, nil, nil, fmt.Errorf("browser bridge bearer token is required")
|
|
||||||
}
|
|
||||||
network, endpoint, err := grpcaddr.Parse(conn.GRPCAddress)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, nil, nil, err
|
return nil, nil, nil, err
|
||||||
}
|
}
|
||||||
@@ -40,9 +45,9 @@ func Dial(ctx context.Context, conn Connection) (*grpc.ClientConn, *GRPCClient,
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, nil, nil, fmt.Errorf("dial gRPC host %s: %w", strings.TrimSpace(conn.GRPCAddress), err)
|
return nil, nil, nil, fmt.Errorf("dial gRPC host %s: %w", normalized.GRPCAddress, err)
|
||||||
}
|
}
|
||||||
ctx = metadata.AppendToOutgoingContext(ctx, "authorization", "Bearer "+strings.TrimSpace(conn.BearerToken))
|
ctx = metadata.AppendToOutgoingContext(ctx, "authorization", "Bearer "+normalized.BearerToken)
|
||||||
return grpcConn, &GRPCClient{client: keepassgov1.NewVaultServiceClient(grpcConn)}, ctx, nil
|
return grpcConn, &GRPCClient{client: keepassgov1.NewVaultServiceClient(grpcConn)}, ctx, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -60,9 +65,28 @@ func (c *GRPCClient) FindBrowserLogins(ctx context.Context, pageURL string) ([]*
|
|||||||
return resp.GetMatches(), nil
|
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) {
|
func (c *GRPCClient) GetBrowserCredential(ctx context.Context, entryID, pageURL string) (*keepassgov1.GetBrowserCredentialResponse, error) {
|
||||||
return c.client.GetBrowserCredential(ctx, &keepassgov1.GetBrowserCredentialRequest{
|
return c.client.GetBrowserCredential(ctx, &keepassgov1.GetBrowserCredentialRequest{
|
||||||
Id: strings.TrimSpace(entryID),
|
Id: strings.TrimSpace(entryID),
|
||||||
PageUrl: strings.TrimSpace(pageURL),
|
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
|
||||||
|
}
|
||||||
+76
-25
@@ -12,6 +12,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"git.julianfamily.org/keepassgo/internal/vault"
|
"git.julianfamily.org/keepassgo/internal/vault"
|
||||||
|
"git.julianfamily.org/keepassgo/internal/vaultview"
|
||||||
"git.julianfamily.org/keepassgo/internal/webdav"
|
"git.julianfamily.org/keepassgo/internal/webdav"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -31,6 +32,7 @@ type Manager struct {
|
|||||||
remoteClient *webdav.Client
|
remoteClient *webdav.Client
|
||||||
remotePath string
|
remotePath string
|
||||||
remoteVersion webdav.Version
|
remoteVersion webdav.Version
|
||||||
|
warning string
|
||||||
}
|
}
|
||||||
|
|
||||||
type PreparedLocalOpen struct {
|
type PreparedLocalOpen struct {
|
||||||
@@ -40,6 +42,7 @@ type PreparedLocalOpen struct {
|
|||||||
Key vault.MasterKey
|
Key vault.MasterKey
|
||||||
Encoded []byte
|
Encoded []byte
|
||||||
VaultRoot string
|
VaultRoot string
|
||||||
|
Warning string
|
||||||
}
|
}
|
||||||
|
|
||||||
type PreparedRemoteOpen struct {
|
type PreparedRemoteOpen struct {
|
||||||
@@ -51,6 +54,7 @@ type PreparedRemoteOpen struct {
|
|||||||
Encoded []byte
|
Encoded []byte
|
||||||
VaultRoot string
|
VaultRoot string
|
||||||
RemoteVersion webdav.Version
|
RemoteVersion webdav.Version
|
||||||
|
Warning string
|
||||||
}
|
}
|
||||||
|
|
||||||
type PreparedUnlock struct {
|
type PreparedUnlock struct {
|
||||||
@@ -58,6 +62,7 @@ type PreparedUnlock struct {
|
|||||||
Config *vault.KDBXConfig
|
Config *vault.KDBXConfig
|
||||||
Key vault.MasterKey
|
Key vault.MasterKey
|
||||||
VaultRoot string
|
VaultRoot string
|
||||||
|
Warning string
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *Manager) SecuritySettings() vault.SecuritySettings {
|
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 {
|
func (m *Manager) Create(model vault.Model, key vault.MasterKey) error {
|
||||||
root := detectSingleVaultRoot(model)
|
root := vaultview.KeepassRoot
|
||||||
model = normalizeUnderRoot(model, root)
|
model = normalizeUnderRoot(model, root)
|
||||||
var encoded bytes.Buffer
|
var encoded bytes.Buffer
|
||||||
if err := vault.SaveKDBXWithConfigAndKey(&encoded, model, key, m.config); err != nil {
|
if err := vault.SaveKDBXWithConfigAndKey(&encoded, model, key, m.config); err != nil {
|
||||||
@@ -86,6 +91,7 @@ func (m *Manager) Create(model vault.Model, key vault.MasterKey) error {
|
|||||||
m.vaultRoot = root
|
m.vaultRoot = root
|
||||||
m.encoded = encoded.Bytes()
|
m.encoded = encoded.Bytes()
|
||||||
m.locked = false
|
m.locked = false
|
||||||
|
m.warning = ""
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -93,6 +99,10 @@ func (m *Manager) HasVault() bool {
|
|||||||
return len(m.encoded) > 0 || m.path != "" || m.remotePath != ""
|
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 {
|
func (m *Manager) EncodedBytes() []byte {
|
||||||
return append([]byte(nil), m.encoded...)
|
return append([]byte(nil), m.encoded...)
|
||||||
}
|
}
|
||||||
@@ -114,6 +124,12 @@ func (m *Manager) Open(path string, key vault.MasterKey) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m *Manager) ConsumeWarning() string {
|
||||||
|
warning := strings.TrimSpace(m.warning)
|
||||||
|
m.warning = ""
|
||||||
|
return warning
|
||||||
|
}
|
||||||
|
|
||||||
func (m *Manager) Save() error {
|
func (m *Manager) Save() error {
|
||||||
if m.remoteClient != nil && m.remotePath != "" {
|
if m.remoteClient != nil && m.remotePath != "" {
|
||||||
return m.SaveRemote()
|
return m.SaveRemote()
|
||||||
@@ -250,7 +266,7 @@ func (m *Manager) SaveAs(path string) error {
|
|||||||
func (m *Manager) Replace(model vault.Model) {
|
func (m *Manager) Replace(model vault.Model) {
|
||||||
root := m.vaultRoot
|
root := m.vaultRoot
|
||||||
if root == "" {
|
if root == "" {
|
||||||
root = detectSingleVaultRoot(model)
|
root = vaultview.KeepassRoot
|
||||||
}
|
}
|
||||||
m.model = normalizeUnderRoot(model, root)
|
m.model = normalizeUnderRoot(model, root)
|
||||||
m.vaultRoot = 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{}, fmt.Errorf("open %s: %w", path, err)
|
||||||
}
|
}
|
||||||
return PreparedLocalOpen{
|
return PreparedLocalOpen{
|
||||||
Model: model,
|
Model: normalizeUnderRoot(model, vaultview.KeepassRoot),
|
||||||
Config: config,
|
Config: config,
|
||||||
Path: path,
|
Path: path,
|
||||||
Key: key,
|
Key: key,
|
||||||
Encoded: content,
|
Encoded: content,
|
||||||
VaultRoot: detectSingleVaultRoot(model),
|
VaultRoot: vaultview.KeepassRoot,
|
||||||
|
Warning: normalizationWarning(model),
|
||||||
}, nil
|
}, 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{}, fmt.Errorf("decode remote %s: %w", path, err)
|
||||||
}
|
}
|
||||||
return PreparedRemoteOpen{
|
return PreparedRemoteOpen{
|
||||||
Model: model,
|
Model: normalizeUnderRoot(model, vaultview.KeepassRoot),
|
||||||
Config: config,
|
Config: config,
|
||||||
Client: client,
|
Client: client,
|
||||||
Path: path,
|
Path: path,
|
||||||
Key: key,
|
Key: key,
|
||||||
Encoded: content,
|
Encoded: content,
|
||||||
VaultRoot: detectSingleVaultRoot(model),
|
VaultRoot: vaultview.KeepassRoot,
|
||||||
RemoteVersion: version,
|
RemoteVersion: version,
|
||||||
|
Warning: normalizationWarning(model),
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -337,10 +355,11 @@ func PrepareUnlock(encoded []byte, key vault.MasterKey) (PreparedUnlock, error)
|
|||||||
return PreparedUnlock{}, fmt.Errorf("unlock vault: %w", err)
|
return PreparedUnlock{}, fmt.Errorf("unlock vault: %w", err)
|
||||||
}
|
}
|
||||||
return PreparedUnlock{
|
return PreparedUnlock{
|
||||||
Model: model,
|
Model: normalizeUnderRoot(model, vaultview.KeepassRoot),
|
||||||
Config: config,
|
Config: config,
|
||||||
Key: key,
|
Key: key,
|
||||||
VaultRoot: detectSingleVaultRoot(model),
|
VaultRoot: vaultview.KeepassRoot,
|
||||||
|
Warning: normalizationWarning(model),
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -355,6 +374,7 @@ func (m *Manager) ApplyPreparedLocalOpen(prepared PreparedLocalOpen) {
|
|||||||
m.remoteClient = nil
|
m.remoteClient = nil
|
||||||
m.remotePath = ""
|
m.remotePath = ""
|
||||||
m.remoteVersion = webdav.Version{}
|
m.remoteVersion = webdav.Version{}
|
||||||
|
m.warning = prepared.Warning
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *Manager) ApplyPreparedRemoteOpen(prepared PreparedRemoteOpen) {
|
func (m *Manager) ApplyPreparedRemoteOpen(prepared PreparedRemoteOpen) {
|
||||||
@@ -368,6 +388,7 @@ func (m *Manager) ApplyPreparedRemoteOpen(prepared PreparedRemoteOpen) {
|
|||||||
m.remotePath = prepared.Path
|
m.remotePath = prepared.Path
|
||||||
m.remoteVersion = prepared.RemoteVersion
|
m.remoteVersion = prepared.RemoteVersion
|
||||||
m.path = ""
|
m.path = ""
|
||||||
|
m.warning = prepared.Warning
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *Manager) ApplyPreparedUnlock(prepared PreparedUnlock) {
|
func (m *Manager) ApplyPreparedUnlock(prepared PreparedUnlock) {
|
||||||
@@ -376,6 +397,7 @@ func (m *Manager) ApplyPreparedUnlock(prepared PreparedUnlock) {
|
|||||||
m.key = prepared.Key
|
m.key = prepared.Key
|
||||||
m.vaultRoot = prepared.VaultRoot
|
m.vaultRoot = prepared.VaultRoot
|
||||||
m.locked = false
|
m.locked = false
|
||||||
|
m.warning = prepared.Warning
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *Manager) ChangeMasterKey(key vault.MasterKey) error {
|
func (m *Manager) ChangeMasterKey(key vault.MasterKey) error {
|
||||||
@@ -580,9 +602,7 @@ func (m *Manager) reloadCurrentLocal(merged vault.Model) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
m.model = merged
|
m.model = merged
|
||||||
if root := detectSingleVaultRoot(merged); root != "" {
|
m.vaultRoot = vaultview.KeepassRoot
|
||||||
m.vaultRoot = root
|
|
||||||
}
|
|
||||||
m.encoded = encoded
|
m.encoded = encoded
|
||||||
m.locked = false
|
m.locked = false
|
||||||
return nil
|
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)
|
return fmt.Errorf("reopen remote %s after synchronize: %w", m.remotePath, err)
|
||||||
}
|
}
|
||||||
m.model = merged
|
m.model = merged
|
||||||
if root := detectSingleVaultRoot(merged); root != "" {
|
m.vaultRoot = vaultview.KeepassRoot
|
||||||
m.vaultRoot = root
|
|
||||||
}
|
|
||||||
m.encoded = encoded
|
m.encoded = encoded
|
||||||
m.remoteVersion = version
|
m.remoteVersion = version
|
||||||
m.locked = false
|
m.locked = false
|
||||||
@@ -863,17 +881,6 @@ func mergePeerGroups(primary, secondary [][]string) [][]string {
|
|||||||
return out
|
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 {
|
func normalizeUnderRoot(model vault.Model, root string) vault.Model {
|
||||||
if root == "" {
|
if root == "" {
|
||||||
return model
|
return model
|
||||||
@@ -884,8 +891,15 @@ func normalizeUnderRoot(model vault.Model, root string) vault.Model {
|
|||||||
switch {
|
switch {
|
||||||
case len(path) == 0:
|
case len(path) == 0:
|
||||||
return []string{root}
|
return []string{root}
|
||||||
|
case path[0] == "Root":
|
||||||
|
if len(path) == 1 {
|
||||||
|
return []string{root}
|
||||||
|
}
|
||||||
|
return append([]string{root}, path[1:]...)
|
||||||
case path[0] == root:
|
case path[0] == root:
|
||||||
return path
|
return path
|
||||||
|
case path[0] == "Templates":
|
||||||
|
return path
|
||||||
default:
|
default:
|
||||||
return append([]string{root}, path...)
|
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)
|
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 {
|
for i := range out.Groups {
|
||||||
out.Groups[i] = normalizePath(out.Groups[i])
|
out.Groups[i] = normalizePath(out.Groups[i])
|
||||||
}
|
}
|
||||||
return out
|
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) {
|
func loadLocalSource(path string, key vault.MasterKey) (vault.Model, *vault.KDBXConfig, error) {
|
||||||
content, err := os.ReadFile(path)
|
content, err := os.ReadFile(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -64,7 +64,7 @@ func TestCreateSaveAsLockAndUnlockRoundTripsVault(t *testing.T) {
|
|||||||
t.Fatalf("Current() after Unlock() error = %v", err)
|
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" {
|
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)
|
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)
|
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" {
|
if len(got) != 1 || got[0].Password != "token-2" {
|
||||||
t.Fatalf("Current() entries = %#v, want Home Assistant entry", got)
|
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) {
|
func TestSavePersistsEditsBackToCurrentPath(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -169,7 +220,7 @@ func TestSavePersistsEditsBackToCurrentPath(t *testing.T) {
|
|||||||
t.Fatalf("LoadKDBXWithKey() error = %v", err)
|
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" {
|
if len(got) != 1 || got[0].Password != "token-2" {
|
||||||
t.Fatalf("loaded entries = %#v, want updated password token-2", got)
|
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)
|
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" {
|
if len(got) != 1 || got[0].Password != "token-1" {
|
||||||
t.Fatalf("Current() entries = %#v, want Vault Console entry from remote vault", got)
|
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)
|
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" {
|
if len(got) != 1 || got[0].Password != "token-2" {
|
||||||
t.Fatalf("loaded remote entries = %#v, want updated token-2 entry", got)
|
t.Fatalf("loaded remote entries = %#v, want updated token-2 entry", got)
|
||||||
}
|
}
|
||||||
@@ -513,7 +564,7 @@ func TestChangeMasterKeyReencryptsSavedAndLockedVault(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Current() error = %v", err)
|
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" {
|
if len(got) != 1 || got[0].Title != "Vault Console" {
|
||||||
t.Fatalf("Current() entries = %#v, want Vault Console entry after ChangeMasterKey", got)
|
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)
|
t.Fatalf("Current() after reopen error = %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
got := current.EntriesInPath([]string{"Root", "Internet"})
|
got := current.EntriesInPath([]string{"keepass", "Internet"})
|
||||||
if len(got) != 1 {
|
if len(got) != 1 {
|
||||||
t.Fatalf("len(EntriesInPath(Root/Internet)) after reopen = %d, want 1", len(got))
|
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)
|
t.Fatalf("reopened Current() error = %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
got := current.EntriesInPath([]string{"Root", "Internet"})
|
got := current.EntriesInPath([]string{"keepass", "Internet"})
|
||||||
if len(got) != 1 {
|
if len(got) != 1 {
|
||||||
t.Fatalf("len(EntriesInPath(Root/Internet)) = %d, want 1", len(got))
|
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)
|
t.Fatalf("reopened Current() error = %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
got := current.EntriesInPath([]string{"Root", "Internet"})
|
got := current.EntriesInPath([]string{"keepass", "Internet"})
|
||||||
if len(got) != 2 {
|
if len(got) != 2 {
|
||||||
t.Fatalf("len(EntriesInPath(Root/Internet)) = %d, want 2", len(got))
|
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)
|
t.Fatalf("reopened Current() error = %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
got := current.EntriesInPath([]string{"Root", "Internet"})
|
got := current.EntriesInPath([]string{"keepass", "Internet"})
|
||||||
if len(got) != 2 {
|
if len(got) != 2 {
|
||||||
t.Fatalf("len(EntriesInPath(Root/Internet)) = %d, want 2", len(got))
|
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)
|
t.Fatalf("reopened Current() error = %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
got := current.EntriesInPath([]string{"Root", "Internet"})
|
got := current.EntriesInPath([]string{"keepass", "Internet"})
|
||||||
if len(got) != 2 {
|
if len(got) != 2 {
|
||||||
t.Fatalf("len(EntriesInPath(Root/Internet)) = %d, want 2", len(got))
|
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)
|
t.Fatalf("reopened Current() error = %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
got := current.EntriesInPath([]string{"Root", "Internet"})
|
got := current.EntriesInPath([]string{"keepass", "Internet"})
|
||||||
if len(got) != 2 {
|
if len(got) != 2 {
|
||||||
t.Fatalf("len(EntriesInPath(Root/Internet)) = %d, want 2", len(got))
|
t.Fatalf("len(EntriesInPath(Root/Internet)) = %d, want 2", len(got))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ var ErrInvalidMasterKey = errors.New("invalid master key")
|
|||||||
const (
|
const (
|
||||||
templatesRoot = "Templates"
|
templatesRoot = "Templates"
|
||||||
recycleBinRoot = "Recycle Bin"
|
recycleBinRoot = "Recycle Bin"
|
||||||
|
keepassRoot = "keepass"
|
||||||
keepassGOIDField = "KeePassGO-ID"
|
keepassGOIDField = "KeePassGO-ID"
|
||||||
remoteProfilesKey = "keepassgo.remoteProfiles"
|
remoteProfilesKey = "keepassgo.remoteProfiles"
|
||||||
)
|
)
|
||||||
@@ -502,6 +503,10 @@ func compareGroupNames(a, b string) int {
|
|||||||
return -1
|
return -1
|
||||||
case b == "Root":
|
case b == "Root":
|
||||||
return 1
|
return 1
|
||||||
|
case a == keepassRoot:
|
||||||
|
return -1
|
||||||
|
case b == keepassRoot:
|
||||||
|
return 1
|
||||||
case a == templatesRoot:
|
case a == templatesRoot:
|
||||||
return -1
|
return -1
|
||||||
case b == templatesRoot:
|
case b == templatesRoot:
|
||||||
|
|||||||
@@ -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 {
|
func mustGroup(name string, children ...any) gokeepasslib.Group {
|
||||||
group := gokeepasslib.NewGroup()
|
group := gokeepasslib.NewGroup()
|
||||||
group.Name = name
|
group.Name = name
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -50,6 +50,36 @@ package() {
|
|||||||
|
|
||||||
install -Dm755 keepassgo "${pkgdir}/usr/bin/keepassgo"
|
install -Dm755 keepassgo "${pkgdir}/usr/bin/keepassgo"
|
||||||
install -Dm755 keepassgo-browser-bridge "${pkgdir}/usr/bin/keepassgo-browser-bridge"
|
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 \
|
install -Dm644 internal/assets/keepassgo-icon.png \
|
||||||
"${pkgdir}/usr/share/icons/hicolor/512x512/apps/keepassgo.png"
|
"${pkgdir}/usr/share/icons/hicolor/512x512/apps/keepassgo.png"
|
||||||
install -Dm644 internal/assets/keepassgo-icon.svg \
|
install -Dm644 internal/assets/keepassgo-icon.svg \
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -62,6 +62,8 @@ type GetSessionStatusResponse struct {
|
|||||||
Locked bool `protobuf:"varint,1,opt,name=locked,proto3" json:"locked,omitempty"`
|
Locked bool `protobuf:"varint,1,opt,name=locked,proto3" json:"locked,omitempty"`
|
||||||
Dirty bool `protobuf:"varint,2,opt,name=dirty,proto3" json:"dirty,omitempty"`
|
Dirty bool `protobuf:"varint,2,opt,name=dirty,proto3" json:"dirty,omitempty"`
|
||||||
EntryCount uint32 `protobuf:"varint,3,opt,name=entry_count,json=entryCount,proto3" json:"entry_count,omitempty"`
|
EntryCount uint32 `protobuf:"varint,3,opt,name=entry_count,json=entryCount,proto3" json:"entry_count,omitempty"`
|
||||||
|
PendingApprovalCount uint32 `protobuf:"varint,4,opt,name=pending_approval_count,json=pendingApprovalCount,proto3" json:"pending_approval_count,omitempty"`
|
||||||
|
TokenPendingApprovalCount uint32 `protobuf:"varint,5,opt,name=token_pending_approval_count,json=tokenPendingApprovalCount,proto3" json:"token_pending_approval_count,omitempty"`
|
||||||
unknownFields protoimpl.UnknownFields
|
unknownFields protoimpl.UnknownFields
|
||||||
sizeCache protoimpl.SizeCache
|
sizeCache protoimpl.SizeCache
|
||||||
}
|
}
|
||||||
@@ -117,6 +119,20 @@ func (x *GetSessionStatusResponse) GetEntryCount() uint32 {
|
|||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (x *GetSessionStatusResponse) GetPendingApprovalCount() uint32 {
|
||||||
|
if x != nil {
|
||||||
|
return x.PendingApprovalCount
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *GetSessionStatusResponse) GetTokenPendingApprovalCount() uint32 {
|
||||||
|
if x != nil {
|
||||||
|
return x.TokenPendingApprovalCount
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
type OpenVaultRequest struct {
|
type OpenVaultRequest struct {
|
||||||
state protoimpl.MessageState `protogen:"open.v1"`
|
state protoimpl.MessageState `protogen:"open.v1"`
|
||||||
Path string `protobuf:"bytes,1,opt,name=path,proto3" json:"path,omitempty"`
|
Path string `protobuf:"bytes,1,opt,name=path,proto3" json:"path,omitempty"`
|
||||||
@@ -2738,12 +2754,14 @@ var File_proto_keepassgo_v1_keepassgo_proto protoreflect.FileDescriptor
|
|||||||
const file_proto_keepassgo_v1_keepassgo_proto_rawDesc = "" +
|
const file_proto_keepassgo_v1_keepassgo_proto_rawDesc = "" +
|
||||||
"\n" +
|
"\n" +
|
||||||
"\"proto/keepassgo/v1/keepassgo.proto\x12\fkeepassgo.v1\"\x19\n" +
|
"\"proto/keepassgo/v1/keepassgo.proto\x12\fkeepassgo.v1\"\x19\n" +
|
||||||
"\x17GetSessionStatusRequest\"i\n" +
|
"\x17GetSessionStatusRequest\"\xe0\x01\n" +
|
||||||
"\x18GetSessionStatusResponse\x12\x16\n" +
|
"\x18GetSessionStatusResponse\x12\x16\n" +
|
||||||
"\x06locked\x18\x01 \x01(\bR\x06locked\x12\x14\n" +
|
"\x06locked\x18\x01 \x01(\bR\x06locked\x12\x14\n" +
|
||||||
"\x05dirty\x18\x02 \x01(\bR\x05dirty\x12\x1f\n" +
|
"\x05dirty\x18\x02 \x01(\bR\x05dirty\x12\x1f\n" +
|
||||||
"\ventry_count\x18\x03 \x01(\rR\n" +
|
"\ventry_count\x18\x03 \x01(\rR\n" +
|
||||||
"entryCount\"f\n" +
|
"entryCount\x124\n" +
|
||||||
|
"\x16pending_approval_count\x18\x04 \x01(\rR\x14pendingApprovalCount\x12?\n" +
|
||||||
|
"\x1ctoken_pending_approval_count\x18\x05 \x01(\rR\x19tokenPendingApprovalCount\"f\n" +
|
||||||
"\x10OpenVaultRequest\x12\x12\n" +
|
"\x10OpenVaultRequest\x12\x12\n" +
|
||||||
"\x04path\x18\x01 \x01(\tR\x04path\x12\x1a\n" +
|
"\x04path\x18\x01 \x01(\tR\x04path\x12\x1a\n" +
|
||||||
"\bpassword\x18\x02 \x01(\tR\bpassword\x12\"\n" +
|
"\bpassword\x18\x02 \x01(\tR\bpassword\x12\"\n" +
|
||||||
|
|||||||
@@ -41,6 +41,8 @@ message GetSessionStatusResponse {
|
|||||||
bool locked = 1;
|
bool locked = 1;
|
||||||
bool dirty = 2;
|
bool dirty = 2;
|
||||||
uint32 entry_count = 3;
|
uint32 entry_count = 3;
|
||||||
|
uint32 pending_approval_count = 4;
|
||||||
|
uint32 token_pending_approval_count = 5;
|
||||||
}
|
}
|
||||||
|
|
||||||
message OpenVaultRequest {
|
message OpenVaultRequest {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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())
|
||||||
@@ -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()
|
||||||
Reference in New Issue
Block a user