Compare commits
47 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 | |||
| 852c115b2a | |||
| 2ef571c241 | |||
| c017308aa1 |
@@ -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.
|
||||||
|
|||||||
+21
-4
@@ -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,12 @@ 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: Install native build dependencies
|
- name: Install native build dependencies
|
||||||
shell: bash
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
@@ -78,6 +86,12 @@ 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: Install native build dependencies
|
- name: Install native build dependencies
|
||||||
shell: bash
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
@@ -135,11 +149,14 @@ 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: Upload CI artifacts
|
- name: Upload CI artifacts
|
||||||
|
|||||||
@@ -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,7 @@ 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 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 +177,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,6 +12,9 @@ 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
|
||||||
@@ -25,8 +29,31 @@ ifneq ($(strip $(SIGNPASS)),)
|
|||||||
GOGIO_SIGN_FLAGS += -signpass $(SIGNPASS)
|
GOGIO_SIGN_FLAGS += -signpass $(SIGNPASS)
|
||||||
endif
|
endif
|
||||||
|
|
||||||
.PHONY: apk archlinux-pkgbuild
|
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
|
||||||
|
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 +61,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 +83,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" \
|
||||||
@@ -68,3 +128,12 @@ archlinux-pkgbuild: $(ARCH_PKG_TMPL) Makefile
|
|||||||
-e 's|@PKGVER@|$(ARCH_PKGVER)|g' \
|
-e 's|@PKGVER@|$(ARCH_PKGVER)|g' \
|
||||||
-e 's|@REPO_DIR@|$(ARCH_REPO_DIR)|g' \
|
-e 's|@REPO_DIR@|$(ARCH_REPO_DIR)|g' \
|
||||||
"$(ARCH_PKG_TMPL)" > "$(ARCH_PKGBUILD)"
|
"$(ARCH_PKG_TMPL)" > "$(ARCH_PKGBUILD)"
|
||||||
|
|
||||||
|
browser-bridge:
|
||||||
|
go build ./cmd/keepassgo-browser-bridge
|
||||||
|
|
||||||
|
browser-extension-validate:
|
||||||
|
@command -v xvfb-run >/dev/null 2>&1 || { echo "xvfb-run is required"; exit 1; }
|
||||||
|
@command -v firefox >/dev/null 2>&1 || { echo "firefox is required"; exit 1; }
|
||||||
|
@command -v openssl >/dev/null 2>&1 || { echo "openssl is required"; exit 1; }
|
||||||
|
xvfb-run -a python scripts/validate_browser_extension.py $(if $(BROWSER),--browser $(BROWSER),)
|
||||||
|
|||||||
@@ -63,6 +63,7 @@ makepkg -si
|
|||||||
The package installs:
|
The package installs:
|
||||||
|
|
||||||
- `/usr/bin/keepassgo`
|
- `/usr/bin/keepassgo`
|
||||||
|
- `/usr/bin/keepassgo-browser-bridge`
|
||||||
- a desktop entry at `/usr/share/applications/keepassgo.desktop`
|
- a desktop entry at `/usr/share/applications/keepassgo.desktop`
|
||||||
- application icons under the hicolor theme
|
- application icons under the hicolor theme
|
||||||
|
|
||||||
@@ -89,12 +90,35 @@ 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
|
||||||
|
|
||||||
Desktop automation is resolved through the secure gRPC API rather than synthetic auto-type.
|
Desktop automation is resolved through the secure gRPC API rather than synthetic auto-type.
|
||||||
See [`docs/desktop-automation.md`](./docs/desktop-automation.md).
|
See [`docs/desktop-automation.md`](./docs/desktop-automation.md).
|
||||||
|
|
||||||
|
On desktop, KeePassGO now listens on a Unix socket by default under the user runtime directory.
|
||||||
|
Set `KEEPASSGO_GRPC_ADDR` or `-grpc-addr` to override it, for example `tcp://127.0.0.1:47777`.
|
||||||
|
|
||||||
|
## Browser Extension
|
||||||
|
|
||||||
|
Firefox and Chromium browser integration is available through the local gRPC API plus a native messaging bridge.
|
||||||
|
See [`docs/browser-extension.md`](./docs/browser-extension.md).
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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"
|
||||||
@@ -41,6 +45,9 @@
|
|||||||
<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" />
|
||||||
|
<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,18 +21,7 @@ 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;
|
||||||
}
|
}
|
||||||
@@ -57,6 +47,50 @@ final class AutofillCacheStore {
|
|||||||
return chooseEntry(target, parentHost);
|
return chooseEntry(target, parentHost);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static Entry findByID(Context context, String entryID) {
|
||||||
|
if (entryID == null || entryID.trim().isEmpty()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
for (Entry entry : readEntries(context)) {
|
||||||
|
if (entryID.equals(entry.id)) {
|
||||||
|
return entry;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
static List<Entry> chooserCandidates(Context context, String rawTarget) {
|
||||||
|
List<Entry> entries = readEntries(context);
|
||||||
|
if (entries.isEmpty()) {
|
||||||
|
return entries;
|
||||||
|
}
|
||||||
|
Entry direct = findBestMatch(context, 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<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<>();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private static File findCacheFile(Context context) {
|
private static File findCacheFile(Context context) {
|
||||||
List<File> candidates = new ArrayList<>();
|
List<File> candidates = new ArrayList<>();
|
||||||
File filesDir = context.getFilesDir();
|
File filesDir = context.getFilesDir();
|
||||||
@@ -103,16 +137,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 +174,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 {
|
||||||
@@ -293,20 +339,24 @@ final class AutofillCacheStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,9 +11,11 @@ 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";
|
||||||
@@ -36,6 +39,7 @@ public final class SharedVaultImportActivity extends Activity {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private void handleIntent(Intent intent) {
|
private void handleIntent(Intent intent) {
|
||||||
|
logIntent(intent);
|
||||||
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,10 +59,29 @@ 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;
|
||||||
}
|
}
|
||||||
@@ -69,7 +92,7 @@ public final class SharedVaultImportActivity extends Activity {
|
|||||||
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.kdbx");
|
||||||
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");
|
||||||
}
|
}
|
||||||
@@ -88,6 +111,17 @@ public final class SharedVaultImportActivity extends Activity {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 +157,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,27 @@
|
|||||||
|
# KeePassGO Browser Extension
|
||||||
|
|
||||||
|
Shared extension assets for Firefox and Chromium-based browsers live here.
|
||||||
|
|
||||||
|
The Arch package installs this directory under `/usr/share/keepassgo/browser-extension/`. On Linux desktop builds, launching KeePassGO refreshes the user-scoped native messaging manifests for Firefox and for any installed Chrome or Chromium `KeePassGO Browser` extension ids it can discover from browser profiles.
|
||||||
|
|
||||||
|
- `manifest.firefox.json` uses the fixed Firefox extension id `browser@keepassgo.com`
|
||||||
|
- `manifest.chromium.json` is the Chromium/Chrome manifest template
|
||||||
|
- `background.js` caches per-tab match state, updates the toolbar badge, keeps token-scoped approval state visible, and talks to the native messaging host `com.keepassgo.browser`
|
||||||
|
- `content.js` fills username and password fields on the current page, keeps fills tied to the focused form when possible, and shows inline KeePassGO field affordances when matches exist
|
||||||
|
- `options.html` stores the API token in browser extension storage
|
||||||
|
|
||||||
|
The extension sends the API token to the native host on each request. The bridge does not store the token on disk.
|
||||||
|
|
||||||
|
Quick extension-side checks:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
node --test browser/extension/background.test.cjs browser/extension/content.test.cjs
|
||||||
|
```
|
||||||
|
|
||||||
|
Reproducible Chromium validation:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make browser-extension-validate
|
||||||
|
```
|
||||||
|
|
||||||
|
That command validates Firefox by default. Use `make browser-extension-validate BROWSER=chromium` for the Chromium harness.
|
||||||
@@ -0,0 +1,744 @@
|
|||||||
|
const ext = globalThis.browser ?? globalThis.chrome;
|
||||||
|
const nativeHost = "com.keepassgo.browser";
|
||||||
|
const isNodeTestEnv = typeof module !== "undefined" && module.exports;
|
||||||
|
const usePromiseAPI = typeof globalThis.browser !== "undefined";
|
||||||
|
const defaultSettings = {
|
||||||
|
bearerToken: ""
|
||||||
|
};
|
||||||
|
const pageStatePrefix = "keepassgo-page-state:";
|
||||||
|
const matchCacheTTL = 30 * 1000;
|
||||||
|
const pendingPollMillis = 1500;
|
||||||
|
const pageStates = new Map();
|
||||||
|
const refreshJobs = new Map();
|
||||||
|
const pendingPollers = new Map();
|
||||||
|
|
||||||
|
function storageGet(keys) {
|
||||||
|
if (usePromiseAPI) {
|
||||||
|
return ext.storage.local.get(keys);
|
||||||
|
}
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
ext.storage.local.get(keys, (value) => {
|
||||||
|
const error = ext.runtime.lastError;
|
||||||
|
if (error) {
|
||||||
|
reject(new Error(error.message));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
resolve(value);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function storageSet(value) {
|
||||||
|
if (usePromiseAPI) {
|
||||||
|
return ext.storage.local.set(value);
|
||||||
|
}
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
ext.storage.local.set(value, () => {
|
||||||
|
const error = ext.runtime.lastError;
|
||||||
|
if (error) {
|
||||||
|
reject(new Error(error.message));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function sessionArea() {
|
||||||
|
return ext.storage?.session ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sessionStorageGet(keys) {
|
||||||
|
const area = sessionArea();
|
||||||
|
if (!area) {
|
||||||
|
return Promise.resolve({});
|
||||||
|
}
|
||||||
|
if (usePromiseAPI) {
|
||||||
|
return area.get(keys).then((value) => value || {});
|
||||||
|
}
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
area.get(keys, (value) => {
|
||||||
|
const error = ext.runtime.lastError;
|
||||||
|
if (error) {
|
||||||
|
reject(new Error(error.message));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
resolve(value || {});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function sessionStorageSet(value) {
|
||||||
|
const area = sessionArea();
|
||||||
|
if (!area) {
|
||||||
|
return Promise.resolve();
|
||||||
|
}
|
||||||
|
if (usePromiseAPI) {
|
||||||
|
return area.set(value);
|
||||||
|
}
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
area.set(value, () => {
|
||||||
|
const error = ext.runtime.lastError;
|
||||||
|
if (error) {
|
||||||
|
reject(new Error(error.message));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function sessionStorageRemove(keys) {
|
||||||
|
const area = sessionArea();
|
||||||
|
if (!area) {
|
||||||
|
return Promise.resolve();
|
||||||
|
}
|
||||||
|
if (usePromiseAPI) {
|
||||||
|
return area.remove(keys);
|
||||||
|
}
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
area.remove(keys, () => {
|
||||||
|
const error = ext.runtime.lastError;
|
||||||
|
if (error) {
|
||||||
|
reject(new Error(error.message));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function tabsQuery(query) {
|
||||||
|
if (usePromiseAPI) {
|
||||||
|
return ext.tabs.query(query);
|
||||||
|
}
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
ext.tabs.query(query, (tabs) => {
|
||||||
|
const error = ext.runtime.lastError;
|
||||||
|
if (error) {
|
||||||
|
reject(new Error(error.message));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
resolve(tabs);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function tabsGet(tabId) {
|
||||||
|
if (usePromiseAPI) {
|
||||||
|
return ext.tabs.get(tabId);
|
||||||
|
}
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
ext.tabs.get(tabId, (tab) => {
|
||||||
|
const error = ext.runtime.lastError;
|
||||||
|
if (error) {
|
||||||
|
reject(new Error(error.message));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
resolve(tab);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function tabsSendMessage(tabId, message) {
|
||||||
|
if (usePromiseAPI) {
|
||||||
|
return ext.tabs.sendMessage(tabId, message);
|
||||||
|
}
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
ext.tabs.sendMessage(tabId, message, (response) => {
|
||||||
|
const error = ext.runtime.lastError;
|
||||||
|
if (error) {
|
||||||
|
reject(new Error(error.message));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
resolve(response);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function connectNative(message) {
|
||||||
|
if (usePromiseAPI) {
|
||||||
|
return ext.runtime.sendNativeMessage(nativeHost, message);
|
||||||
|
}
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
ext.runtime.sendNativeMessage(nativeHost, message, (response) => {
|
||||||
|
const error = ext.runtime.lastError;
|
||||||
|
if (error) {
|
||||||
|
reject(new Error(error.message));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
resolve(response);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadSettings() {
|
||||||
|
const stored = await storageGet(["bearerToken"]);
|
||||||
|
return {
|
||||||
|
bearerToken: (stored.bearerToken || defaultSettings.bearerToken).trim()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function supportsPageStateURL(rawURL) {
|
||||||
|
return typeof rawURL === "string" && /^https?:\/\//i.test(rawURL);
|
||||||
|
}
|
||||||
|
|
||||||
|
function pageStateKey(tabId) {
|
||||||
|
return `${pageStatePrefix}${String(tabId)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function cloneTarget(target) {
|
||||||
|
return target && typeof target === "object" ? { ...target } : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizePageState(state) {
|
||||||
|
return {
|
||||||
|
tabId: Number.isInteger(state?.tabId) ? state.tabId : null,
|
||||||
|
pageUrl: typeof state?.pageUrl === "string" ? state.pageUrl : "",
|
||||||
|
configured: Boolean(state?.configured),
|
||||||
|
success: state?.success !== false,
|
||||||
|
status: state?.status ?? null,
|
||||||
|
matches: Array.isArray(state?.matches) ? state.matches : [],
|
||||||
|
error: typeof state?.error === "string" ? state.error : "",
|
||||||
|
pageHasLoginForm: Boolean(state?.pageHasLoginForm),
|
||||||
|
signature: typeof state?.signature === "string" ? state.signature : "",
|
||||||
|
focusTarget: cloneTarget(state?.focusTarget),
|
||||||
|
pendingFill: Boolean(state?.pendingFill),
|
||||||
|
pendingEntryId: typeof state?.pendingEntryId === "string" ? state.pendingEntryId : "",
|
||||||
|
pendingTarget: cloneTarget(state?.pendingTarget),
|
||||||
|
pendingMessage: typeof state?.pendingMessage === "string" ? state.pendingMessage : "",
|
||||||
|
lastFilledEntryId: typeof state?.lastFilledEntryId === "string" ? state.lastFilledEntryId : "",
|
||||||
|
updatedAt: Number.isFinite(state?.updatedAt) ? state.updatedAt : 0
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function defaultPageState(tabId, pageUrl) {
|
||||||
|
return normalizePageState({
|
||||||
|
tabId,
|
||||||
|
pageUrl,
|
||||||
|
configured: true,
|
||||||
|
success: true,
|
||||||
|
status: null,
|
||||||
|
matches: [],
|
||||||
|
error: "",
|
||||||
|
pageHasLoginForm: false,
|
||||||
|
signature: "",
|
||||||
|
focusTarget: null,
|
||||||
|
pendingFill: false,
|
||||||
|
pendingEntryId: "",
|
||||||
|
pendingTarget: null,
|
||||||
|
pendingMessage: "",
|
||||||
|
lastFilledEntryId: "",
|
||||||
|
updatedAt: 0
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getPageState(tabId, pageUrl) {
|
||||||
|
if (!Number.isInteger(tabId)) {
|
||||||
|
return defaultPageState(null, pageUrl || "");
|
||||||
|
}
|
||||||
|
const existing = pageStates.get(tabId);
|
||||||
|
if (existing && (!pageUrl || existing.pageUrl === pageUrl)) {
|
||||||
|
return normalizePageState(existing);
|
||||||
|
}
|
||||||
|
const stored = await sessionStorageGet(pageStateKey(tabId));
|
||||||
|
const state = normalizePageState(stored[pageStateKey(tabId)] || defaultPageState(tabId, pageUrl || ""));
|
||||||
|
if (pageUrl && state.pageUrl !== pageUrl) {
|
||||||
|
return defaultPageState(tabId, pageUrl);
|
||||||
|
}
|
||||||
|
pageStates.set(tabId, state);
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function setPageState(tabId, nextState) {
|
||||||
|
const state = normalizePageState({ ...nextState, tabId });
|
||||||
|
if (!Number.isInteger(tabId)) {
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
pageStates.set(tabId, state);
|
||||||
|
await sessionStorageSet({ [pageStateKey(tabId)]: state });
|
||||||
|
await updateActionState(tabId, state);
|
||||||
|
await notifyContentState(tabId, state);
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearPendingPoll(tabId) {
|
||||||
|
const timer = pendingPollers.get(tabId);
|
||||||
|
if (timer !== undefined) {
|
||||||
|
clearTimeout(timer);
|
||||||
|
pendingPollers.delete(tabId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function clearPageState(tabId) {
|
||||||
|
if (!Number.isInteger(tabId)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
pageStates.delete(tabId);
|
||||||
|
refreshJobs.delete(tabId);
|
||||||
|
clearPendingPoll(tabId);
|
||||||
|
await sessionStorageRemove(pageStateKey(tabId));
|
||||||
|
await clearActionState(tabId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function describeError(error) {
|
||||||
|
return error instanceof Error ? error.message : String(error || "Unknown error");
|
||||||
|
}
|
||||||
|
|
||||||
|
function approvalHintForState(state) {
|
||||||
|
if (!state.pendingFill) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
return state.pendingMessage || "Approve or deny the fill request in KeePassGO.";
|
||||||
|
}
|
||||||
|
|
||||||
|
function schedulePendingPoll(tabId, pageUrl) {
|
||||||
|
if (!Number.isInteger(tabId)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
clearPendingPoll(tabId);
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
pendingPollers.delete(tabId);
|
||||||
|
void refreshPageState(tabId, pageUrl, { force: true }).catch(() => null);
|
||||||
|
}, pendingPollMillis);
|
||||||
|
pendingPollers.set(tabId, timer);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function notifyContentState(tabId, state) {
|
||||||
|
if (!Number.isInteger(tabId)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await tabsSendMessage(tabId, {
|
||||||
|
type: "keepassgo-page-state",
|
||||||
|
state
|
||||||
|
});
|
||||||
|
} catch (_error) {
|
||||||
|
// Ignore pages without a ready content script.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function clearActionState(tabId) {
|
||||||
|
if (!Number.isInteger(tabId) || !ext.action) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await Promise.allSettled([
|
||||||
|
ext.action.setBadgeText({ tabId, text: "" }),
|
||||||
|
ext.action.setTitle({ tabId, title: "KeePassGO Browser" })
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function actionPresentationForState(state) {
|
||||||
|
let badgeText = "";
|
||||||
|
let title = "KeePassGO Browser";
|
||||||
|
let color = "#255f4a";
|
||||||
|
|
||||||
|
if (state.pendingFill) {
|
||||||
|
badgeText = "!";
|
||||||
|
color = "#9f5f0e";
|
||||||
|
title = approvalHintForState(state) || "KeePassGO approval needed for this page";
|
||||||
|
} else if (!state.configured) {
|
||||||
|
title = "Configure KeePassGO Browser in extension settings";
|
||||||
|
} else if (!state.success) {
|
||||||
|
badgeText = "!";
|
||||||
|
color = "#9f2f2f";
|
||||||
|
title = state.error || "KeePassGO is unavailable for this page";
|
||||||
|
} else if (state.status?.locked) {
|
||||||
|
title = "Unlock KeePassGO to fill this page";
|
||||||
|
} else if (state.pageHasLoginForm && state.matches.length > 0) {
|
||||||
|
badgeText = String(Math.min(state.matches.length, 9));
|
||||||
|
title = `KeePassGO found ${state.matches.length} matching entr${state.matches.length === 1 ? "y" : "ies"} on this page`;
|
||||||
|
} else if (state.pageHasLoginForm) {
|
||||||
|
title = "KeePassGO found no matching entries on this page";
|
||||||
|
}
|
||||||
|
|
||||||
|
return { badgeText, title, color };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateActionState(tabId, state) {
|
||||||
|
if (!Number.isInteger(tabId) || !ext.action) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const presentation = actionPresentationForState(state);
|
||||||
|
await Promise.allSettled([
|
||||||
|
ext.action.setBadgeText({ tabId, text: presentation.badgeText }),
|
||||||
|
ext.action.setBadgeBackgroundColor({ tabId, color: presentation.color }),
|
||||||
|
ext.action.setTitle({ tabId, title: presentation.title })
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function activePageContext() {
|
||||||
|
const [tab] = await tabsQuery({ active: true, currentWindow: true });
|
||||||
|
return {
|
||||||
|
tabId: Number.isInteger(tab?.id) ? tab.id : null,
|
||||||
|
url: typeof tab?.url === "string" ? tab.url : ""
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function scanTabForLoginForm(tabId) {
|
||||||
|
if (!Number.isInteger(tabId)) {
|
||||||
|
return { pageHasLoginForm: false, focusTarget: null, signature: "" };
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const response = await tabsSendMessage(tabId, { type: "keepassgo-page-scan" });
|
||||||
|
return {
|
||||||
|
pageHasLoginForm: Boolean(response?.pageHasLoginForm),
|
||||||
|
focusTarget: cloneTarget(response?.focusTarget),
|
||||||
|
signature: typeof response?.signature === "string" ? response.signature : ""
|
||||||
|
};
|
||||||
|
} catch (_error) {
|
||||||
|
return { pageHasLoginForm: false, focusTarget: null, signature: "" };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function shouldReuseMatches(state, force) {
|
||||||
|
if (force || state.pendingFill) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!state.pageHasLoginForm || !Array.isArray(state.matches)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return Date.now() - (state.updatedAt || 0) < matchCacheTTL;
|
||||||
|
}
|
||||||
|
|
||||||
|
function tokenPendingApprovalCount(status) {
|
||||||
|
return Number(status?.tokenPendingApprovalCount || 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchStatus(settings) {
|
||||||
|
if (!settings.bearerToken) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
configured: false,
|
||||||
|
status: null,
|
||||||
|
error: "Set an API token in extension settings."
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const status = await connectNative({
|
||||||
|
action: "status",
|
||||||
|
bearerToken: settings.bearerToken
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
success: Boolean(status?.success),
|
||||||
|
configured: true,
|
||||||
|
status: status?.status ?? null,
|
||||||
|
error: status?.error ?? ""
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshPageState(tabId, pageUrl, options = {}) {
|
||||||
|
if (!Number.isInteger(tabId)) {
|
||||||
|
return defaultPageState(null, pageUrl || "");
|
||||||
|
}
|
||||||
|
const force = Boolean(options.force);
|
||||||
|
const existingJob = refreshJobs.get(tabId);
|
||||||
|
if (existingJob && !force) {
|
||||||
|
return existingJob;
|
||||||
|
}
|
||||||
|
|
||||||
|
const job = (async () => {
|
||||||
|
let resolvedURL = typeof pageUrl === "string" ? pageUrl : "";
|
||||||
|
if (!supportsPageStateURL(resolvedURL)) {
|
||||||
|
const tab = await tabsGet(tabId).catch(() => null);
|
||||||
|
resolvedURL = typeof tab?.url === "string" ? tab.url : resolvedURL;
|
||||||
|
}
|
||||||
|
if (!supportsPageStateURL(resolvedURL)) {
|
||||||
|
await clearPageState(tabId);
|
||||||
|
return defaultPageState(tabId, resolvedURL || "");
|
||||||
|
}
|
||||||
|
|
||||||
|
let state = await getPageState(tabId, resolvedURL);
|
||||||
|
if (state.pageUrl !== resolvedURL) {
|
||||||
|
state = defaultPageState(tabId, resolvedURL);
|
||||||
|
}
|
||||||
|
|
||||||
|
const scan = typeof options.pageHasLoginForm === "boolean"
|
||||||
|
? {
|
||||||
|
pageHasLoginForm: options.pageHasLoginForm,
|
||||||
|
focusTarget: cloneTarget(options.focusTarget) || state.focusTarget,
|
||||||
|
signature: typeof options.signature === "string" ? options.signature : state.signature
|
||||||
|
}
|
||||||
|
: await scanTabForLoginForm(tabId);
|
||||||
|
|
||||||
|
state = {
|
||||||
|
...state,
|
||||||
|
pageUrl: resolvedURL,
|
||||||
|
pageHasLoginForm: scan.pageHasLoginForm,
|
||||||
|
focusTarget: cloneTarget(scan.focusTarget) || state.focusTarget,
|
||||||
|
signature: typeof scan.signature === "string" ? scan.signature : state.signature
|
||||||
|
};
|
||||||
|
|
||||||
|
const settings = await loadSettings();
|
||||||
|
const statusInfo = await fetchStatus(settings).catch((error) => ({
|
||||||
|
success: false,
|
||||||
|
configured: true,
|
||||||
|
status: null,
|
||||||
|
error: describeError(error)
|
||||||
|
}));
|
||||||
|
|
||||||
|
state = {
|
||||||
|
...state,
|
||||||
|
configured: statusInfo.configured,
|
||||||
|
success: statusInfo.success,
|
||||||
|
status: statusInfo.status,
|
||||||
|
pendingFill: state.pendingFill || tokenPendingApprovalCount(statusInfo.status) > 0,
|
||||||
|
pendingMessage: tokenPendingApprovalCount(statusInfo.status) > 0
|
||||||
|
? approvalHintForState(state) || "Approve or deny the browser fill request in KeePassGO."
|
||||||
|
: "",
|
||||||
|
error: statusInfo.error
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!statusInfo.configured || !statusInfo.success || statusInfo.status?.locked || !state.pageHasLoginForm) {
|
||||||
|
state.matches = [];
|
||||||
|
state.updatedAt = Date.now();
|
||||||
|
const saved = await setPageState(tabId, state);
|
||||||
|
if (saved.pendingFill) {
|
||||||
|
schedulePendingPoll(tabId, resolvedURL);
|
||||||
|
} else {
|
||||||
|
clearPendingPoll(tabId);
|
||||||
|
}
|
||||||
|
return saved;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (shouldReuseMatches(state, force)) {
|
||||||
|
const saved = await setPageState(tabId, state);
|
||||||
|
if (saved.pendingFill) {
|
||||||
|
schedulePendingPoll(tabId, resolvedURL);
|
||||||
|
} else {
|
||||||
|
clearPendingPoll(tabId);
|
||||||
|
}
|
||||||
|
return saved;
|
||||||
|
}
|
||||||
|
|
||||||
|
const matches = await connectNative({
|
||||||
|
action: "find-logins",
|
||||||
|
bearerToken: settings.bearerToken,
|
||||||
|
url: resolvedURL
|
||||||
|
});
|
||||||
|
|
||||||
|
state = {
|
||||||
|
...state,
|
||||||
|
success: Boolean(matches?.success),
|
||||||
|
status: matches?.status ?? state.status,
|
||||||
|
pendingFill: state.pendingFill || tokenPendingApprovalCount(matches?.status ?? state.status) > 0,
|
||||||
|
pendingMessage: tokenPendingApprovalCount(matches?.status ?? state.status) > 0
|
||||||
|
? approvalHintForState(state) || "Approve or deny the browser fill request in KeePassGO."
|
||||||
|
: "",
|
||||||
|
matches: Array.isArray(matches?.matches) ? matches.matches : [],
|
||||||
|
error: matches?.error ?? "",
|
||||||
|
updatedAt: Date.now()
|
||||||
|
};
|
||||||
|
const saved = await setPageState(tabId, state);
|
||||||
|
if (saved.pendingFill) {
|
||||||
|
schedulePendingPoll(tabId, resolvedURL);
|
||||||
|
} else {
|
||||||
|
clearPendingPoll(tabId);
|
||||||
|
}
|
||||||
|
return saved;
|
||||||
|
})().finally(() => {
|
||||||
|
if (refreshJobs.get(tabId) === job) {
|
||||||
|
refreshJobs.delete(tabId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
refreshJobs.set(tabId, job);
|
||||||
|
return job;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function statusForPage(options = {}) {
|
||||||
|
let page = await activePageContext();
|
||||||
|
if (Number.isInteger(options.tabId)) {
|
||||||
|
const tab = await tabsGet(options.tabId).catch(() => null);
|
||||||
|
page = {
|
||||||
|
tabId: options.tabId,
|
||||||
|
url: typeof tab?.url === "string" ? tab.url : ""
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (page.tabId == null) {
|
||||||
|
return defaultPageState(null, page.url);
|
||||||
|
}
|
||||||
|
if (!options.force) {
|
||||||
|
const cached = await getPageState(page.tabId, page.url);
|
||||||
|
if (cached.pageUrl === page.url && cached.updatedAt && !cached.pendingFill) {
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return refreshPageState(page.tabId, page.url, options);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fillLogin(tabId, entryId) {
|
||||||
|
if (!Number.isInteger(tabId)) {
|
||||||
|
throw new Error("No active tab is available.");
|
||||||
|
}
|
||||||
|
const tab = await tabsGet(tabId);
|
||||||
|
const pageUrl = typeof tab?.url === "string" ? tab.url : "";
|
||||||
|
if (!supportsPageStateURL(pageUrl)) {
|
||||||
|
throw new Error("This page cannot be filled.");
|
||||||
|
}
|
||||||
|
|
||||||
|
let state = await getPageState(tabId, pageUrl);
|
||||||
|
state = await setPageState(tabId, {
|
||||||
|
...state,
|
||||||
|
pageUrl,
|
||||||
|
pendingFill: true,
|
||||||
|
pendingEntryId: String(entryId || "").trim(),
|
||||||
|
pendingTarget: cloneTarget(state.focusTarget),
|
||||||
|
pendingMessage: "Approve or deny the browser fill request in KeePassGO.",
|
||||||
|
error: "",
|
||||||
|
updatedAt: Date.now()
|
||||||
|
});
|
||||||
|
schedulePendingPoll(tabId, pageUrl);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const settings = await loadSettings();
|
||||||
|
if (!settings.bearerToken) {
|
||||||
|
throw new Error("API token is not configured.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await connectNative({
|
||||||
|
action: "get-login",
|
||||||
|
bearerToken: settings.bearerToken,
|
||||||
|
entryId,
|
||||||
|
url: pageUrl
|
||||||
|
});
|
||||||
|
if (!response?.success || !response.credential) {
|
||||||
|
throw new Error(response?.error || "KeePassGO did not return a credential.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const fillResponse = await tabsSendMessage(tabId, {
|
||||||
|
type: "keepassgo-fill-credential",
|
||||||
|
credential: response.credential,
|
||||||
|
target: state.pendingTarget
|
||||||
|
});
|
||||||
|
if (!fillResponse?.ok) {
|
||||||
|
throw new Error(fillResponse?.error || "The current page could not be filled.");
|
||||||
|
}
|
||||||
|
|
||||||
|
state = await setPageState(tabId, {
|
||||||
|
...state,
|
||||||
|
pendingFill: false,
|
||||||
|
pendingEntryId: "",
|
||||||
|
pendingTarget: null,
|
||||||
|
pendingMessage: "",
|
||||||
|
lastFilledEntryId: String(entryId || "").trim(),
|
||||||
|
error: "",
|
||||||
|
updatedAt: Date.now()
|
||||||
|
});
|
||||||
|
clearPendingPoll(tabId);
|
||||||
|
return {
|
||||||
|
credential: response.credential,
|
||||||
|
pageUrl,
|
||||||
|
state
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
state = await setPageState(tabId, {
|
||||||
|
...state,
|
||||||
|
pendingFill: false,
|
||||||
|
pendingEntryId: "",
|
||||||
|
pendingTarget: null,
|
||||||
|
pendingMessage: "",
|
||||||
|
error: describeError(error),
|
||||||
|
updatedAt: Date.now()
|
||||||
|
});
|
||||||
|
clearPendingPoll(tabId);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshActivePage(options = {}) {
|
||||||
|
const page = await activePageContext();
|
||||||
|
if (page.tabId == null) {
|
||||||
|
return defaultPageState(null, page.url);
|
||||||
|
}
|
||||||
|
return refreshPageState(page.tabId, page.url, options);
|
||||||
|
}
|
||||||
|
|
||||||
|
const backgroundTestExports = {
|
||||||
|
normalizePageState,
|
||||||
|
actionPresentationForState,
|
||||||
|
shouldReuseMatches,
|
||||||
|
tokenPendingApprovalCount,
|
||||||
|
defaultSettings
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isNodeTestEnv) {
|
||||||
|
module.exports = backgroundTestExports;
|
||||||
|
} else {
|
||||||
|
ext.runtime.onMessage.addListener((message, sender, sendResponse) => {
|
||||||
|
(async () => {
|
||||||
|
switch (message?.type) {
|
||||||
|
case "keepassgo-popup-state":
|
||||||
|
sendResponse(await statusForPage({
|
||||||
|
force: Boolean(message.force),
|
||||||
|
tabId: Number.isInteger(message?.tabId) ? message.tabId : null
|
||||||
|
}));
|
||||||
|
return;
|
||||||
|
case "keepassgo-fill-entry": {
|
||||||
|
const targetTabID = Number.isInteger(message?.tabId)
|
||||||
|
? message.tabId
|
||||||
|
: (Number.isInteger(sender?.tab?.id) ? sender.tab.id : (await activePageContext()).tabId);
|
||||||
|
if (Number.isInteger(targetTabID) && message.target) {
|
||||||
|
const targetState = await getPageState(targetTabID, "");
|
||||||
|
await setPageState(targetTabID, {
|
||||||
|
...targetState,
|
||||||
|
focusTarget: cloneTarget(message.target)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
sendResponse({ success: true, ...(await fillLogin(targetTabID, message.entryId)) });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
case "keepassgo-load-settings":
|
||||||
|
sendResponse({ success: true, settings: await loadSettings() });
|
||||||
|
return;
|
||||||
|
case "keepassgo-save-settings":
|
||||||
|
await storageSet({
|
||||||
|
bearerToken: String(message.settings?.bearerToken || "").trim()
|
||||||
|
});
|
||||||
|
await refreshActivePage({ force: true }).catch(() => null);
|
||||||
|
sendResponse({ success: true });
|
||||||
|
return;
|
||||||
|
case "keepassgo-page-ready":
|
||||||
|
if (Number.isInteger(sender?.tab?.id)) {
|
||||||
|
sendResponse(await refreshPageState(sender.tab.id, sender.tab.url, {
|
||||||
|
force: Boolean(message.force),
|
||||||
|
pageHasLoginForm: Boolean(message.pageHasLoginForm),
|
||||||
|
focusTarget: cloneTarget(message.focusTarget),
|
||||||
|
signature: typeof message.signature === "string" ? message.signature : ""
|
||||||
|
}));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
sendResponse(defaultPageState(null, ""));
|
||||||
|
return;
|
||||||
|
case "keepassgo-refresh-page-state":
|
||||||
|
if (Number.isInteger(sender?.tab?.id)) {
|
||||||
|
sendResponse(await refreshPageState(sender.tab.id, sender.tab.url, { force: true }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
sendResponse(defaultPageState(null, ""));
|
||||||
|
return;
|
||||||
|
default:
|
||||||
|
sendResponse({ success: false, error: `Unsupported message ${message?.type || ""}`.trim() });
|
||||||
|
}
|
||||||
|
})().catch((error) => {
|
||||||
|
sendResponse({ success: false, error: describeError(error) });
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
ext.tabs?.onActivated?.addListener(({ tabId }) => {
|
||||||
|
void refreshPageState(tabId, "", { force: false }).catch(() => null);
|
||||||
|
});
|
||||||
|
|
||||||
|
ext.tabs?.onUpdated?.addListener((tabId, changeInfo, tab) => {
|
||||||
|
if (typeof changeInfo.url === "string") {
|
||||||
|
void clearPageState(tabId).catch(() => null);
|
||||||
|
}
|
||||||
|
if (changeInfo.status === "complete") {
|
||||||
|
void refreshPageState(tabId, tab?.url || changeInfo.url || "", { force: false }).catch(() => null);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
ext.tabs?.onRemoved?.addListener((tabId) => {
|
||||||
|
void clearPageState(tabId).catch(() => null);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
const test = require("node:test");
|
||||||
|
const assert = require("node:assert/strict");
|
||||||
|
|
||||||
|
const background = require("./background.js");
|
||||||
|
|
||||||
|
test("normalizePageState preserves focused and pending field targets", () => {
|
||||||
|
const state = background.normalizePageState({
|
||||||
|
tabId: 7,
|
||||||
|
pageUrl: "https://vault.example.invalid/login",
|
||||||
|
focusTarget: { role: "username", formIndex: 0, fieldIndex: 1 },
|
||||||
|
pendingTarget: { role: "password", formIndex: 0, fieldIndex: 2 }
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.deepEqual(state.focusTarget, { role: "username", formIndex: 0, fieldIndex: 1 });
|
||||||
|
assert.deepEqual(state.pendingTarget, { role: "password", formIndex: 0, fieldIndex: 2 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("shouldReuseMatches only reuses recent non-pending page matches", () => {
|
||||||
|
const recentState = {
|
||||||
|
pageHasLoginForm: true,
|
||||||
|
matches: [{ id: "vault-console" }],
|
||||||
|
pendingFill: false,
|
||||||
|
updatedAt: Date.now()
|
||||||
|
};
|
||||||
|
assert.equal(background.shouldReuseMatches(recentState, false), true);
|
||||||
|
|
||||||
|
assert.equal(background.shouldReuseMatches({ ...recentState, pendingFill: true }, false), false);
|
||||||
|
assert.equal(background.shouldReuseMatches({ ...recentState, pageHasLoginForm: false }, false), false);
|
||||||
|
assert.equal(background.shouldReuseMatches(recentState, true), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("actionPresentationForState prioritizes approval visibility", () => {
|
||||||
|
const presentation = background.actionPresentationForState({
|
||||||
|
pendingFill: true,
|
||||||
|
pendingMessage: "Approve the browser fill request in KeePassGO.",
|
||||||
|
configured: true,
|
||||||
|
success: true,
|
||||||
|
pageHasLoginForm: true,
|
||||||
|
matches: [{ id: "vault-console" }]
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(presentation.badgeText, "!");
|
||||||
|
assert.equal(presentation.color, "#9f5f0e");
|
||||||
|
assert.match(presentation.title, /approve/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("tokenPendingApprovalCount reads token-scoped approval state", () => {
|
||||||
|
assert.equal(background.tokenPendingApprovalCount({ tokenPendingApprovalCount: 2 }), 2);
|
||||||
|
assert.equal(background.tokenPendingApprovalCount({}), 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("default settings include a blank bearer token that can be overridden by harness patching", () => {
|
||||||
|
assert.equal(background.defaultSettings.bearerToken, "");
|
||||||
|
});
|
||||||
@@ -0,0 +1,861 @@
|
|||||||
|
const ext = globalThis.browser ?? globalThis.chrome;
|
||||||
|
const isNodeTestEnv = typeof module !== "undefined" && module.exports;
|
||||||
|
const usePromiseAPI = typeof globalThis.browser !== "undefined";
|
||||||
|
|
||||||
|
function runtimeSend(message) {
|
||||||
|
if (usePromiseAPI) {
|
||||||
|
return ext.runtime.sendMessage(message);
|
||||||
|
}
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
ext.runtime.sendMessage(message, (response) => {
|
||||||
|
const error = ext.runtime.lastError;
|
||||||
|
if (error) {
|
||||||
|
reject(new Error(error.message));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
resolve(response);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function isVisibleInput(input) {
|
||||||
|
if (!(input instanceof HTMLInputElement)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (input.disabled || input.readOnly) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const style = window.getComputedStyle(input);
|
||||||
|
if (style.display === "none" || style.visibility === "hidden") {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return input.offsetParent !== null || style.position === "fixed";
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeRole(rawRole) {
|
||||||
|
switch (String(rawRole || "").trim().toLowerCase()) {
|
||||||
|
case "password":
|
||||||
|
return "password";
|
||||||
|
case "username":
|
||||||
|
return "username";
|
||||||
|
default:
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function lowerJoined(values) {
|
||||||
|
return values
|
||||||
|
.map((value) => String(value || "").trim().toLowerCase())
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(" ");
|
||||||
|
}
|
||||||
|
|
||||||
|
function fieldHintText(input) {
|
||||||
|
if (!input || typeof input !== "object") {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
const labels = input.labels ? Array.from(input.labels).map((label) => label.textContent || "") : [];
|
||||||
|
return lowerJoined([
|
||||||
|
input.getAttribute?.("type"),
|
||||||
|
input.getAttribute?.("name"),
|
||||||
|
input.getAttribute?.("id"),
|
||||||
|
input.autocomplete,
|
||||||
|
input.getAttribute?.("autocomplete"),
|
||||||
|
input.getAttribute?.("placeholder"),
|
||||||
|
input.getAttribute?.("aria-label"),
|
||||||
|
...labels
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function textLikeInputType(type) {
|
||||||
|
switch (String(type || "").toLowerCase()) {
|
||||||
|
case "":
|
||||||
|
case "text":
|
||||||
|
case "email":
|
||||||
|
case "tel":
|
||||||
|
case "number":
|
||||||
|
return true;
|
||||||
|
default:
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function hintMatches(text, patterns) {
|
||||||
|
return patterns.some((pattern) => pattern.test(text));
|
||||||
|
}
|
||||||
|
|
||||||
|
function scopeHintText(scope) {
|
||||||
|
const isDocumentScope = typeof document !== "undefined" && scope === document;
|
||||||
|
if ((!scope || typeof scope !== "object") || (!isDocumentScope && typeof scope.querySelectorAll !== "function" && typeof scope.getAttribute !== "function")) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
const attrText = isDocumentScope ? "" : lowerJoined([
|
||||||
|
scope.getAttribute?.("id"),
|
||||||
|
scope.getAttribute?.("name"),
|
||||||
|
scope.getAttribute?.("class"),
|
||||||
|
scope.getAttribute?.("action"),
|
||||||
|
scope.getAttribute?.("aria-label")
|
||||||
|
]);
|
||||||
|
const headingText = lowerJoined(Array.from(scope.querySelectorAll?.("button, h1, h2, h3, h4, legend, label, [role='button']") || [])
|
||||||
|
.slice(0, 8)
|
||||||
|
.map((element) => element.textContent || ""));
|
||||||
|
return lowerJoined([attrText, headingText]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasAuthFlowSignals(usernameInput, scope) {
|
||||||
|
if (usernameInput) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return hintMatches(scopeHintText(scope), authScopePatterns);
|
||||||
|
}
|
||||||
|
|
||||||
|
const usernameHintPatterns = [
|
||||||
|
/\buser(name|id)?\b/,
|
||||||
|
/\blog[\s_-]?in\b/,
|
||||||
|
/\bsign[\s_-]?in\b/,
|
||||||
|
/\bemail\b/,
|
||||||
|
/\be-mail\b/,
|
||||||
|
/\baccount\b/,
|
||||||
|
/\bmember\b/,
|
||||||
|
/\bidentifier\b/
|
||||||
|
];
|
||||||
|
|
||||||
|
const nonLoginHintPatterns = [
|
||||||
|
/\bsearch\b/,
|
||||||
|
/\bquery\b/,
|
||||||
|
/\bfilter\b/,
|
||||||
|
/\bcomment\b/,
|
||||||
|
/\bmessage\b/,
|
||||||
|
/\bcontact\b/,
|
||||||
|
/\bcity\b/,
|
||||||
|
/\bstate\b/,
|
||||||
|
/\bpostal\b/,
|
||||||
|
/\bzip\b/,
|
||||||
|
/\bcoupon\b/,
|
||||||
|
/\bpromo\b/,
|
||||||
|
/\bnewsletter\b/,
|
||||||
|
/\bsubscribe\b/
|
||||||
|
];
|
||||||
|
|
||||||
|
const authScopePatterns = [
|
||||||
|
/\blog[\s_-]?in\b/,
|
||||||
|
/\bsign[\s_-]?in\b/,
|
||||||
|
/\bauth\b/,
|
||||||
|
/\bpassword\b/,
|
||||||
|
/\bpasscode\b/,
|
||||||
|
/\b2fa\b/,
|
||||||
|
/\btwo[\s-]?factor\b/,
|
||||||
|
/\bverify\b/,
|
||||||
|
/\baccount\b/
|
||||||
|
];
|
||||||
|
|
||||||
|
function describeFieldRole(input) {
|
||||||
|
const type = String(input?.getAttribute?.("type") || "").toLowerCase();
|
||||||
|
if (type === "password") {
|
||||||
|
return "password";
|
||||||
|
}
|
||||||
|
if (!textLikeInputType(type)) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
const hints = fieldHintText(input);
|
||||||
|
if (!hints) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
if (hintMatches(hints, nonLoginHintPatterns)) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
if (hintMatches(hints, usernameHintPatterns)) {
|
||||||
|
return "username";
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function isUsernameCandidate(input) {
|
||||||
|
if (!isVisibleInput(input)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return describeFieldRole(input) === "username";
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPasswordCandidate(input) {
|
||||||
|
return isVisibleInput(input) && describeFieldRole(input) === "password";
|
||||||
|
}
|
||||||
|
|
||||||
|
function dispatchFillEvents(input) {
|
||||||
|
if (typeof InputEvent === "function") {
|
||||||
|
input.dispatchEvent(new InputEvent("input", { bubbles: true, data: input.value, inputType: "insertText" }));
|
||||||
|
} else {
|
||||||
|
input.dispatchEvent(new Event("input", { bubbles: true }));
|
||||||
|
}
|
||||||
|
input.dispatchEvent(new Event("change", { bubbles: true }));
|
||||||
|
}
|
||||||
|
|
||||||
|
function setInputValue(input, value) {
|
||||||
|
const prototype = Object.getPrototypeOf(input);
|
||||||
|
const descriptor = prototype ? Object.getOwnPropertyDescriptor(prototype, "value") : null;
|
||||||
|
if (descriptor?.set) {
|
||||||
|
descriptor.set.call(input, value);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
input.value = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function visibleInputs(scope) {
|
||||||
|
return Array.from(scope.querySelectorAll("input")).filter(isVisibleInput);
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveFormInputs(anchorInput) {
|
||||||
|
if (anchorInput?.form instanceof HTMLFormElement) {
|
||||||
|
return visibleInputs(anchorInput.form);
|
||||||
|
}
|
||||||
|
return visibleInputs(document);
|
||||||
|
}
|
||||||
|
|
||||||
|
function firstVisiblePassword(scope) {
|
||||||
|
return visibleInputs(scope).find(isPasswordCandidate) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function firstVisibleUsername(scope) {
|
||||||
|
return visibleInputs(scope).find(isUsernameCandidate) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function authFlowCandidate(anchorInput) {
|
||||||
|
const scope = (typeof HTMLFormElement !== "undefined" && anchorInput?.form instanceof HTMLFormElement ? anchorInput.form : document);
|
||||||
|
const scopeInputs = resolveFormInputs(anchorInput);
|
||||||
|
const passwordInput = scopeInputs.find(isPasswordCandidate) || null;
|
||||||
|
if (!passwordInput) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const associated = associatedFieldsForAnchor(anchorInput || passwordInput);
|
||||||
|
if (!hasAuthFlowSignals(associated.usernameInput, scope)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
usernameInput: associated.usernameInput,
|
||||||
|
passwordInput,
|
||||||
|
anchorInput: anchorInput || passwordInput,
|
||||||
|
scope
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function loginCandidates() {
|
||||||
|
const candidates = [];
|
||||||
|
for (const passwordInput of visibleInputs(document).filter(isPasswordCandidate)) {
|
||||||
|
const candidate = authFlowCandidate(passwordInput);
|
||||||
|
if (!candidate) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (candidates.some((existing) => existing.passwordInput === candidate.passwordInput)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
candidates.push(candidate);
|
||||||
|
}
|
||||||
|
return candidates;
|
||||||
|
}
|
||||||
|
|
||||||
|
function associatedFieldsForAnchor(anchorInput) {
|
||||||
|
const scopeInputs = resolveFormInputs(anchorInput);
|
||||||
|
const passwordInput = scopeInputs.find(isPasswordCandidate) || firstVisiblePassword(document);
|
||||||
|
const usernameInScope = scopeInputs.filter(isUsernameCandidate);
|
||||||
|
let usernameInput = usernameInScope[0] || null;
|
||||||
|
if (passwordInput && usernameInScope.length !== 0) {
|
||||||
|
const priorSibling = usernameInScope.find((input) =>
|
||||||
|
typeof input.compareDocumentPosition === "function" &&
|
||||||
|
Boolean(input.compareDocumentPosition(passwordInput) & Node.DOCUMENT_POSITION_FOLLOWING)
|
||||||
|
);
|
||||||
|
usernameInput = priorSibling || usernameInScope[0] || null;
|
||||||
|
}
|
||||||
|
if (!usernameInput) {
|
||||||
|
usernameInput = firstVisibleUsername(document);
|
||||||
|
}
|
||||||
|
return { usernameInput, passwordInput };
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildFieldDescriptor(input, role) {
|
||||||
|
if (typeof HTMLInputElement === "undefined" || !(input instanceof HTMLInputElement)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const normalizedRole = normalizeRole(role || describeFieldRole(input));
|
||||||
|
const form = typeof HTMLFormElement !== "undefined" && input.form instanceof HTMLFormElement ? input.form : null;
|
||||||
|
const scope = form || document;
|
||||||
|
const inputs = visibleInputs(scope);
|
||||||
|
const fieldIndex = inputs.indexOf(input);
|
||||||
|
const forms = Array.from(document.forms || []);
|
||||||
|
return {
|
||||||
|
role: normalizedRole,
|
||||||
|
formIndex: form ? forms.indexOf(form) : -1,
|
||||||
|
fieldIndex,
|
||||||
|
id: String(input.id || ""),
|
||||||
|
name: String(input.name || ""),
|
||||||
|
autocomplete: String(input.autocomplete || "").toLowerCase()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveFieldDescriptor(descriptor) {
|
||||||
|
if (!descriptor || typeof descriptor !== "object") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const normalizedRole = normalizeRole(descriptor.role);
|
||||||
|
if (!normalizedRole) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const forms = Array.from(document.forms || []);
|
||||||
|
const form = Number.isInteger(descriptor.formIndex) && descriptor.formIndex >= 0 ? forms[descriptor.formIndex] || null : null;
|
||||||
|
const scope = form || document;
|
||||||
|
const inputs = visibleInputs(scope);
|
||||||
|
if (Number.isInteger(descriptor.fieldIndex) && descriptor.fieldIndex >= 0 && descriptor.fieldIndex < inputs.length) {
|
||||||
|
const candidate = inputs[descriptor.fieldIndex];
|
||||||
|
if (describeFieldRole(candidate) === normalizedRole) {
|
||||||
|
return candidate;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (descriptor.id) {
|
||||||
|
const byID = scope.querySelector(`#${CSS.escape(descriptor.id)}`);
|
||||||
|
if (byID instanceof HTMLInputElement && isVisibleInput(byID) && describeFieldRole(byID) === normalizedRole) {
|
||||||
|
return byID;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (descriptor.name) {
|
||||||
|
const byName = visibleInputs(scope).find((input) => input.name === descriptor.name && describeFieldRole(input) === normalizedRole);
|
||||||
|
if (byName) {
|
||||||
|
return byName;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (normalizedRole === "password") {
|
||||||
|
return firstVisiblePassword(scope) || firstVisiblePassword(document);
|
||||||
|
}
|
||||||
|
return firstVisibleUsername(scope) || firstVisibleUsername(document);
|
||||||
|
}
|
||||||
|
|
||||||
|
function chooseFillTargets(targetDescriptor) {
|
||||||
|
const anchorInput = resolveFieldDescriptor(targetDescriptor) || (document.activeElement instanceof HTMLInputElement ? document.activeElement : null);
|
||||||
|
const associated = associatedFieldsForAnchor(anchorInput);
|
||||||
|
if (normalizeRole(targetDescriptor?.role) === "password" && anchorInput instanceof HTMLInputElement) {
|
||||||
|
return {
|
||||||
|
usernameInput: associated.usernameInput,
|
||||||
|
passwordInput: isPasswordCandidate(anchorInput) ? anchorInput : associated.passwordInput,
|
||||||
|
anchorInput
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (normalizeRole(targetDescriptor?.role) === "username" && anchorInput instanceof HTMLInputElement) {
|
||||||
|
return {
|
||||||
|
usernameInput: isUsernameCandidate(anchorInput) ? anchorInput : associated.usernameInput,
|
||||||
|
passwordInput: associated.passwordInput,
|
||||||
|
anchorInput
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
usernameInput: associated.usernameInput,
|
||||||
|
passwordInput: associated.passwordInput,
|
||||||
|
anchorInput: anchorInput || associated.passwordInput || associated.usernameInput || null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function scanLoginFields() {
|
||||||
|
const activeElement = document.activeElement instanceof HTMLInputElement ? document.activeElement : null;
|
||||||
|
const activeUsable = activeElement && isVisibleInput(activeElement) ? activeElement : null;
|
||||||
|
const explicitRole = describeFieldRole(activeUsable);
|
||||||
|
const activeTargets = activeUsable ? authFlowCandidate(activeUsable) : null;
|
||||||
|
const candidates = loginCandidates();
|
||||||
|
const chosen = activeTargets || candidates[0] || null;
|
||||||
|
const anchorInput = activeUsable || chosen?.passwordInput || chosen?.usernameInput || null;
|
||||||
|
const focusRole = explicitRole || describeFieldRole(anchorInput);
|
||||||
|
const focusTarget = anchorInput ? buildFieldDescriptor(anchorInput, focusRole) : null;
|
||||||
|
const roles = candidates.map((candidate) => {
|
||||||
|
const passwordDescriptor = buildFieldDescriptor(candidate.passwordInput, "password");
|
||||||
|
const usernameDescriptor = candidate.usernameInput ? buildFieldDescriptor(candidate.usernameInput, "username") : null;
|
||||||
|
return `${passwordDescriptor.formIndex}:${passwordDescriptor.fieldIndex}:password:${usernameDescriptor ? `${usernameDescriptor.formIndex}:${usernameDescriptor.fieldIndex}` : "-"}`;
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
pageHasLoginForm: Boolean(chosen),
|
||||||
|
usernameInput: chosen?.usernameInput || null,
|
||||||
|
passwordInput: chosen?.passwordInput || null,
|
||||||
|
anchorInput,
|
||||||
|
focusTarget,
|
||||||
|
signature: roles.join("|")
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function fillCredential(credential, targetDescriptor) {
|
||||||
|
const { passwordInput, usernameInput } = chooseFillTargets(targetDescriptor);
|
||||||
|
|
||||||
|
if (usernameInput && credential.username) {
|
||||||
|
usernameInput.focus();
|
||||||
|
setInputValue(usernameInput, credential.username);
|
||||||
|
dispatchFillEvents(usernameInput);
|
||||||
|
}
|
||||||
|
if (passwordInput && credential.password) {
|
||||||
|
passwordInput.focus();
|
||||||
|
setInputValue(passwordInput, credential.password);
|
||||||
|
dispatchFillEvents(passwordInput);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!usernameInput && !passwordInput) {
|
||||||
|
return { ok: false, error: "No fillable username or password fields were found." };
|
||||||
|
}
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
function domainLabel(rawURL) {
|
||||||
|
try {
|
||||||
|
return new URL(rawURL).host || "";
|
||||||
|
} catch (_error) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function inlineMatchSummary(match) {
|
||||||
|
const parts = [];
|
||||||
|
if (match.username) {
|
||||||
|
parts.push(match.username);
|
||||||
|
}
|
||||||
|
if (match.url) {
|
||||||
|
const host = domainLabel(match.url);
|
||||||
|
if (host) {
|
||||||
|
parts.push(host);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (Array.isArray(match.path) && match.path.length !== 0) {
|
||||||
|
parts.push(match.path.join(" / "));
|
||||||
|
}
|
||||||
|
return parts.join(" · ") || "No username";
|
||||||
|
}
|
||||||
|
|
||||||
|
function shouldShowInlineOverlay(state, hasTarget, suppressed, idleHidden) {
|
||||||
|
if (suppressed || idleHidden || !hasTarget) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return Boolean(
|
||||||
|
state?.pageHasLoginForm &&
|
||||||
|
(
|
||||||
|
state?.pendingFill ||
|
||||||
|
(state?.configured && state?.success && !state?.status?.locked && Array.isArray(state?.matches) && state.matches.length > 0)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const contentTestExports = {
|
||||||
|
normalizeRole,
|
||||||
|
describeFieldRole,
|
||||||
|
buildFieldDescriptor,
|
||||||
|
resolveFieldDescriptor,
|
||||||
|
chooseFillTargets,
|
||||||
|
inlineMatchSummary,
|
||||||
|
domainLabel,
|
||||||
|
shouldShowInlineOverlay,
|
||||||
|
fieldHintText,
|
||||||
|
scopeHintText,
|
||||||
|
hasAuthFlowSignals,
|
||||||
|
authFlowCandidate
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isNodeTestEnv) {
|
||||||
|
module.exports = contentTestExports;
|
||||||
|
} else {
|
||||||
|
let pageState = {
|
||||||
|
configured: true,
|
||||||
|
success: true,
|
||||||
|
matches: [],
|
||||||
|
pageHasLoginForm: false,
|
||||||
|
pendingFill: false,
|
||||||
|
error: "",
|
||||||
|
focusTarget: null
|
||||||
|
};
|
||||||
|
let chooserOpen = false;
|
||||||
|
let inlineSuppressed = false;
|
||||||
|
let inlineIdleHidden = false;
|
||||||
|
let refreshTimer = null;
|
||||||
|
let idleHideTimer = null;
|
||||||
|
let lastReportedSignature = "";
|
||||||
|
let lastReportedTarget = "";
|
||||||
|
|
||||||
|
const root = document.createElement("div");
|
||||||
|
root.id = "keepassgo-inline-root";
|
||||||
|
root.setAttribute("aria-live", "polite");
|
||||||
|
const shadow = root.attachShadow({ mode: "open" });
|
||||||
|
shadow.innerHTML = `
|
||||||
|
<style>
|
||||||
|
:host {
|
||||||
|
all: initial;
|
||||||
|
}
|
||||||
|
.dock {
|
||||||
|
position: fixed;
|
||||||
|
z-index: 2147483647;
|
||||||
|
display: none;
|
||||||
|
min-width: 220px;
|
||||||
|
max-width: min(340px, calc(100vw - 24px));
|
||||||
|
font: 13px/1.35 "Noto Sans", "Liberation Sans", sans-serif;
|
||||||
|
color: #214f44;
|
||||||
|
}
|
||||||
|
.dock[data-open="true"] .panel {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
.trigger {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border: 1px solid #c6d8cf;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: linear-gradient(180deg, #ffffff, #edf5f0);
|
||||||
|
color: #214f44;
|
||||||
|
box-shadow: 0 12px 26px rgba(33, 79, 68, 0.16);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.trigger[data-tone="warning"] {
|
||||||
|
border-color: #e4d0ae;
|
||||||
|
background: linear-gradient(180deg, #fff8ed, #f9edd6);
|
||||||
|
color: #7f4b09;
|
||||||
|
}
|
||||||
|
.trigger[data-tone="error"] {
|
||||||
|
border-color: #e4bcbc;
|
||||||
|
background: linear-gradient(180deg, #fff5f5, #f9e7e7);
|
||||||
|
color: #8c2f2f;
|
||||||
|
}
|
||||||
|
.brand {
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
}
|
||||||
|
.meta {
|
||||||
|
color: #4d6d66;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.panel {
|
||||||
|
display: none;
|
||||||
|
margin-top: 8px;
|
||||||
|
border: 1px solid #d7e3dc;
|
||||||
|
border-radius: 16px;
|
||||||
|
background: #fffdfa;
|
||||||
|
box-shadow: 0 18px 42px rgba(33, 79, 68, 0.22);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.panel-header {
|
||||||
|
padding: 12px 14px 10px;
|
||||||
|
border-bottom: 1px solid #e6efea;
|
||||||
|
background: linear-gradient(180deg, #f8fbf8, #f1f6f3);
|
||||||
|
}
|
||||||
|
.panel-title {
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
.panel-copy {
|
||||||
|
margin-top: 4px;
|
||||||
|
color: #4d6d66;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.match-list {
|
||||||
|
display: grid;
|
||||||
|
gap: 0;
|
||||||
|
max-height: 280px;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
.match {
|
||||||
|
display: grid;
|
||||||
|
gap: 3px;
|
||||||
|
width: 100%;
|
||||||
|
padding: 12px 14px;
|
||||||
|
border: 0;
|
||||||
|
border-top: 1px solid #eef4f0;
|
||||||
|
background: #fffdfa;
|
||||||
|
color: #214f44;
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.match:hover,
|
||||||
|
.match:focus-visible {
|
||||||
|
background: #edf5f0;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
.match strong {
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.pill {
|
||||||
|
display: inline-flex;
|
||||||
|
width: fit-content;
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #e7f1eb;
|
||||||
|
color: #255f4a;
|
||||||
|
font-size: 11px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
}
|
||||||
|
.subtle {
|
||||||
|
color: #4d6d66;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.empty {
|
||||||
|
padding: 12px 14px;
|
||||||
|
color: #4d6d66;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
<div class="dock" data-open="false">
|
||||||
|
<button type="button" class="trigger" data-tone="ready">
|
||||||
|
<span class="brand">KeePassGO</span>
|
||||||
|
<span class="meta">Checking this form</span>
|
||||||
|
</button>
|
||||||
|
<div class="panel">
|
||||||
|
<div class="panel-header">
|
||||||
|
<div class="panel-title">KeePassGO suggestions</div>
|
||||||
|
<div class="panel-copy">Select a matching login for this field.</div>
|
||||||
|
</div>
|
||||||
|
<div class="match-list"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
const dock = shadow.querySelector(".dock");
|
||||||
|
const trigger = shadow.querySelector(".trigger");
|
||||||
|
const meta = shadow.querySelector(".meta");
|
||||||
|
const matchList = shadow.querySelector(".match-list");
|
||||||
|
const panelCopy = shadow.querySelector(".panel-copy");
|
||||||
|
|
||||||
|
function ensureRootMounted() {
|
||||||
|
if (!root.isConnected) {
|
||||||
|
document.documentElement.appendChild(root);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function currentTarget() {
|
||||||
|
return chooseFillTargets(pageState.focusTarget).anchorInput;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hideDock() {
|
||||||
|
chooserOpen = false;
|
||||||
|
dock.style.display = "none";
|
||||||
|
dock.dataset.open = "false";
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearIdleHideTimer() {
|
||||||
|
if (idleHideTimer !== null) {
|
||||||
|
clearTimeout(idleHideTimer);
|
||||||
|
idleHideTimer = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function refreshInlineLifetime(shouldShow) {
|
||||||
|
clearIdleHideTimer();
|
||||||
|
if (!shouldShow || chooserOpen || pageState.pendingFill) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
idleHideTimer = window.setTimeout(() => {
|
||||||
|
inlineIdleHidden = true;
|
||||||
|
hideDock();
|
||||||
|
}, 15000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function positionDock() {
|
||||||
|
const anchor = currentTarget();
|
||||||
|
if (!anchor || dock.style.display === "none") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const rect = anchor.getBoundingClientRect();
|
||||||
|
const width = Math.min(340, Math.max(220, rect.width));
|
||||||
|
const left = Math.min(window.innerWidth - width - 12, Math.max(12, rect.left));
|
||||||
|
const top = Math.min(window.innerHeight - 16, rect.bottom + 8);
|
||||||
|
dock.style.left = `${left}px`;
|
||||||
|
dock.style.top = `${top}px`;
|
||||||
|
dock.style.width = `${width}px`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderMatches() {
|
||||||
|
matchList.textContent = "";
|
||||||
|
if (pageState.pendingFill) {
|
||||||
|
const pending = document.createElement("div");
|
||||||
|
pending.className = "empty";
|
||||||
|
pending.textContent = pageState.pendingMessage || "Approve or deny the fill request in KeePassGO.";
|
||||||
|
matchList.appendChild(pending);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!Array.isArray(pageState.matches) || pageState.matches.length === 0) {
|
||||||
|
const empty = document.createElement("div");
|
||||||
|
empty.className = "empty";
|
||||||
|
empty.textContent = pageState.error || "No matching entries were found for this page.";
|
||||||
|
matchList.appendChild(empty);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const match of pageState.matches) {
|
||||||
|
const row = document.createElement("button");
|
||||||
|
row.type = "button";
|
||||||
|
row.className = "match";
|
||||||
|
const title = document.createElement("strong");
|
||||||
|
title.textContent = match.title;
|
||||||
|
const summary = document.createElement("span");
|
||||||
|
summary.className = "subtle";
|
||||||
|
summary.textContent = inlineMatchSummary(match);
|
||||||
|
const quality = document.createElement("span");
|
||||||
|
quality.className = "pill";
|
||||||
|
quality.textContent = match.quality || "Candidate";
|
||||||
|
row.appendChild(title);
|
||||||
|
row.appendChild(summary);
|
||||||
|
row.appendChild(quality);
|
||||||
|
row.addEventListener("click", async () => {
|
||||||
|
row.disabled = true;
|
||||||
|
inlineSuppressed = true;
|
||||||
|
hideDock();
|
||||||
|
try {
|
||||||
|
await runtimeSend({
|
||||||
|
type: "keepassgo-fill-entry",
|
||||||
|
entryId: match.id,
|
||||||
|
target: pageState.focusTarget
|
||||||
|
});
|
||||||
|
} catch (_error) {
|
||||||
|
pageState = {
|
||||||
|
...pageState,
|
||||||
|
pendingFill: false,
|
||||||
|
error: "KeePassGO could not fill this page."
|
||||||
|
};
|
||||||
|
renderInlineState();
|
||||||
|
} finally {
|
||||||
|
row.disabled = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
matchList.appendChild(row);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderInlineState() {
|
||||||
|
const target = currentTarget();
|
||||||
|
const shouldShow = shouldShowInlineOverlay(pageState, Boolean(target), inlineSuppressed, inlineIdleHidden);
|
||||||
|
|
||||||
|
if (!shouldShow) {
|
||||||
|
clearIdleHideTimer();
|
||||||
|
hideDock();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
ensureRootMounted();
|
||||||
|
dock.style.display = "block";
|
||||||
|
trigger.dataset.tone = pageState.pendingFill ? "warning" : (pageState.error ? "error" : "ready");
|
||||||
|
if (pageState.pendingFill) {
|
||||||
|
meta.textContent = "Approval needed in KeePassGO";
|
||||||
|
panelCopy.textContent = pageState.pendingMessage || "Approve or deny the fill request in KeePassGO.";
|
||||||
|
} else {
|
||||||
|
const count = Array.isArray(pageState.matches) ? pageState.matches.length : 0;
|
||||||
|
meta.textContent = count === 1 ? "1 login ready" : `${count} logins ready`;
|
||||||
|
panelCopy.textContent = "Select a matching login for this field.";
|
||||||
|
}
|
||||||
|
dock.dataset.open = chooserOpen ? "true" : "false";
|
||||||
|
renderMatches();
|
||||||
|
positionDock();
|
||||||
|
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("click", (event) => {
|
||||||
|
if (!root.contains(event.target)) {
|
||||||
|
chooserOpen = false;
|
||||||
|
renderInlineState();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
window.addEventListener("scroll", () => {
|
||||||
|
positionDock();
|
||||||
|
}, true);
|
||||||
|
|
||||||
|
window.addEventListener("resize", () => {
|
||||||
|
positionDock();
|
||||||
|
});
|
||||||
|
|
||||||
|
const observer = new MutationObserver((records) => {
|
||||||
|
if (records.some((record) => record.type === "childList")) {
|
||||||
|
scheduleRefresh(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
observer.observe(document.documentElement, {
|
||||||
|
childList: true,
|
||||||
|
subtree: true
|
||||||
|
});
|
||||||
|
|
||||||
|
ext.runtime.onMessage.addListener((message, _sender, sendResponse) => {
|
||||||
|
if (message?.type === "keepassgo-fill-credential") {
|
||||||
|
try {
|
||||||
|
sendResponse(fillCredential(message.credential || {}, message.target || pageState.focusTarget));
|
||||||
|
} catch (error) {
|
||||||
|
sendResponse({ ok: false, error: error instanceof Error ? error.message : String(error) });
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (message?.type === "keepassgo-page-state") {
|
||||||
|
pageState = {
|
||||||
|
...pageState,
|
||||||
|
...(message.state || {})
|
||||||
|
};
|
||||||
|
renderInlineState();
|
||||||
|
sendResponse({ ok: true });
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (message?.type === "keepassgo-page-scan") {
|
||||||
|
const scan = scanLoginFields();
|
||||||
|
sendResponse({
|
||||||
|
pageHasLoginForm: scan.pageHasLoginForm,
|
||||||
|
focusTarget: scan.focusTarget,
|
||||||
|
signature: scan.signature
|
||||||
|
});
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
|
||||||
|
reportFieldState(true);
|
||||||
|
}
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
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 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);
|
||||||
|
});
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"manifest_version": 3,
|
||||||
|
"name": "KeePassGO Browser",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"description": "Fill credentials from KeePassGO on sign-in pages.",
|
||||||
|
"permissions": ["activeTab", "nativeMessaging", "storage", "tabs"],
|
||||||
|
"host_permissions": ["http://*/*", "https://*/*"],
|
||||||
|
"background": {
|
||||||
|
"service_worker": "background.js"
|
||||||
|
},
|
||||||
|
"action": {
|
||||||
|
"default_title": "KeePassGO Browser",
|
||||||
|
"default_popup": "popup.html"
|
||||||
|
},
|
||||||
|
"options_ui": {
|
||||||
|
"page": "options.html",
|
||||||
|
"open_in_tab": true
|
||||||
|
},
|
||||||
|
"content_scripts": [
|
||||||
|
{
|
||||||
|
"matches": ["http://*/*", "https://*/*"],
|
||||||
|
"js": ["content.js"],
|
||||||
|
"run_at": "document_idle"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
{
|
||||||
|
"manifest_version": 2,
|
||||||
|
"name": "KeePassGO Browser",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"description": "Fill credentials from KeePassGO on sign-in pages.",
|
||||||
|
"permissions": [
|
||||||
|
"activeTab",
|
||||||
|
"nativeMessaging",
|
||||||
|
"storage",
|
||||||
|
"tabs",
|
||||||
|
"http://*/*",
|
||||||
|
"https://*/*"
|
||||||
|
],
|
||||||
|
"background": {
|
||||||
|
"scripts": ["background.js"]
|
||||||
|
},
|
||||||
|
"browser_action": {
|
||||||
|
"default_title": "KeePassGO Browser",
|
||||||
|
"default_popup": "popup.html"
|
||||||
|
},
|
||||||
|
"options_ui": {
|
||||||
|
"page": "options.html",
|
||||||
|
"open_in_tab": true
|
||||||
|
},
|
||||||
|
"content_scripts": [
|
||||||
|
{
|
||||||
|
"matches": ["http://*/*", "https://*/*"],
|
||||||
|
"js": ["content.js"],
|
||||||
|
"run_at": "document_idle"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"browser_specific_settings": {
|
||||||
|
"gecko": {
|
||||||
|
"id": "browser@keepassgo.com"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>KeePassGO Browser Settings</title>
|
||||||
|
<link rel="stylesheet" href="style.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main class="surface settings">
|
||||||
|
<header class="topbar">
|
||||||
|
<div>
|
||||||
|
<h1>Browser Settings</h1>
|
||||||
|
<p class="subtle">Connect the extension to KeePassGO.</p>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<form id="settings-form" class="settings-form">
|
||||||
|
<label>
|
||||||
|
<span>API token</span>
|
||||||
|
<textarea id="bearer-token" name="bearer-token" rows="6" spellcheck="false"></textarea>
|
||||||
|
</label>
|
||||||
|
<div class="actions">
|
||||||
|
<button type="submit">Save</button>
|
||||||
|
</div>
|
||||||
|
<p id="settings-status" class="subtle"></p>
|
||||||
|
</form>
|
||||||
|
</main>
|
||||||
|
<script src="options.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
const extOptions = globalThis.browser ?? globalThis.chrome;
|
||||||
|
const usePromiseAPI = typeof globalThis.browser !== "undefined";
|
||||||
|
|
||||||
|
function runtimeSend(message) {
|
||||||
|
if (usePromiseAPI) {
|
||||||
|
return extOptions.runtime.sendMessage(message);
|
||||||
|
}
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
extOptions.runtime.sendMessage(message, (response) => {
|
||||||
|
const error = extOptions.runtime.lastError;
|
||||||
|
if (error) {
|
||||||
|
reject(new Error(error.message));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
resolve(response);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadSettings() {
|
||||||
|
const response = await runtimeSend({ type: "keepassgo-load-settings" });
|
||||||
|
if (!response?.success) {
|
||||||
|
throw new Error(response?.error || "Could not load settings.");
|
||||||
|
}
|
||||||
|
document.getElementById("bearer-token").value = response.settings.bearerToken || "";
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveSettings(event) {
|
||||||
|
event.preventDefault();
|
||||||
|
const status = document.getElementById("settings-status");
|
||||||
|
status.textContent = "Saving…";
|
||||||
|
try {
|
||||||
|
const response = await runtimeSend({
|
||||||
|
type: "keepassgo-save-settings",
|
||||||
|
settings: {
|
||||||
|
bearerToken: document.getElementById("bearer-token").value
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (!response?.success) {
|
||||||
|
throw new Error(response?.error || "Could not save settings.");
|
||||||
|
}
|
||||||
|
status.textContent = "Saved.";
|
||||||
|
} catch (error) {
|
||||||
|
status.textContent = error instanceof Error ? error.message : String(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById("settings-form").addEventListener("submit", saveSettings);
|
||||||
|
void loadSettings();
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>KeePassGO Browser</title>
|
||||||
|
<link rel="stylesheet" href="style.css">
|
||||||
|
</head>
|
||||||
|
<body class="popup">
|
||||||
|
<main class="surface">
|
||||||
|
<header class="topbar">
|
||||||
|
<div>
|
||||||
|
<h1>KeePassGO</h1>
|
||||||
|
<p id="page-host" class="subtle">Checking current page</p>
|
||||||
|
</div>
|
||||||
|
<a href="options.html" target="_blank" rel="noreferrer" class="link-button">Settings</a>
|
||||||
|
</header>
|
||||||
|
<section id="status-card" class="status-card">
|
||||||
|
<strong id="status-title">Loading</strong>
|
||||||
|
<p id="status-message" class="subtle">Checking KeePassGO.</p>
|
||||||
|
</section>
|
||||||
|
<p id="page-hint" class="inline-hint subtle">Loading page state.</p>
|
||||||
|
<section>
|
||||||
|
<h2>Matches</h2>
|
||||||
|
<div id="matches" class="match-list"></div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
<script src="popup.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,173 @@
|
|||||||
|
const extPopup = globalThis.browser ?? globalThis.chrome;
|
||||||
|
const usePromiseAPI = typeof globalThis.browser !== "undefined";
|
||||||
|
|
||||||
|
function runtimeSend(message) {
|
||||||
|
if (usePromiseAPI) {
|
||||||
|
return extPopup.runtime.sendMessage(message);
|
||||||
|
}
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
extPopup.runtime.sendMessage(message, (response) => {
|
||||||
|
const error = extPopup.runtime.lastError;
|
||||||
|
if (error) {
|
||||||
|
reject(new Error(error.message));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
resolve(response);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function hostFromURL(rawURL) {
|
||||||
|
try {
|
||||||
|
return new URL(rawURL).host || rawURL;
|
||||||
|
} catch (_error) {
|
||||||
|
return rawURL || "Current page";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setStatus(title, message, tone) {
|
||||||
|
const card = document.getElementById("status-card");
|
||||||
|
card.dataset.tone = tone || "neutral";
|
||||||
|
document.getElementById("status-title").textContent = title;
|
||||||
|
document.getElementById("status-message").textContent = message;
|
||||||
|
}
|
||||||
|
|
||||||
|
function matchSubtitle(match) {
|
||||||
|
const parts = [];
|
||||||
|
if (match.username) {
|
||||||
|
parts.push(match.username);
|
||||||
|
}
|
||||||
|
if (Array.isArray(match.path) && match.path.length !== 0) {
|
||||||
|
parts.push(match.path.join(" / "));
|
||||||
|
}
|
||||||
|
return parts.join(" · ") || "No username";
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderMatches(state) {
|
||||||
|
const root = document.getElementById("matches");
|
||||||
|
const targetTabID = popupTabID();
|
||||||
|
root.textContent = "";
|
||||||
|
if (!Array.isArray(state.matches) || state.matches.length === 0) {
|
||||||
|
const empty = document.createElement("p");
|
||||||
|
empty.className = "subtle";
|
||||||
|
empty.textContent = state.pageHasLoginForm
|
||||||
|
? "No matching entries for this page."
|
||||||
|
: "No login fields detected on this page.";
|
||||||
|
root.appendChild(empty);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const match of state.matches) {
|
||||||
|
const row = document.createElement("button");
|
||||||
|
row.type = "button";
|
||||||
|
row.className = "match-row";
|
||||||
|
const main = document.createElement("span");
|
||||||
|
main.className = "match-main";
|
||||||
|
const title = document.createElement("strong");
|
||||||
|
title.textContent = match.title;
|
||||||
|
const subtitle = document.createElement("span");
|
||||||
|
subtitle.className = "subtle";
|
||||||
|
subtitle.textContent = matchSubtitle(match);
|
||||||
|
const quality = document.createElement("span");
|
||||||
|
quality.className = "quality";
|
||||||
|
quality.textContent = match.quality || "";
|
||||||
|
main.appendChild(title);
|
||||||
|
main.appendChild(subtitle);
|
||||||
|
row.appendChild(main);
|
||||||
|
row.appendChild(quality);
|
||||||
|
row.addEventListener("click", async () => {
|
||||||
|
row.disabled = true;
|
||||||
|
setStatus("Approval may be required", "KeePassGO will prompt if this token needs approval before fill.", "warning");
|
||||||
|
try {
|
||||||
|
const result = await runtimeSend({
|
||||||
|
type: "keepassgo-fill-entry",
|
||||||
|
entryId: match.id,
|
||||||
|
tabId: targetTabID
|
||||||
|
});
|
||||||
|
if (!result?.success) {
|
||||||
|
throw new Error(result?.error || "Fill failed.");
|
||||||
|
}
|
||||||
|
setStatus("Filled", `${match.title} was sent to the current page.`, "ready");
|
||||||
|
} catch (error) {
|
||||||
|
setStatus("Fill failed", error instanceof Error ? error.message : String(error), "error");
|
||||||
|
} finally {
|
||||||
|
row.disabled = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
root.appendChild(row);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderPageHint(state) {
|
||||||
|
const hint = document.getElementById("page-hint");
|
||||||
|
if (state.pendingFill) {
|
||||||
|
hint.textContent = "Approval is pending in KeePassGO.";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (state.pageHasLoginForm && Array.isArray(state.matches) && state.matches.length > 0) {
|
||||||
|
hint.textContent = "Inline KeePassGO suggestions are available on the page.";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (state.pageHasLoginForm) {
|
||||||
|
hint.textContent = "KeePassGO checked this login form already.";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
hint.textContent = "Open a sign-in page to see KeePassGO suggestions here.";
|
||||||
|
}
|
||||||
|
|
||||||
|
function popupTabID() {
|
||||||
|
const rawValue = new URLSearchParams(window.location.search).get("tabId");
|
||||||
|
if (rawValue === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const parsed = Number.parseInt(rawValue, 10);
|
||||||
|
return Number.isInteger(parsed) ? parsed : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
try {
|
||||||
|
const state = await runtimeSend({
|
||||||
|
type: "keepassgo-popup-state",
|
||||||
|
force: true,
|
||||||
|
tabId: popupTabID()
|
||||||
|
});
|
||||||
|
document.getElementById("page-host").textContent = hostFromURL(state.pageUrl || "");
|
||||||
|
renderPageHint(state);
|
||||||
|
|
||||||
|
if (!state.configured) {
|
||||||
|
setStatus("Configure access", state.error || "Set the API token in extension settings.", "warning");
|
||||||
|
renderMatches({ matches: [] });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (state.pendingFill) {
|
||||||
|
setStatus("Approval needed", state.pendingMessage || "Approve or deny the fill request in KeePassGO.", "warning");
|
||||||
|
renderMatches(state);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!state.success) {
|
||||||
|
setStatus("KeePassGO unavailable", state.error || "The native host could not reach KeePassGO.", "error");
|
||||||
|
renderMatches(state);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (state.status?.locked) {
|
||||||
|
setStatus("Vault locked", "Unlock KeePassGO, then try the page again.", "warning");
|
||||||
|
renderMatches(state);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const count = Array.isArray(state.matches) ? state.matches.length : 0;
|
||||||
|
if (!state.pageHasLoginForm) {
|
||||||
|
setStatus("Ready", "KeePassGO is connected. Open a login form to check for matches.", "ready");
|
||||||
|
} else if (count === 0) {
|
||||||
|
setStatus("Checked this page", "KeePassGO did not find a matching login for this form.", "ready");
|
||||||
|
} else {
|
||||||
|
setStatus("Page suggestions ready", count === 1 ? "1 matching entry is ready on this page." : `${count} matching entries are ready on this page.`, "ready");
|
||||||
|
}
|
||||||
|
renderMatches(state);
|
||||||
|
} catch (error) {
|
||||||
|
setStatus("Error", error instanceof Error ? error.message : String(error), "error");
|
||||||
|
renderMatches({ matches: [] });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void main();
|
||||||
@@ -0,0 +1,178 @@
|
|||||||
|
:root {
|
||||||
|
color-scheme: light;
|
||||||
|
--ink: #214f44;
|
||||||
|
--ink-soft: #4d6d66;
|
||||||
|
--surface: #fffdfa;
|
||||||
|
--surface-2: #f2f7f3;
|
||||||
|
--line: #d7e3dc;
|
||||||
|
--accent: #255f4a;
|
||||||
|
--accent-soft: #dfeee6;
|
||||||
|
--warn: #9f5f0e;
|
||||||
|
--error: #9f2f2f;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
font: 14px/1.4 "Noto Sans", "Liberation Sans", sans-serif;
|
||||||
|
color: var(--ink);
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at top right, #ecf5ef, transparent 38%),
|
||||||
|
linear-gradient(180deg, #f8fbf8, #eef4f0);
|
||||||
|
}
|
||||||
|
|
||||||
|
body.popup {
|
||||||
|
min-width: 360px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.surface {
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1,
|
||||||
|
h2,
|
||||||
|
p {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
font-size: 22px;
|
||||||
|
line-height: 1.1;
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
font-size: 14px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
color: var(--ink-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtle {
|
||||||
|
color: var(--ink-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-card {
|
||||||
|
padding: 12px 14px;
|
||||||
|
border-radius: 12px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
background: var(--surface);
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-card[data-tone="ready"] {
|
||||||
|
border-color: #c5dccf;
|
||||||
|
background: var(--accent-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-card[data-tone="warning"] {
|
||||||
|
border-color: #e4d0ae;
|
||||||
|
background: #fbf4e7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-card[data-tone="error"] {
|
||||||
|
border-color: #e4bcbc;
|
||||||
|
background: #fcf1f1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inline-hint {
|
||||||
|
margin: -6px 0 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.match-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.match-row,
|
||||||
|
button,
|
||||||
|
.link-button {
|
||||||
|
appearance: none;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: var(--surface);
|
||||||
|
color: var(--ink);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.match-row {
|
||||||
|
display: flex;
|
||||||
|
width: 100%;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 12px 14px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.match-row:hover,
|
||||||
|
button:hover,
|
||||||
|
.link-button:hover {
|
||||||
|
background: var(--surface-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.match-main {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: start;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quality {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--ink-soft);
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings {
|
||||||
|
max-width: 720px;
|
||||||
|
margin: 0 auto;
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-form {
|
||||||
|
display: grid;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
label {
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
input,
|
||||||
|
textarea {
|
||||||
|
width: 100%;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 10px;
|
||||||
|
background: #fff;
|
||||||
|
color: var(--ink);
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
button,
|
||||||
|
.link-button {
|
||||||
|
padding: 10px 14px;
|
||||||
|
background: var(--accent);
|
||||||
|
color: #fff;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: end;
|
||||||
|
}
|
||||||
@@ -0,0 +1,166 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"runtime"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"git.julianfamily.org/keepassgo/internal/browserbridge"
|
||||||
|
"git.julianfamily.org/keepassgo/internal/grpcaddr"
|
||||||
|
"google.golang.org/grpc"
|
||||||
|
)
|
||||||
|
|
||||||
|
type bridgeConfig struct {
|
||||||
|
grpcAddr string
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
cfg := bridgeConfig{
|
||||||
|
grpcAddr: resolveGlobalGRPCAddr(os.Args[1:]),
|
||||||
|
}
|
||||||
|
if len(os.Args) > 1 {
|
||||||
|
switch strings.TrimSpace(os.Args[1]) {
|
||||||
|
case "install-native-host":
|
||||||
|
if err := runInstallNativeHost(os.Args[2:]); err != nil {
|
||||||
|
fail(err)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
case "status":
|
||||||
|
if err := runStatus(cfg, stripGlobalGRPCAddrFlags(os.Args[2:])); err != nil {
|
||||||
|
fail(err)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := runNativeMessage(cfg); err != nil {
|
||||||
|
_ = browserbridge.WriteResponse(os.Stdout, browserbridge.Response{Success: false, Error: err.Error()})
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func runInstallNativeHost(args []string) error {
|
||||||
|
fs := flag.NewFlagSet("install-native-host", flag.ContinueOnError)
|
||||||
|
browserName := fs.String("browser", string(browserbridge.BrowserFirefox), "target browser: firefox, chrome, chromium")
|
||||||
|
binaryPath := fs.String("binary", "", "path to keepassgo-browser-bridge binary")
|
||||||
|
extensionID := fs.String("extension-id", "", "browser extension id (required for chrome/chromium)")
|
||||||
|
extensionKey := fs.String("extension-key", "", "Chromium manifest public key used to derive a fixed extension id")
|
||||||
|
extensionKeyFile := fs.String("extension-key-file", "", "path to a Chromium manifest public key file")
|
||||||
|
outputPath := fs.String("output", "", "native host manifest output path")
|
||||||
|
if err := fs.Parse(args); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
path := strings.TrimSpace(*binaryPath)
|
||||||
|
if path == "" {
|
||||||
|
resolved, err := defaultBinaryPath()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
path = resolved
|
||||||
|
}
|
||||||
|
resolvedExtensionID := strings.TrimSpace(*extensionID)
|
||||||
|
if resolvedExtensionID == "" {
|
||||||
|
keyValue := strings.TrimSpace(*extensionKey)
|
||||||
|
if keyValue == "" && strings.TrimSpace(*extensionKeyFile) != "" {
|
||||||
|
data, err := os.ReadFile(strings.TrimSpace(*extensionKeyFile))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
keyValue = string(data)
|
||||||
|
}
|
||||||
|
if keyValue != "" {
|
||||||
|
derivedID, err := browserbridge.ChromiumExtensionIDFromManifestKey(keyValue)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
resolvedExtensionID = derivedID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
installed, err := browserbridge.InstallManifest(browserbridge.Browser(strings.TrimSpace(*browserName)), path, resolvedExtensionID, strings.TrimSpace(*outputPath))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
fmt.Fprintln(os.Stdout, installed)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func runStatus(cfg bridgeConfig, args []string) error {
|
||||||
|
fs := flag.NewFlagSet("status", flag.ContinueOnError)
|
||||||
|
token := fs.String("token", "", "KeePassGO API bearer token")
|
||||||
|
if err := fs.Parse(args); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
req := browserbridge.Request{
|
||||||
|
Action: "status",
|
||||||
|
BearerToken: strings.TrimSpace(*token),
|
||||||
|
}
|
||||||
|
conn, client, ctx, err := dialBridge(context.Background(), cfg, req)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer func() { _ = conn.Close() }()
|
||||||
|
resp := browserbridge.HandleRequest(ctx, req, cfg.grpcAddr, client)
|
||||||
|
enc := json.NewEncoder(os.Stdout)
|
||||||
|
enc.SetIndent("", " ")
|
||||||
|
return enc.Encode(resp)
|
||||||
|
}
|
||||||
|
|
||||||
|
func runNativeMessage(cfg bridgeConfig) error {
|
||||||
|
req, err := browserbridge.ReadRequest(os.Stdin)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
conn, client, ctx, err := dialBridge(context.Background(), cfg, req)
|
||||||
|
if err != nil {
|
||||||
|
return browserbridge.WriteResponse(os.Stdout, browserbridge.Response{Success: false, Error: err.Error()})
|
||||||
|
}
|
||||||
|
defer func() { _ = conn.Close() }()
|
||||||
|
return browserbridge.WriteResponse(os.Stdout, browserbridge.HandleRequest(ctx, req, cfg.grpcAddr, client))
|
||||||
|
}
|
||||||
|
|
||||||
|
func dialBridge(ctx context.Context, cfg bridgeConfig, req browserbridge.Request) (*grpc.ClientConn, *browserbridge.GRPCClient, context.Context, error) {
|
||||||
|
return browserbridge.DialRequest(ctx, req, cfg.grpcAddr)
|
||||||
|
}
|
||||||
|
|
||||||
|
func defaultBinaryPath() (string, error) {
|
||||||
|
return browserbridge.ResolveBridgeBinaryPath("")
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolveGlobalGRPCAddr(args []string) string {
|
||||||
|
addr := grpcaddr.Default(runtime.GOOS)
|
||||||
|
for i := 0; i < len(args); i++ {
|
||||||
|
arg := strings.TrimSpace(args[i])
|
||||||
|
switch {
|
||||||
|
case arg == "--grpc-addr" && i+1 < len(args):
|
||||||
|
return strings.TrimSpace(args[i+1])
|
||||||
|
case strings.HasPrefix(arg, "--grpc-addr="):
|
||||||
|
return strings.TrimSpace(strings.TrimPrefix(arg, "--grpc-addr="))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return addr
|
||||||
|
}
|
||||||
|
|
||||||
|
func stripGlobalGRPCAddrFlags(args []string) []string {
|
||||||
|
out := make([]string, 0, len(args))
|
||||||
|
for i := 0; i < len(args); i++ {
|
||||||
|
arg := strings.TrimSpace(args[i])
|
||||||
|
switch {
|
||||||
|
case arg == "--grpc-addr" && i+1 < len(args):
|
||||||
|
i++
|
||||||
|
continue
|
||||||
|
case strings.HasPrefix(arg, "--grpc-addr="):
|
||||||
|
continue
|
||||||
|
default:
|
||||||
|
out = append(out, args[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func fail(err error) {
|
||||||
|
fmt.Fprintln(os.Stderr, err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
# Browser Extension
|
||||||
|
|
||||||
|
KeePassGO browser integration uses:
|
||||||
|
|
||||||
|
- the existing local gRPC API in KeePassGO
|
||||||
|
- API tokens for authorization
|
||||||
|
- a tiny native messaging host for browser-to-gRPC transport adaptation
|
||||||
|
|
||||||
|
The browser extension does **not** talk to vault files directly.
|
||||||
|
|
||||||
|
## Security Model
|
||||||
|
|
||||||
|
- KeePassGO remains the source of truth for authentication, authorization, approvals, and audit events.
|
||||||
|
- The browser extension stores the API token in browser extension storage.
|
||||||
|
- The native messaging host receives the token on each request from the extension.
|
||||||
|
- The native messaging host uses the token only to attach `authorization: Bearer ...` metadata to the local gRPC request.
|
||||||
|
- The native messaging host does not persist the token to disk.
|
||||||
|
|
||||||
|
The native messaging host is therefore part of the trusted client for that browser profile. Scope the API token accordingly.
|
||||||
|
|
||||||
|
## RPCs Used
|
||||||
|
|
||||||
|
The browser integration uses:
|
||||||
|
|
||||||
|
- `GetSessionStatus`
|
||||||
|
- `FindBrowserLogins`
|
||||||
|
- `GetBrowserCredential`
|
||||||
|
|
||||||
|
The browser feature intentionally stays on the same secure gRPC surface used by other trusted automation.
|
||||||
|
|
||||||
|
## Default Listener
|
||||||
|
|
||||||
|
On desktop KeePassGO listens on a Unix socket by default:
|
||||||
|
|
||||||
|
- primary location: under the user runtime directory
|
||||||
|
- fallback: `/run/user/<uid>` if present
|
||||||
|
- final fallback: a private directory under the system temp directory
|
||||||
|
|
||||||
|
Override the listener with `-grpc-addr` or `KEEPASSGO_GRPC_ADDR`, for example:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
KEEPASSGO_GRPC_ADDR=tcp://127.0.0.1:47777 ./keepassgo
|
||||||
|
```
|
||||||
|
|
||||||
|
## Native Host
|
||||||
|
|
||||||
|
Build the bridge:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
go build ./cmd/keepassgo-browser-bridge
|
||||||
|
```
|
||||||
|
|
||||||
|
On Linux desktop builds, KeePassGO now refreshes the user-scoped native messaging manifests on launch. That automatic update always installs the Firefox manifest and also installs Chrome or Chromium manifests when it finds an installed `KeePassGO Browser` extension in that browser profile. The Arch package also ships the extension assets under `/usr/share/keepassgo/browser-extension/`.
|
||||||
|
|
||||||
|
Install a Firefox native messaging manifest:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./keepassgo-browser-bridge install-native-host --browser firefox --binary /absolute/path/to/keepassgo-browser-bridge
|
||||||
|
```
|
||||||
|
|
||||||
|
Install a Chromium native messaging manifest:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./keepassgo-browser-bridge install-native-host --browser chromium --binary /absolute/path/to/keepassgo-browser-bridge --extension-key-file /path/to/chromium-extension-public-key.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
Chrome and Chromium require the actual extension id in the native host manifest. KeePassGO can derive that id from the Chromium manifest public key so you do not have to type it separately.
|
||||||
|
|
||||||
|
For a fixed Chromium ID:
|
||||||
|
|
||||||
|
1. Keep a stable Chromium extension signing key outside the repo.
|
||||||
|
2. Add the corresponding public key to the Chromium manifest as `"key": "<base64-public-key>"`.
|
||||||
|
3. Use the same public key with `install-native-host --extension-key-file ...` so the native host manifest is locked to that stable extension ID.
|
||||||
|
|
||||||
|
## Extension Setup
|
||||||
|
|
||||||
|
Firefox:
|
||||||
|
|
||||||
|
1. Load `browser/extension/manifest.firefox.json` as a temporary add-on or package it as an extension.
|
||||||
|
2. Open the extension settings page.
|
||||||
|
3. Paste an API token scoped for browser login lookup and credential copy.
|
||||||
|
|
||||||
|
Chromium / Chrome:
|
||||||
|
|
||||||
|
1. Load a Chromium manifest based on `browser/extension/manifest.chromium.json`, or install the published extension when that distribution exists.
|
||||||
|
2. Start KeePassGO once so it can refresh the native host manifest for the discovered extension id.
|
||||||
|
3. Configure the API token in the extension settings page.
|
||||||
|
|
||||||
|
## Current Browser Flow
|
||||||
|
|
||||||
|
- The extension checks sign-in pages in the background and caches per-tab match state instead of waiting for the popup to be opened first.
|
||||||
|
- The toolbar badge shows when KeePassGO found matches for the current page.
|
||||||
|
- Username and password fields get an inline KeePassGO affordance that opens a candidate chooser anchored to the focused field and keeps fills scoped to that field's form when possible.
|
||||||
|
- If a fill request needs user approval, the extension keeps the pending state visible in both the page affordance and the popup until KeePassGO resolves it, using the token-scoped pending-approval count from the local gRPC API.
|
||||||
|
|
||||||
|
For extension-side regression checks, run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
node --test browser/extension/background.test.cjs browser/extension/content.test.cjs
|
||||||
|
```
|
||||||
|
|
||||||
|
For a reproducible real-browser Chromium validation harness, run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make browser-extension-validate
|
||||||
|
```
|
||||||
|
|
||||||
|
That target:
|
||||||
|
|
||||||
|
- validates the Firefox flow by default with a temporary addon install
|
||||||
|
- can also validate Chromium with `make browser-extension-validate BROWSER=chromium`
|
||||||
|
- builds the native messaging bridge
|
||||||
|
- starts a stub KeePassGO gRPC server and a local login page
|
||||||
|
- drives the browser through inline match discovery, approval visibility, and fill completion
|
||||||
|
|
||||||
|
If validation fails, the script preserves its temporary workspace path so the captured HTML, screenshots, logs, and native-host files can be inspected.
|
||||||
|
|
||||||
|
## Required Token Scope
|
||||||
|
|
||||||
|
At minimum, the browser token should have policy rules allowing:
|
||||||
|
|
||||||
|
- `list_entries` for the groups you want the browser to search
|
||||||
|
- `copy_username` for entries the browser may fill
|
||||||
|
- `copy_password` for entries the browser may fill
|
||||||
|
- `copy_url` for entries the browser may confirm against page URL
|
||||||
@@ -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=
|
||||||
|
|||||||
+53
-3
@@ -4,10 +4,13 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net"
|
"net"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
|
|
||||||
"git.julianfamily.org/keepassgo/internal/clipboard"
|
"git.julianfamily.org/keepassgo/internal/clipboard"
|
||||||
|
"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"
|
||||||
"git.julianfamily.org/keepassgo/internal/vault"
|
"git.julianfamily.org/keepassgo/internal/vault"
|
||||||
@@ -27,6 +30,7 @@ type Host struct {
|
|||||||
lastModel vault.Model
|
lastModel vault.Model
|
||||||
started bool
|
started bool
|
||||||
listenAddr string
|
listenAddr string
|
||||||
|
socketPath string
|
||||||
}
|
}
|
||||||
|
|
||||||
func StartHost(addr string, lifecycle lifecycleBackend, profiles map[string]passwords.Profile, clipboardWriter clipboard.Writer, dirty DirtyProvider) (*Host, error) {
|
func StartHost(addr string, lifecycle lifecycleBackend, profiles map[string]passwords.Profile, clipboardWriter clipboard.Writer, dirty DirtyProvider) (*Host, error) {
|
||||||
@@ -35,7 +39,11 @@ func StartHost(addr string, lifecycle lifecycleBackend, profiles map[string]pass
|
|||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
listener, err := net.Listen("tcp", addr)
|
network, endpoint, err := grpcaddr.Parse(addr)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
listener, socketPath, err := listen(network, endpoint)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("listen gRPC host %s: %w", addr, err)
|
return nil, fmt.Errorf("listen gRPC host %s: %w", addr, err)
|
||||||
}
|
}
|
||||||
@@ -50,7 +58,8 @@ func StartHost(addr string, lifecycle lifecycleBackend, profiles map[string]pass
|
|||||||
listener: listener,
|
listener: listener,
|
||||||
lifecycle: lifecycle,
|
lifecycle: lifecycle,
|
||||||
dirty: dirty,
|
dirty: dirty,
|
||||||
listenAddr: listener.Addr().String(),
|
listenAddr: formatListenAddress(network, listener.Addr().String(), socketPath),
|
||||||
|
socketPath: socketPath,
|
||||||
started: true,
|
started: true,
|
||||||
}
|
}
|
||||||
if err := host.SyncFromLifecycle(); err != nil && !errors.Is(err, session.ErrLocked) {
|
if err := host.SyncFromLifecycle(); err != nil && !errors.Is(err, session.ErrLocked) {
|
||||||
@@ -91,7 +100,16 @@ func (h *Host) Stop() error {
|
|||||||
}
|
}
|
||||||
h.started = false
|
h.started = false
|
||||||
h.grpcServer.Stop()
|
h.grpcServer.Stop()
|
||||||
return h.listener.Close()
|
err := h.listener.Close()
|
||||||
|
if errors.Is(err, net.ErrClosed) {
|
||||||
|
err = nil
|
||||||
|
}
|
||||||
|
if h.socketPath != "" {
|
||||||
|
if removeErr := os.Remove(h.socketPath); removeErr != nil && !errors.Is(removeErr, os.ErrNotExist) && err == nil {
|
||||||
|
err = removeErr
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *Host) SyncFromLifecycle() error {
|
func (h *Host) SyncFromLifecycle() error {
|
||||||
@@ -120,3 +138,35 @@ func (h *Host) SyncFromLifecycle() error {
|
|||||||
h.server.SetSessionState(h.lastModel, locked, dirty)
|
h.server.SetSessionState(h.lastModel, locked, dirty)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func listen(network, endpoint string) (net.Listener, string, error) {
|
||||||
|
if network == "unix" {
|
||||||
|
if err := os.MkdirAll(filepath.Dir(endpoint), 0o700); err != nil {
|
||||||
|
return nil, "", err
|
||||||
|
}
|
||||||
|
if err := os.Remove(endpoint); err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||||
|
return nil, "", err
|
||||||
|
}
|
||||||
|
listener, err := net.Listen("unix", endpoint)
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", err
|
||||||
|
}
|
||||||
|
if err := os.Chmod(endpoint, 0o600); err != nil {
|
||||||
|
_ = listener.Close()
|
||||||
|
return nil, "", err
|
||||||
|
}
|
||||||
|
return listener, endpoint, nil
|
||||||
|
}
|
||||||
|
listener, err := net.Listen(network, endpoint)
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", err
|
||||||
|
}
|
||||||
|
return listener, "", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatListenAddress(network, listenerAddr, socketPath string) string {
|
||||||
|
if network == "unix" {
|
||||||
|
return "unix://" + socketPath
|
||||||
|
}
|
||||||
|
return listenerAddr
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,10 +2,13 @@ package api
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
"net"
|
"net"
|
||||||
|
"os"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"git.julianfamily.org/keepassgo/internal/apitokens"
|
"git.julianfamily.org/keepassgo/internal/apitokens"
|
||||||
|
"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"
|
||||||
"git.julianfamily.org/keepassgo/internal/vault"
|
"git.julianfamily.org/keepassgo/internal/vault"
|
||||||
@@ -42,10 +45,14 @@ func TestStartHostServesVaultLifecycleAndSyncsSessionState(t *testing.T) {
|
|||||||
}
|
}
|
||||||
defer func() { _ = host.Stop() }()
|
defer func() { _ = host.Stop() }()
|
||||||
|
|
||||||
|
network, endpoint, err := grpcaddr.Parse(host.Address())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Parse(host.Address()) error = %v", err)
|
||||||
|
}
|
||||||
conn, err := grpc.NewClient("passthrough:///"+host.Address(),
|
conn, err := grpc.NewClient("passthrough:///"+host.Address(),
|
||||||
grpc.WithTransportCredentials(insecure.NewCredentials()),
|
grpc.WithTransportCredentials(insecure.NewCredentials()),
|
||||||
grpc.WithContextDialer(func(context.Context, string) (net.Conn, error) {
|
grpc.WithContextDialer(func(context.Context, string) (net.Conn, error) {
|
||||||
return net.Dial("tcp", host.Address())
|
return net.Dial(network, endpoint)
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -80,3 +87,37 @@ func TestStartHostServesVaultLifecycleAndSyncsSessionState(t *testing.T) {
|
|||||||
t.Fatal("GetSessionStatus().Locked = false, want true after lifecycle lock")
|
t.Fatal("GetSessionStatus().Locked = false, want true after lifecycle lock")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestStartHostServesOverUnixSocket(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
socketDir := t.TempDir()
|
||||||
|
socketPath := socketDir + "/keepassgo.sock"
|
||||||
|
lifecycle := &session.Manager{}
|
||||||
|
if err := lifecycle.Create(vault.Model{
|
||||||
|
Entries: []vault.Entry{
|
||||||
|
testAPITokenEntry(t,
|
||||||
|
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationManageVault, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root"}}},
|
||||||
|
),
|
||||||
|
},
|
||||||
|
}, vault.MasterKey{Password: "correct horse battery staple"}); err != nil {
|
||||||
|
t.Fatalf("Create() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
host, err := StartHost("unix://"+socketPath, lifecycle, passwords.DefaultProfiles(), nil, func() bool { return false })
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("StartHost() error = %v", err)
|
||||||
|
}
|
||||||
|
if got := host.Address(); got != "unix://"+socketPath {
|
||||||
|
t.Fatalf("host.Address() = %q, want %q", got, "unix://"+socketPath)
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(socketPath); err != nil {
|
||||||
|
t.Fatalf("Stat(socketPath) error = %v", err)
|
||||||
|
}
|
||||||
|
if err := host.Stop(); err != nil {
|
||||||
|
t.Fatalf("Stop() error = %v", err)
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(socketPath); !errors.Is(err, os.ErrNotExist) {
|
||||||
|
t.Fatalf("socket exists after Stop(), err = %v, want not-exist", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+477
-85
@@ -3,7 +3,9 @@ package api
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
"maps"
|
"maps"
|
||||||
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
"slices"
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -17,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"
|
||||||
@@ -36,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 {
|
||||||
@@ -52,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,
|
||||||
@@ -76,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)
|
||||||
}
|
}
|
||||||
@@ -89,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
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -225,6 +258,172 @@ func (s *Server) UnlockVault(ctx context.Context, req *keepassgov1.UnlockVaultRe
|
|||||||
return &keepassgov1.UnlockVaultResponse{}, nil
|
return &keepassgov1.UnlockVaultResponse{}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Server) FindBrowserLogins(ctx context.Context, req *keepassgov1.FindBrowserLoginsRequest) (*keepassgov1.FindBrowserLoginsResponse, error) {
|
||||||
|
model, locked := s.snapshotModel()
|
||||||
|
if locked {
|
||||||
|
return nil, status.Error(codes.FailedPrecondition, "vault is locked")
|
||||||
|
}
|
||||||
|
displayModel := visibleModel(model)
|
||||||
|
token, err := s.authenticateRequest(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
pageHost, err := normalizedBrowserHost(req.GetPageUrl())
|
||||||
|
if err != nil {
|
||||||
|
return nil, status.Error(codes.InvalidArgument, err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
var matches []rankedBrowserMatch
|
||||||
|
for _, entry := range displayModel.Entries {
|
||||||
|
quality, score := classifyBrowserEntryMatch(pageHost, entry.URL)
|
||||||
|
if score == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
resource := apitokens.Resource{Kind: apitokens.ResourceGroup, Path: entry.Path}
|
||||||
|
matches = append(matches, rankedBrowserMatch{
|
||||||
|
match: &keepassgov1.BrowserLoginMatch{
|
||||||
|
Id: entry.ID,
|
||||||
|
Title: entry.Title,
|
||||||
|
Username: entry.Username,
|
||||||
|
Url: entry.URL,
|
||||||
|
Path: append([]string(nil), entry.Path...),
|
||||||
|
Quality: quality,
|
||||||
|
},
|
||||||
|
score: score,
|
||||||
|
resource: resource,
|
||||||
|
decision: evaluateAuthorization(model, token, apitokens.OperationListEntries, resource),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
slices.SortFunc(matches, func(a, b rankedBrowserMatch) int {
|
||||||
|
switch {
|
||||||
|
case a.score != b.score:
|
||||||
|
return b.score - a.score
|
||||||
|
case a.match.GetTitle() != b.match.GetTitle():
|
||||||
|
return strings.Compare(a.match.GetTitle(), b.match.GetTitle())
|
||||||
|
case a.match.GetUsername() != b.match.GetUsername():
|
||||||
|
return strings.Compare(a.match.GetUsername(), b.match.GetUsername())
|
||||||
|
default:
|
||||||
|
return strings.Compare(a.match.GetId(), b.match.GetId())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
out, err := s.authorizedBrowserMatches(ctx, token, matches)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
switch len(out) {
|
||||||
|
case 1:
|
||||||
|
s.audit.Record(apiaudit.Event{
|
||||||
|
Type: apiaudit.EventAutofillFound,
|
||||||
|
TokenID: token.ID,
|
||||||
|
TokenName: token.Name,
|
||||||
|
ClientName: token.ClientName,
|
||||||
|
Operation: apitokens.OperationListEntries,
|
||||||
|
Message: "browser login match found for " + pageHost,
|
||||||
|
})
|
||||||
|
case 2, 3, 4, 5:
|
||||||
|
s.audit.Record(apiaudit.Event{
|
||||||
|
Type: apiaudit.EventAutofillAmbiguous,
|
||||||
|
TokenID: token.ID,
|
||||||
|
TokenName: token.Name,
|
||||||
|
ClientName: token.ClientName,
|
||||||
|
Operation: apitokens.OperationListEntries,
|
||||||
|
Message: "browser login search returned multiple matches for " + pageHost,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return &keepassgov1.FindBrowserLoginsResponse{Matches: out}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) authorizedBrowserMatches(ctx context.Context, token apitokens.Token, matches []rankedBrowserMatch) ([]*keepassgov1.BrowserLoginMatch, error) {
|
||||||
|
out := make([]*keepassgov1.BrowserLoginMatch, 0, len(matches))
|
||||||
|
for _, match := range matches {
|
||||||
|
if match.decision == apitokens.DecisionAllow {
|
||||||
|
out = append(out, match.match)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(out) != 0 {
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
for _, match := range matches {
|
||||||
|
if match.decision != apitokens.DecisionPrompt {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, err := s.authorizeResourceRequest(ctx, token, apitokens.OperationListEntries, match.resource); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return s.authorizedBrowserMatchesWithinPath(ctx, token, matches, match.resource.Path)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) authorizedBrowserMatchesWithinPath(_ context.Context, _ apitokens.Token, matches []rankedBrowserMatch, path []string) ([]*keepassgov1.BrowserLoginMatch, error) {
|
||||||
|
out := make([]*keepassgov1.BrowserLoginMatch, 0, len(matches))
|
||||||
|
for _, match := range matches {
|
||||||
|
if len(path) > len(match.resource.Path) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !slices.Equal(path, match.resource.Path[:len(path)]) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if match.decision == apitokens.DecisionDeny {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out = append(out, match.match)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) GetBrowserCredential(ctx context.Context, req *keepassgov1.GetBrowserCredentialRequest) (*keepassgov1.GetBrowserCredentialResponse, error) {
|
||||||
|
model, locked := s.snapshotModel()
|
||||||
|
if locked {
|
||||||
|
return nil, status.Error(codes.FailedPrecondition, "vault is locked")
|
||||||
|
}
|
||||||
|
token, err := s.authenticateRequest(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
entry, err := findEntryByID(model, req.GetId())
|
||||||
|
if err != nil {
|
||||||
|
return nil, status.Error(codes.NotFound, err.Error())
|
||||||
|
}
|
||||||
|
if pageURL := strings.TrimSpace(req.GetPageUrl()); pageURL != "" {
|
||||||
|
pageHost, err := normalizedBrowserHost(pageURL)
|
||||||
|
if err != nil {
|
||||||
|
return nil, status.Error(codes.InvalidArgument, err.Error())
|
||||||
|
}
|
||||||
|
if _, score := classifyBrowserEntryMatch(pageHost, entry.URL); score == 0 {
|
||||||
|
return nil, status.Error(codes.InvalidArgument, "entry url does not match requested page")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(entry.Username) != "" {
|
||||||
|
if _, err := s.authorizeResourceRequest(ctx, token, apitokens.OperationCopyUsername, apitokens.Resource{Kind: apitokens.ResourceEntry, EntryID: entry.ID, Path: entry.Path}); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if _, err := s.authorizeResourceRequest(ctx, token, apitokens.OperationCopyPassword, apitokens.Resource{Kind: apitokens.ResourceEntry, EntryID: entry.ID, Path: entry.Path}); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(entry.URL) != "" {
|
||||||
|
if _, err := s.authorizeResourceRequest(ctx, token, apitokens.OperationCopyURL, apitokens.Resource{Kind: apitokens.ResourceEntry, EntryID: entry.ID, Path: entry.Path}); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
s.audit.Record(apiaudit.Event{
|
||||||
|
Type: apiaudit.EventAutofillFound,
|
||||||
|
TokenID: token.ID,
|
||||||
|
TokenName: token.Name,
|
||||||
|
ClientName: token.ClientName,
|
||||||
|
Operation: apitokens.OperationCopyPassword,
|
||||||
|
Resource: apitokens.Resource{Kind: apitokens.ResourceEntry, EntryID: entry.ID, Path: entry.Path},
|
||||||
|
Message: "browser credential returned for " + entry.ID,
|
||||||
|
})
|
||||||
|
return &keepassgov1.GetBrowserCredentialResponse{
|
||||||
|
Id: entry.ID,
|
||||||
|
Username: entry.Username,
|
||||||
|
Password: entry.Password,
|
||||||
|
Url: entry.URL,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
func mapLifecycleError(operation string, err error) error {
|
func mapLifecycleError(operation string, err error) error {
|
||||||
switch {
|
switch {
|
||||||
case errors.Is(err, os.ErrNotExist):
|
case errors.Is(err, os.ErrNotExist):
|
||||||
@@ -245,11 +444,13 @@ 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)
|
||||||
|
internalPath := expandClientPath(displayModel, req.GetPath())
|
||||||
|
if _, err := s.authorizePathRequest(ctx, apitokens.OperationListEntries, internalPath); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
model = visibleModel(model)
|
model = displayModel
|
||||||
var entries []vault.Entry
|
var entries []vault.Entry
|
||||||
if strings.TrimSpace(req.GetQuery()) != "" {
|
if strings.TrimSpace(req.GetQuery()) != "" {
|
||||||
results := model.Search(req.GetQuery())
|
results := model.Search(req.GetQuery())
|
||||||
@@ -258,14 +459,14 @@ func (s *Server) ListEntries(ctx context.Context, req *keepassgov1.ListEntriesRe
|
|||||||
entries = append(entries, result.Entry)
|
entries = append(entries, result.Entry)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
entries = model.EntriesInPath(req.GetPath())
|
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
|
||||||
@@ -276,76 +477,86 @@ func (s *Server) ListGroups(ctx context.Context, req *keepassgov1.ListGroupsRequ
|
|||||||
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.dirty = true
|
||||||
|
s.syncMutationLocked()
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
s.model.CreateGroup(req.GetParentPath(), req.GetName())
|
func (s *Server) CreateGroup(ctx context.Context, req *keepassgov1.CreateGroupRequest) (*keepassgov1.CreateGroupResponse, error) {
|
||||||
s.dirty = true
|
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
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -354,21 +565,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) {
|
||||||
@@ -393,6 +605,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
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -426,7 +639,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) {
|
||||||
@@ -447,7 +661,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
|
||||||
}
|
}
|
||||||
@@ -479,7 +693,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) {
|
||||||
@@ -497,7 +712,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
|
||||||
@@ -518,11 +733,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) {
|
||||||
@@ -543,6 +759,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
|
||||||
}
|
}
|
||||||
@@ -554,7 +771,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
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -565,7 +783,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())
|
||||||
@@ -574,7 +793,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) {
|
||||||
@@ -626,6 +846,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
|
||||||
}
|
}
|
||||||
@@ -684,6 +905,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
|
||||||
}
|
}
|
||||||
@@ -740,7 +962,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,
|
||||||
@@ -749,12 +971,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(),
|
||||||
@@ -763,11 +985,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 {
|
||||||
@@ -787,6 +1031,53 @@ func findMutableEntryByID(model *vault.Model, id string) (vault.Entry, int, erro
|
|||||||
return vault.Entry{}, -1, vault.ErrEntryNotFound
|
return vault.Entry{}, -1, vault.ErrEntryNotFound
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func normalizedBrowserHost(raw string) (string, error) {
|
||||||
|
parsed, err := url.Parse(strings.TrimSpace(raw))
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("parse page url: %w", err)
|
||||||
|
}
|
||||||
|
host := strings.ToLower(parsed.Hostname())
|
||||||
|
if host == "" {
|
||||||
|
return "", fmt.Errorf("page url must include a hostname")
|
||||||
|
}
|
||||||
|
return host, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizedBrowserEntryHost(raw string) string {
|
||||||
|
raw = strings.TrimSpace(raw)
|
||||||
|
if raw == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
parsed, err := url.Parse(raw)
|
||||||
|
if err == nil {
|
||||||
|
if host := strings.ToLower(parsed.Hostname()); host != "" {
|
||||||
|
return host
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !strings.Contains(raw, "://") {
|
||||||
|
parsed, err = url.Parse("https://" + raw)
|
||||||
|
if err == nil {
|
||||||
|
return strings.ToLower(parsed.Hostname())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func classifyBrowserEntryMatch(pageHost, rawEntryURL string) (string, int) {
|
||||||
|
entryHost := normalizedBrowserEntryHost(rawEntryURL)
|
||||||
|
if entryHost == "" {
|
||||||
|
return "", 0
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case pageHost == entryHost:
|
||||||
|
return "exact-host", 3
|
||||||
|
case strings.HasSuffix(pageHost, "."+entryHost):
|
||||||
|
return "subdomain", 2
|
||||||
|
default:
|
||||||
|
return "", 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func visibleModel(model vault.Model) vault.Model {
|
func visibleModel(model vault.Model) vault.Model {
|
||||||
out := model
|
out := model
|
||||||
out.Entries = nil
|
out.Entries = nil
|
||||||
@@ -878,6 +1169,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 {
|
||||||
@@ -891,7 +1220,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:
|
||||||
@@ -903,9 +1234,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)
|
||||||
@@ -919,7 +1250,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):
|
||||||
@@ -929,7 +1260,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):
|
||||||
@@ -939,7 +1270,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):
|
||||||
@@ -949,7 +1280,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):
|
||||||
@@ -978,14 +1309,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 {
|
||||||
@@ -1001,6 +1339,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:
|
||||||
|
|||||||
+618
-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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -159,6 +236,446 @@ func TestVaultServiceRejectsUnauthorizedPasswordGeneration(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestVaultServiceFindsBrowserLoginsForAuthorizedClients(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
client, _, cleanup := newTestClient(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
ctx := tokenContext(defaultTestTokenSecret)
|
||||||
|
resp, err := client.FindBrowserLogins(ctx, &keepassgov1.FindBrowserLoginsRequest{
|
||||||
|
PageUrl: "https://vault.crew.example.invalid/login",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("FindBrowserLogins() error = %v", err)
|
||||||
|
}
|
||||||
|
if len(resp.Matches) != 1 {
|
||||||
|
t.Fatalf("len(FindBrowserLogins().Matches) = %d, want 1", len(resp.Matches))
|
||||||
|
}
|
||||||
|
if resp.Matches[0].Id != "vault-console" {
|
||||||
|
t.Fatalf("FindBrowserLogins().Matches[0].Id = %q, want vault-console", resp.Matches[0].Id)
|
||||||
|
}
|
||||||
|
if resp.Matches[0].Quality != "exact-host" {
|
||||||
|
t.Fatalf("FindBrowserLogins().Matches[0].Quality = %q, want exact-host", resp.Matches[0].Quality)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestVaultServiceFindsBrowserLoginsForSchemeLessEntryURLs(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
client, _, cleanup := newTestClientForModel(t, vault.Model{
|
||||||
|
Entries: []vault.Entry{
|
||||||
|
{
|
||||||
|
ID: "gitlab",
|
||||||
|
Title: "GitLab",
|
||||||
|
Username: "jjulian",
|
||||||
|
Password: "secret",
|
||||||
|
URL: "gitlab.com",
|
||||||
|
Path: []string{"Root", "Internet"},
|
||||||
|
},
|
||||||
|
testAPITokenEntry(t,
|
||||||
|
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationListEntries, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root"}}},
|
||||||
|
),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
resp, err := client.FindBrowserLogins(tokenContext(defaultTestTokenSecret), &keepassgov1.FindBrowserLoginsRequest{
|
||||||
|
PageUrl: "https://gitlab.com/users/sign_in",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("FindBrowserLogins() error = %v", err)
|
||||||
|
}
|
||||||
|
if len(resp.Matches) != 1 {
|
||||||
|
t.Fatalf("len(FindBrowserLogins().Matches) = %d, want 1", len(resp.Matches))
|
||||||
|
}
|
||||||
|
if resp.Matches[0].Id != "gitlab" {
|
||||||
|
t.Fatalf("FindBrowserLogins().Matches[0].Id = %q, want gitlab", resp.Matches[0].Id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestVaultServiceFindsBrowserLoginsWithinAuthorizedGroupScope(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
client, _, cleanup := newTestClientForModel(t, vault.Model{
|
||||||
|
Entries: []vault.Entry{
|
||||||
|
{
|
||||||
|
ID: "codex-nextcloud",
|
||||||
|
Title: "Nextcloud (codex)",
|
||||||
|
Username: "jjulian",
|
||||||
|
Password: "secret-1",
|
||||||
|
URL: "https://nextcloud.example.invalid",
|
||||||
|
Path: []string{"keepass", "Joe", "codex"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: "joe-nextcloud",
|
||||||
|
Title: "Nextcloud",
|
||||||
|
Username: "jjulian",
|
||||||
|
Password: "secret-2",
|
||||||
|
URL: "https://nextcloud.example.invalid",
|
||||||
|
Path: []string{"keepass", "Joe", "Internet"},
|
||||||
|
},
|
||||||
|
testAPITokenEntry(t,
|
||||||
|
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationListEntries, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root", "Joe", "codex"}}},
|
||||||
|
),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
resp, err := client.FindBrowserLogins(tokenContext(defaultTestTokenSecret), &keepassgov1.FindBrowserLoginsRequest{
|
||||||
|
PageUrl: "https://nextcloud.example.invalid/login",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("FindBrowserLogins() error = %v", err)
|
||||||
|
}
|
||||||
|
if len(resp.Matches) != 1 {
|
||||||
|
t.Fatalf("len(FindBrowserLogins().Matches) = %d, want 1", len(resp.Matches))
|
||||||
|
}
|
||||||
|
if resp.Matches[0].Id != "codex-nextcloud" {
|
||||||
|
t.Fatalf("FindBrowserLogins().Matches[0].Id = %q, want codex-nextcloud", resp.Matches[0].Id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestVaultServiceFindsBrowserLoginsRechecksChildPoliciesAfterPrompt(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
model := vault.Model{
|
||||||
|
Entries: []vault.Entry{
|
||||||
|
{
|
||||||
|
ID: "rusty-casino",
|
||||||
|
Title: "Rusty Casino",
|
||||||
|
Username: "rustyryan",
|
||||||
|
Password: "bellagio-1",
|
||||||
|
URL: "https://vault.heist.example.invalid",
|
||||||
|
Path: []string{"Crews", "Bellagio"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: "benedict-vault",
|
||||||
|
Title: "Benedict Vault",
|
||||||
|
Username: "terrybenedict",
|
||||||
|
Password: "bellagio-2",
|
||||||
|
URL: "https://vault.heist.example.invalid",
|
||||||
|
Path: []string{"Crews", "Bellagio", "Denied"},
|
||||||
|
},
|
||||||
|
testAPITokenEntry(t,
|
||||||
|
apitokens.PolicyRule{Effect: apitokens.EffectDeny, Operation: apitokens.OperationListEntries, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Crews", "Bellagio", "Denied"}}},
|
||||||
|
),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
client, _, service, cleanup := newTestHarnessForModel(t, model)
|
||||||
|
defer cleanup()
|
||||||
|
service.approvals = apiapproval.NewBroker(time.Minute)
|
||||||
|
|
||||||
|
respCh := make(chan *keepassgov1.FindBrowserLoginsResponse, 1)
|
||||||
|
errCh := make(chan error, 1)
|
||||||
|
go func() {
|
||||||
|
resp, err := client.FindBrowserLogins(tokenContext(defaultTestTokenSecret), &keepassgov1.FindBrowserLoginsRequest{
|
||||||
|
PageUrl: "https://vault.heist.example.invalid/login",
|
||||||
|
})
|
||||||
|
respCh <- resp
|
||||||
|
errCh <- err
|
||||||
|
}()
|
||||||
|
|
||||||
|
pending := waitForServerPendingApproval(t, service, 1)[0]
|
||||||
|
if got := pending.Resource.Path; !slices.Equal(got, []string{"Crews", "Bellagio"}) {
|
||||||
|
t.Fatalf("pending.Resource.Path = %v, want [Crews Bellagio]", got)
|
||||||
|
}
|
||||||
|
if _, _, err := service.ResolveApproval(pending.ID, apiapproval.OutcomeAllowOnce); err != nil {
|
||||||
|
t.Fatalf("ResolveApproval(allow once) error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp := <-respCh
|
||||||
|
if err := <-errCh; err != nil {
|
||||||
|
t.Fatalf("FindBrowserLogins() error = %v", err)
|
||||||
|
}
|
||||||
|
if len(resp.Matches) != 1 {
|
||||||
|
t.Fatalf("len(FindBrowserLogins().Matches) = %d, want 1", len(resp.Matches))
|
||||||
|
}
|
||||||
|
if got := resp.Matches[0].Id; got != "rusty-casino" {
|
||||||
|
t.Fatalf("FindBrowserLogins().Matches[0].Id = %q, want rusty-casino", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestVaultServiceApprovalRequestsUseLogicalRootPathForPhysicalVault(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
model := vault.Model{
|
||||||
|
Entries: []vault.Entry{
|
||||||
|
{
|
||||||
|
ID: "codex-nextcloud",
|
||||||
|
Title: "Nextcloud (codex)",
|
||||||
|
Username: "jjulian",
|
||||||
|
Password: "secret-1",
|
||||||
|
URL: "https://nextcloud.example.invalid",
|
||||||
|
Path: []string{"keepass", "Joe", "codex"},
|
||||||
|
},
|
||||||
|
testAPITokenEntry(t),
|
||||||
|
},
|
||||||
|
Groups: [][]string{
|
||||||
|
{"keepass"},
|
||||||
|
{"keepass", "Joe"},
|
||||||
|
{"keepass", "Joe", "codex"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
client, _, service, cleanup := newTestHarnessForModel(t, model)
|
||||||
|
defer cleanup()
|
||||||
|
service.approvals = apiapproval.NewBroker(time.Minute)
|
||||||
|
|
||||||
|
respCh := make(chan *keepassgov1.ListEntriesResponse, 1)
|
||||||
|
errCh := make(chan error, 1)
|
||||||
|
go func() {
|
||||||
|
resp, err := client.ListEntries(tokenContext(defaultTestTokenSecret), &keepassgov1.ListEntriesRequest{
|
||||||
|
Path: []string{"Joe", "codex"},
|
||||||
|
})
|
||||||
|
respCh <- resp
|
||||||
|
errCh <- err
|
||||||
|
}()
|
||||||
|
|
||||||
|
pending := waitForServerPendingApproval(t, service, 1)[0]
|
||||||
|
if got := pending.Resource.Path; !slices.Equal(got, []string{"Root", "Joe", "codex"}) {
|
||||||
|
t.Fatalf("pending.Resource.Path = %v, want [Root Joe codex]", got)
|
||||||
|
}
|
||||||
|
if _, _, err := service.ResolveApproval(pending.ID, apiapproval.OutcomeAllowOnce); err != nil {
|
||||||
|
t.Fatalf("ResolveApproval(allow once) error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := <-errCh; err != nil {
|
||||||
|
t.Fatalf("ListEntries() error = %v", err)
|
||||||
|
}
|
||||||
|
resp := <-respCh
|
||||||
|
if len(resp.GetEntries()) != 1 || resp.GetEntries()[0].GetId() != "codex-nextcloud" {
|
||||||
|
t.Fatalf("ListEntries().Entries = %#v, want codex-nextcloud after approval", resp.GetEntries())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestVaultServiceDoesNotMatchSpecificBrowserEntryToParentDomain(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
client, _, cleanup := newTestClientForModel(t, vault.Model{
|
||||||
|
Entries: []vault.Entry{
|
||||||
|
{
|
||||||
|
ID: "inside-man-accounts",
|
||||||
|
Title: "Inside Man Accounts",
|
||||||
|
Username: "daltonrussell",
|
||||||
|
Password: "diamond-1",
|
||||||
|
URL: "https://accounts.heist.example.invalid",
|
||||||
|
Path: []string{"Crews", "Bank"},
|
||||||
|
},
|
||||||
|
testAPITokenEntry(t,
|
||||||
|
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationListEntries, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Crews"}}},
|
||||||
|
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationCopyUsername, Resource: apitokens.Resource{Kind: apitokens.ResourceEntry, EntryID: "inside-man-accounts", Path: []string{"Crews", "Bank"}}},
|
||||||
|
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationCopyPassword, Resource: apitokens.Resource{Kind: apitokens.ResourceEntry, EntryID: "inside-man-accounts", Path: []string{"Crews", "Bank"}}},
|
||||||
|
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationCopyURL, Resource: apitokens.Resource{Kind: apitokens.ResourceEntry, EntryID: "inside-man-accounts", Path: []string{"Crews", "Bank"}}},
|
||||||
|
),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
resp, err := client.FindBrowserLogins(tokenContext(defaultTestTokenSecret), &keepassgov1.FindBrowserLoginsRequest{
|
||||||
|
PageUrl: "https://heist.example.invalid/login",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("FindBrowserLogins() error = %v", err)
|
||||||
|
}
|
||||||
|
if len(resp.Matches) != 0 {
|
||||||
|
t.Fatalf("len(FindBrowserLogins().Matches) = %d, want 0", len(resp.Matches))
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = client.GetBrowserCredential(tokenContext(defaultTestTokenSecret), &keepassgov1.GetBrowserCredentialRequest{
|
||||||
|
Id: "inside-man-accounts",
|
||||||
|
PageUrl: "https://heist.example.invalid/login",
|
||||||
|
})
|
||||||
|
if status.Code(err) != codes.InvalidArgument {
|
||||||
|
t.Fatalf("GetBrowserCredential() code = %v, want %v", status.Code(err), codes.InvalidArgument)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestVaultServiceListEntriesHidesSingleInternalVaultRoot(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
client, _, cleanup := newTestClientForModel(t, vault.Model{
|
||||||
|
Entries: []vault.Entry{
|
||||||
|
{
|
||||||
|
ID: "codex-nextcloud",
|
||||||
|
Title: "Nextcloud (codex)",
|
||||||
|
Username: "jjulian",
|
||||||
|
Password: "secret-1",
|
||||||
|
URL: "https://nextcloud.example.invalid",
|
||||||
|
Path: []string{"keepass", "Joe", "codex"},
|
||||||
|
},
|
||||||
|
testAPITokenEntry(t,
|
||||||
|
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationListEntries, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root", "Joe", "codex"}}},
|
||||||
|
),
|
||||||
|
},
|
||||||
|
Groups: [][]string{
|
||||||
|
{"keepass"},
|
||||||
|
{"keepass", "Joe"},
|
||||||
|
{"keepass", "Joe", "codex"},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
resp, err := client.ListEntries(tokenContext(defaultTestTokenSecret), &keepassgov1.ListEntriesRequest{
|
||||||
|
Path: []string{"Joe", "codex"},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ListEntries() error = %v", err)
|
||||||
|
}
|
||||||
|
if len(resp.Entries) != 1 {
|
||||||
|
t.Fatalf("len(ListEntries().Entries) = %d, want 1", len(resp.Entries))
|
||||||
|
}
|
||||||
|
if got := resp.Entries[0].Path; !slices.Equal(got, []string{"Joe", "codex"}) {
|
||||||
|
t.Fatalf("ListEntries().Entries[0].Path = %v, want [Joe codex]", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestVaultServiceListEntriesHidesSingleInternalVaultRootWhenRecycleBinExists(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
client, _, cleanup := newTestClientForModel(t, vault.Model{
|
||||||
|
Entries: []vault.Entry{
|
||||||
|
{
|
||||||
|
ID: "codex-nextcloud",
|
||||||
|
Title: "Nextcloud (codex)",
|
||||||
|
Username: "jjulian",
|
||||||
|
Password: "secret-1",
|
||||||
|
URL: "https://nextcloud.example.invalid",
|
||||||
|
Path: []string{"keepass", "Joe", "codex"},
|
||||||
|
},
|
||||||
|
testAPITokenEntry(t,
|
||||||
|
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationListEntries, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root", "Joe", "codex"}}},
|
||||||
|
),
|
||||||
|
},
|
||||||
|
Groups: [][]string{
|
||||||
|
{"keepass"},
|
||||||
|
{"keepass", "Joe"},
|
||||||
|
{"keepass", "Joe", "codex"},
|
||||||
|
{"Recycle Bin"},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
resp, err := client.ListEntries(tokenContext(defaultTestTokenSecret), &keepassgov1.ListEntriesRequest{
|
||||||
|
Path: []string{"Joe", "codex"},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ListEntries() error = %v", err)
|
||||||
|
}
|
||||||
|
if len(resp.Entries) != 1 {
|
||||||
|
t.Fatalf("len(ListEntries().Entries) = %d, want 1", len(resp.Entries))
|
||||||
|
}
|
||||||
|
if got := resp.Entries[0].Path; !slices.Equal(got, []string{"Joe", "codex"}) {
|
||||||
|
t.Fatalf("ListEntries().Entries[0].Path = %v, want [Joe codex]", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestVaultServiceListGroupsHidesSingleInternalVaultRoot(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
client, _, cleanup := newTestClientForModel(t, vault.Model{
|
||||||
|
Entries: []vault.Entry{
|
||||||
|
testAPITokenEntry(t,
|
||||||
|
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationListGroups, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root"}}},
|
||||||
|
),
|
||||||
|
},
|
||||||
|
Groups: [][]string{
|
||||||
|
{"keepass"},
|
||||||
|
{"keepass", "Joe"},
|
||||||
|
{"keepass", "Shared"},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
resp, err := client.ListGroups(tokenContext(defaultTestTokenSecret), &keepassgov1.ListGroupsRequest{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ListGroups() error = %v", err)
|
||||||
|
}
|
||||||
|
if !slices.Equal(resp.Names, []string{"Joe", "Shared"}) {
|
||||||
|
t.Fatalf("ListGroups().Names = %v, want [Joe Shared]", resp.Names)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestVaultServiceListGroupsHidesSingleInternalVaultRootWhenRecycleBinExists(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
client, _, cleanup := newTestClientForModel(t, vault.Model{
|
||||||
|
Entries: []vault.Entry{
|
||||||
|
testAPITokenEntry(t,
|
||||||
|
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationListGroups, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root"}}},
|
||||||
|
),
|
||||||
|
},
|
||||||
|
Groups: [][]string{
|
||||||
|
{"keepass"},
|
||||||
|
{"keepass", "Joe"},
|
||||||
|
{"keepass", "Shared"},
|
||||||
|
{"Recycle Bin"},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
resp, err := client.ListGroups(tokenContext(defaultTestTokenSecret), &keepassgov1.ListGroupsRequest{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ListGroups() error = %v", err)
|
||||||
|
}
|
||||||
|
if !slices.Equal(resp.Names, []string{"Joe", "Shared"}) {
|
||||||
|
t.Fatalf("ListGroups().Names = %v, want [Joe Shared]", resp.Names)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestVaultServiceGetsBrowserCredentialForAuthorizedClients(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
client, _, cleanup := newTestClient(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
ctx := tokenContext(defaultTestTokenSecret)
|
||||||
|
resp, err := client.GetBrowserCredential(ctx, &keepassgov1.GetBrowserCredentialRequest{
|
||||||
|
Id: "vault-console",
|
||||||
|
PageUrl: "https://vault.crew.example.invalid/login",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetBrowserCredential() error = %v", err)
|
||||||
|
}
|
||||||
|
if resp.Id != "vault-console" {
|
||||||
|
t.Fatalf("GetBrowserCredential().Id = %q, want vault-console", resp.Id)
|
||||||
|
}
|
||||||
|
if resp.Password != "token-1" {
|
||||||
|
t.Fatalf("GetBrowserCredential().Password = %q, want token-1", resp.Password)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestVaultServiceRejectsUnauthorizedBrowserCredentialAccess(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
client, _, cleanup := newTestClientForModel(t, vault.Model{
|
||||||
|
Entries: []vault.Entry{
|
||||||
|
{
|
||||||
|
ID: "vault-console",
|
||||||
|
Title: "Vault Console",
|
||||||
|
Username: "dannyocean",
|
||||||
|
Password: "token-1",
|
||||||
|
URL: "https://vault.crew.example.invalid",
|
||||||
|
Path: []string{"Root", "Internet"},
|
||||||
|
},
|
||||||
|
testAPITokenEntry(t,
|
||||||
|
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationListEntries, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root"}}},
|
||||||
|
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationCopyUsername, Resource: apitokens.Resource{Kind: apitokens.ResourceEntry, EntryID: "vault-console", Path: []string{"Root", "Internet"}}},
|
||||||
|
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationCopyURL, Resource: apitokens.Resource{Kind: apitokens.ResourceEntry, EntryID: "vault-console", Path: []string{"Root", "Internet"}}},
|
||||||
|
apitokens.PolicyRule{Effect: apitokens.EffectDeny, Operation: apitokens.OperationCopyPassword, Resource: apitokens.Resource{Kind: apitokens.ResourceEntry, EntryID: "vault-console", Path: []string{"Root", "Internet"}}},
|
||||||
|
),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
_, err := client.GetBrowserCredential(tokenContext(defaultTestTokenSecret), &keepassgov1.GetBrowserCredentialRequest{
|
||||||
|
Id: "vault-console",
|
||||||
|
PageUrl: "https://vault.crew.example.invalid/login",
|
||||||
|
})
|
||||||
|
if status.Code(err) != codes.PermissionDenied {
|
||||||
|
t.Fatalf("GetBrowserCredential() code = %v, want %v", status.Code(err), codes.PermissionDenied)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestVaultServicePromptsAndResumesWhenApproved(t *testing.T) {
|
func TestVaultServicePromptsAndResumesWhenApproved(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -862,6 +1379,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
|
||||||
@@ -50,6 +51,7 @@ type Broker struct {
|
|||||||
timeout time.Duration
|
timeout time.Duration
|
||||||
now func() time.Time
|
now func() time.Time
|
||||||
nextID func() string
|
nextID func() string
|
||||||
|
notify func()
|
||||||
}
|
}
|
||||||
|
|
||||||
type pendingRequest struct {
|
type pendingRequest struct {
|
||||||
@@ -108,9 +110,18 @@ func (b *Broker) Pending() []Request {
|
|||||||
return requests
|
return requests
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (b *Broker) SetChangeNotifier(notify func()) {
|
||||||
|
if b == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
b.mu.Lock()
|
||||||
|
defer b.mu.Unlock()
|
||||||
|
b.notify = notify
|
||||||
|
}
|
||||||
|
|
||||||
func (b *Broker) Request(ctx context.Context, token apitokens.Token, op apitokens.Operation, resource apitokens.Resource) (Result, error) {
|
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{
|
||||||
@@ -128,12 +139,20 @@ func (b *Broker) Request(ctx context.Context, token apitokens.Token, op apitoken
|
|||||||
|
|
||||||
b.mu.Lock()
|
b.mu.Lock()
|
||||||
b.pending[pending.request.ID] = pending
|
b.pending[pending.request.ID] = pending
|
||||||
|
notify := b.notify
|
||||||
b.mu.Unlock()
|
b.mu.Unlock()
|
||||||
|
if notify != nil {
|
||||||
|
notify()
|
||||||
|
}
|
||||||
|
|
||||||
defer func() {
|
defer func() {
|
||||||
b.mu.Lock()
|
b.mu.Lock()
|
||||||
delete(b.pending, pending.request.ID)
|
delete(b.pending, pending.request.ID)
|
||||||
|
notify := b.notify
|
||||||
b.mu.Unlock()
|
b.mu.Unlock()
|
||||||
|
if notify != nil {
|
||||||
|
notify()
|
||||||
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
timer := time.NewTimer(b.timeout)
|
timer := time.NewTimer(b.timeout)
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package apiapproval
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
|
"slices"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -120,6 +121,46 @@ func TestBrokerTimesOutPendingRequests(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestNilBrokerReturnsConfigurationError(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
var broker *Broker
|
||||||
|
_, err := broker.Request(context.Background(), apitokens.Token{ID: "token-1", Name: "CLI"}, apitokens.OperationListGroups, apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root"}})
|
||||||
|
if !errors.Is(err, ErrBrokerNotConfigured) {
|
||||||
|
t.Fatalf("Request(nil broker) error = %v, want %v", err, ErrBrokerNotConfigured)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBrokerNotifiesWhenPendingRequestsChange(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
broker := NewBroker(time.Minute)
|
||||||
|
changes := make(chan int, 4)
|
||||||
|
broker.SetChangeNotifier(func() {
|
||||||
|
changes <- len(broker.Pending())
|
||||||
|
})
|
||||||
|
|
||||||
|
errCh := make(chan error, 1)
|
||||||
|
go func() {
|
||||||
|
_, err := broker.Request(context.Background(), apitokens.Token{ID: "token-1", Name: "CLI"}, apitokens.OperationListGroups, apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root"}})
|
||||||
|
errCh <- err
|
||||||
|
}()
|
||||||
|
|
||||||
|
waitForPending(t, broker, 1)
|
||||||
|
if _, _, err := broker.Resolve(broker.Pending()[0].ID, OutcomeAllowOnce); err != nil {
|
||||||
|
t.Fatalf("Resolve(allow once) error = %v", err)
|
||||||
|
}
|
||||||
|
if err := <-errCh; err != nil {
|
||||||
|
t.Fatalf("Request() error = %v, want nil", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
got := []int{<-changes, <-changes}
|
||||||
|
slices.Sort(got)
|
||||||
|
if !slices.Equal(got, []int{0, 1}) {
|
||||||
|
t.Fatalf("change notifications = %v, want [0 1]", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func waitForPending(t *testing.T, broker *Broker, want int) {
|
func waitForPending(t *testing.T, broker *Broker, want int) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
|
|||||||
+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)
|
||||||
}
|
}
|
||||||
|
|||||||
+19
-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"
|
||||||
@@ -380,6 +381,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
|
||||||
@@ -475,6 +477,7 @@ type ui struct {
|
|||||||
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
|
||||||
@@ -665,6 +668,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 +682,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)
|
||||||
@@ -1211,6 +1216,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 +1512,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 +2872,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 +2903,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) {
|
||||||
|
|||||||
@@ -47,7 +47,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 +69,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 {
|
||||||
@@ -111,7 +111,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 {
|
||||||
@@ -329,7 +329,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 +346,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 +375,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()
|
||||||
|
|||||||
+262
-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)
|
||||||
@@ -8322,7 +8513,7 @@ 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)
|
||||||
@@ -9169,8 +9360,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 +9388,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,22 +1260,11 @@ 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() {
|
||||||
root := u.hiddenVaultRoot()
|
root := u.hiddenVaultRoot()
|
||||||
@@ -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,8 @@ 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/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"
|
||||||
@@ -56,13 +58,11 @@ func Main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func defaultGRPCAddr(goos string) string {
|
func defaultGRPCAddr(goos string) string {
|
||||||
if strings.EqualFold(strings.TrimSpace(goos), "android") {
|
return grpcaddr.Default(goos)
|
||||||
return "off"
|
|
||||||
}
|
|
||||||
return "127.0.0.1:47777"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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)
|
||||||
@@ -78,6 +78,11 @@ 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)
|
||||||
defer func() { _ = host.Stop() }()
|
defer func() { _ = host.Stop() }()
|
||||||
}
|
}
|
||||||
for {
|
for {
|
||||||
@@ -96,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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,346 @@
|
|||||||
|
package browserbridge
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/binary"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"git.julianfamily.org/keepassgo/internal/grpcaddr"
|
||||||
|
keepassgov1 "git.julianfamily.org/keepassgo/proto/keepassgo/v1"
|
||||||
|
gcodes "google.golang.org/grpc/codes"
|
||||||
|
gstatus "google.golang.org/grpc/status"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
NativeHostName = "com.keepassgo.browser"
|
||||||
|
defaultFirefoxID = "browser@keepassgo.com"
|
||||||
|
maxNativeMessageSize = 1024 * 1024
|
||||||
|
chromiumIDBytes = 16
|
||||||
|
responseVersion = "1"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Request struct {
|
||||||
|
Action string `json:"action"`
|
||||||
|
BearerToken string `json:"bearerToken,omitempty"`
|
||||||
|
URL string `json:"url,omitempty"`
|
||||||
|
EntryID string `json:"entryId,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Response struct {
|
||||||
|
Success bool `json:"success"`
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
|
Status *Status `json:"status,omitempty"`
|
||||||
|
Matches []Match `json:"matches,omitempty"`
|
||||||
|
Credential *Credential `json:"credential,omitempty"`
|
||||||
|
Version string `json:"version,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Status struct {
|
||||||
|
Connected bool `json:"connected"`
|
||||||
|
Locked bool `json:"locked"`
|
||||||
|
Dirty bool `json:"dirty,omitempty"`
|
||||||
|
EntryCount uint32 `json:"entryCount,omitempty"`
|
||||||
|
PendingApprovalCount uint32 `json:"pendingApprovalCount,omitempty"`
|
||||||
|
TokenPendingApprovalCount uint32 `json:"tokenPendingApprovalCount,omitempty"`
|
||||||
|
GRPCAddress string `json:"grpcAddress,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Match struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Username string `json:"username,omitempty"`
|
||||||
|
URL string `json:"url,omitempty"`
|
||||||
|
Path []string `json:"path,omitempty"`
|
||||||
|
Quality string `json:"quality,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Credential struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Username string `json:"username,omitempty"`
|
||||||
|
Password string `json:"password,omitempty"`
|
||||||
|
URL string `json:"url,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Connection struct {
|
||||||
|
GRPCAddress string
|
||||||
|
BearerToken string
|
||||||
|
}
|
||||||
|
|
||||||
|
type Client interface {
|
||||||
|
Status(context.Context) (*keepassgov1.GetSessionStatusResponse, error)
|
||||||
|
FindBrowserLogins(context.Context, string) ([]*keepassgov1.BrowserLoginMatch, error)
|
||||||
|
GetBrowserCredential(context.Context, string, string) (*keepassgov1.GetBrowserCredentialResponse, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type Browser string
|
||||||
|
|
||||||
|
const (
|
||||||
|
BrowserFirefox Browser = "firefox"
|
||||||
|
BrowserChrome Browser = "chrome"
|
||||||
|
BrowserChromium Browser = "chromium"
|
||||||
|
)
|
||||||
|
|
||||||
|
type NativeHostManifest struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
Path string `json:"path"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
AllowedExtensions []string `json:"allowed_extensions,omitempty"`
|
||||||
|
AllowedOrigins []string `json:"allowed_origins,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func DefaultFirefoxExtensionID() string {
|
||||||
|
return defaultFirefoxID
|
||||||
|
}
|
||||||
|
|
||||||
|
func ReadRequest(r io.Reader) (Request, error) {
|
||||||
|
var sizeBuf [4]byte
|
||||||
|
if _, err := io.ReadFull(r, sizeBuf[:]); err != nil {
|
||||||
|
return Request{}, err
|
||||||
|
}
|
||||||
|
size := binary.LittleEndian.Uint32(sizeBuf[:])
|
||||||
|
if size == 0 || size > maxNativeMessageSize {
|
||||||
|
return Request{}, fmt.Errorf("invalid native message size %d", size)
|
||||||
|
}
|
||||||
|
body := make([]byte, size)
|
||||||
|
if _, err := io.ReadFull(r, body); err != nil {
|
||||||
|
return Request{}, err
|
||||||
|
}
|
||||||
|
var req Request
|
||||||
|
if err := json.Unmarshal(body, &req); err != nil {
|
||||||
|
return Request{}, fmt.Errorf("decode native request: %w", err)
|
||||||
|
}
|
||||||
|
return req, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func WriteResponse(w io.Writer, resp Response) error {
|
||||||
|
data, err := json.Marshal(resp)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("encode native response: %w", err)
|
||||||
|
}
|
||||||
|
if len(data) > maxNativeMessageSize {
|
||||||
|
return fmt.Errorf("native response too large: %d", len(data))
|
||||||
|
}
|
||||||
|
var sizeBuf [4]byte
|
||||||
|
binary.LittleEndian.PutUint32(sizeBuf[:], uint32(len(data)))
|
||||||
|
if _, err := w.Write(sizeBuf[:]); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_, err = w.Write(data)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r Request) Connection(grpcAddr string) (Connection, error) {
|
||||||
|
return normalizeConnection(Connection{
|
||||||
|
GRPCAddress: strings.TrimSpace(grpcAddr),
|
||||||
|
BearerToken: strings.TrimSpace(r.BearerToken),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeConnection(conn Connection) (Connection, error) {
|
||||||
|
if strings.TrimSpace(conn.GRPCAddress) == "" {
|
||||||
|
conn.GRPCAddress = grpcaddr.Default(runtime.GOOS)
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(conn.BearerToken) == "" {
|
||||||
|
return Connection{}, fmt.Errorf("browser bridge bearer token is required")
|
||||||
|
}
|
||||||
|
conn.GRPCAddress = strings.TrimSpace(conn.GRPCAddress)
|
||||||
|
conn.BearerToken = strings.TrimSpace(conn.BearerToken)
|
||||||
|
return conn, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func HandleRequest(ctx context.Context, req Request, grpcAddr string, client Client) Response {
|
||||||
|
conn, err := req.Connection(grpcAddr)
|
||||||
|
if err != nil {
|
||||||
|
return Response{Success: false, Error: err.Error()}
|
||||||
|
}
|
||||||
|
action := strings.TrimSpace(req.Action)
|
||||||
|
switch action {
|
||||||
|
case "status":
|
||||||
|
status, err := statusResponse(ctx, client, conn.GRPCAddress)
|
||||||
|
if err != nil {
|
||||||
|
return Response{Success: false, Error: err.Error(), Status: disconnectedStatus(conn.GRPCAddress)}
|
||||||
|
}
|
||||||
|
return Response{Success: true, Status: status, Version: responseVersion}
|
||||||
|
case "find-logins":
|
||||||
|
matches, err := findMatches(ctx, client, req.URL)
|
||||||
|
if err != nil {
|
||||||
|
if status := inferredActionStatus(conn.GRPCAddress, err); status != nil {
|
||||||
|
return Response{Success: true, Status: status, Matches: nil, Version: responseVersion}
|
||||||
|
}
|
||||||
|
return Response{Success: false, Error: err.Error(), Status: disconnectedStatus(conn.GRPCAddress)}
|
||||||
|
}
|
||||||
|
return Response{Success: true, Status: availableStatus(conn.GRPCAddress), Matches: matches, Version: responseVersion}
|
||||||
|
case "get-login":
|
||||||
|
credential, err := loadCredential(ctx, client, req.EntryID, req.URL)
|
||||||
|
if err != nil {
|
||||||
|
if status := inferredActionStatus(conn.GRPCAddress, err); status != nil {
|
||||||
|
return Response{Success: false, Error: err.Error(), Status: status}
|
||||||
|
}
|
||||||
|
return Response{Success: false, Error: err.Error(), Status: disconnectedStatus(conn.GRPCAddress)}
|
||||||
|
}
|
||||||
|
return Response{Success: true, Status: availableStatus(conn.GRPCAddress), Credential: credential, Version: responseVersion}
|
||||||
|
default:
|
||||||
|
return Response{Success: false, Error: fmt.Sprintf("unsupported action %q", action)}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func disconnectedStatus(addr string) *Status {
|
||||||
|
return &Status{Connected: false, GRPCAddress: strings.TrimSpace(addr)}
|
||||||
|
}
|
||||||
|
|
||||||
|
func availableStatus(addr string) *Status {
|
||||||
|
return &Status{Connected: true, Locked: false, GRPCAddress: strings.TrimSpace(addr)}
|
||||||
|
}
|
||||||
|
|
||||||
|
func inferredActionStatus(addr string, err error) *Status {
|
||||||
|
switch gstatus.Code(err) {
|
||||||
|
case gcodes.FailedPrecondition:
|
||||||
|
return &Status{Connected: true, Locked: true, GRPCAddress: strings.TrimSpace(addr)}
|
||||||
|
case gcodes.OK:
|
||||||
|
return availableStatus(addr)
|
||||||
|
default:
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func statusResponse(ctx context.Context, client Client, addr string) (*Status, error) {
|
||||||
|
resp, err := client.Status(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &Status{
|
||||||
|
Connected: true,
|
||||||
|
Locked: resp.GetLocked(),
|
||||||
|
Dirty: resp.GetDirty(),
|
||||||
|
EntryCount: resp.GetEntryCount(),
|
||||||
|
PendingApprovalCount: resp.GetPendingApprovalCount(),
|
||||||
|
TokenPendingApprovalCount: resp.GetTokenPendingApprovalCount(),
|
||||||
|
GRPCAddress: strings.TrimSpace(addr),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func findMatches(ctx context.Context, client Client, rawURL string) ([]Match, error) {
|
||||||
|
resp, err := client.FindBrowserLogins(ctx, strings.TrimSpace(rawURL))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out := make([]Match, 0, len(resp))
|
||||||
|
for _, match := range resp {
|
||||||
|
out = append(out, Match{
|
||||||
|
ID: match.GetId(),
|
||||||
|
Title: match.GetTitle(),
|
||||||
|
Username: match.GetUsername(),
|
||||||
|
URL: match.GetUrl(),
|
||||||
|
Path: append([]string(nil), match.GetPath()...),
|
||||||
|
Quality: match.GetQuality(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadCredential(ctx context.Context, client Client, entryID, rawURL string) (*Credential, error) {
|
||||||
|
id := strings.TrimSpace(entryID)
|
||||||
|
if id == "" {
|
||||||
|
return nil, fmt.Errorf("entry id is required")
|
||||||
|
}
|
||||||
|
resp, err := client.GetBrowserCredential(ctx, id, strings.TrimSpace(rawURL))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &Credential{
|
||||||
|
ID: resp.GetId(),
|
||||||
|
Username: resp.GetUsername(),
|
||||||
|
Password: resp.GetPassword(),
|
||||||
|
URL: resp.GetUrl(),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func Manifest(browser Browser, binaryPath, extensionID string) (NativeHostManifest, error) {
|
||||||
|
path := strings.TrimSpace(binaryPath)
|
||||||
|
if path == "" {
|
||||||
|
return NativeHostManifest{}, fmt.Errorf("native host binary path is required")
|
||||||
|
}
|
||||||
|
switch browser {
|
||||||
|
case BrowserFirefox:
|
||||||
|
id := strings.TrimSpace(extensionID)
|
||||||
|
if id == "" {
|
||||||
|
id = defaultFirefoxID
|
||||||
|
}
|
||||||
|
return NativeHostManifest{
|
||||||
|
Name: NativeHostName,
|
||||||
|
Description: "KeePassGO browser bridge",
|
||||||
|
Path: path,
|
||||||
|
Type: "stdio",
|
||||||
|
AllowedExtensions: []string{id},
|
||||||
|
}, nil
|
||||||
|
case BrowserChrome, BrowserChromium:
|
||||||
|
id := strings.TrimSpace(extensionID)
|
||||||
|
if id == "" {
|
||||||
|
return NativeHostManifest{}, fmt.Errorf("%s extension id is required", browser)
|
||||||
|
}
|
||||||
|
return NativeHostManifest{
|
||||||
|
Name: NativeHostName,
|
||||||
|
Description: "KeePassGO browser bridge",
|
||||||
|
Path: path,
|
||||||
|
Type: "stdio",
|
||||||
|
AllowedOrigins: []string{"chrome-extension://" + id + "/"},
|
||||||
|
}, nil
|
||||||
|
default:
|
||||||
|
return NativeHostManifest{}, fmt.Errorf("unsupported browser %q", browser)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func ChromiumExtensionIDFromManifestKey(raw string) (string, error) {
|
||||||
|
normalized := strings.TrimSpace(raw)
|
||||||
|
normalized = strings.ReplaceAll(normalized, "-----BEGIN PUBLIC KEY-----", "")
|
||||||
|
normalized = strings.ReplaceAll(normalized, "-----END PUBLIC KEY-----", "")
|
||||||
|
normalized = strings.ReplaceAll(normalized, "\n", "")
|
||||||
|
normalized = strings.ReplaceAll(normalized, "\r", "")
|
||||||
|
normalized = strings.ReplaceAll(normalized, "\t", "")
|
||||||
|
normalized = strings.ReplaceAll(normalized, " ", "")
|
||||||
|
if normalized == "" {
|
||||||
|
return "", fmt.Errorf("chromium extension key is required")
|
||||||
|
}
|
||||||
|
publicKeyDER, err := base64.StdEncoding.DecodeString(normalized)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("decode chromium extension key: %w", err)
|
||||||
|
}
|
||||||
|
hash := sha256.Sum256(publicKeyDER)
|
||||||
|
var builder strings.Builder
|
||||||
|
builder.Grow(chromiumIDBytes * 2)
|
||||||
|
for _, b := range hash[:chromiumIDBytes] {
|
||||||
|
builder.WriteByte('a' + ((b >> 4) & 0x0f))
|
||||||
|
builder.WriteByte('a' + (b & 0x0f))
|
||||||
|
}
|
||||||
|
return builder.String(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func DefaultManifestPath(browser Browser) (string, error) {
|
||||||
|
home, err := os.UserHomeDir()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
switch browser {
|
||||||
|
case BrowserFirefox:
|
||||||
|
return filepath.Join(home, ".mozilla", "native-messaging-hosts", NativeHostName+".json"), nil
|
||||||
|
case BrowserChrome:
|
||||||
|
return filepath.Join(home, ".config", "google-chrome", "NativeMessagingHosts", NativeHostName+".json"), nil
|
||||||
|
case BrowserChromium:
|
||||||
|
return filepath.Join(home, ".config", "chromium", "NativeMessagingHosts", NativeHostName+".json"), nil
|
||||||
|
default:
|
||||||
|
return "", fmt.Errorf("unsupported browser %q", browser)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func InstallManifest(browser Browser, binaryPath, extensionID, outputPath string) (string, error) {
|
||||||
|
return InstallManifestSet(browser, binaryPath, []string{strings.TrimSpace(extensionID)}, outputPath)
|
||||||
|
}
|
||||||
@@ -0,0 +1,396 @@
|
|||||||
|
package browserbridge
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/binary"
|
||||||
|
"encoding/json"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
|
"slices"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
keepassgov1 "git.julianfamily.org/keepassgo/proto/keepassgo/v1"
|
||||||
|
gcodes "google.golang.org/grpc/codes"
|
||||||
|
gstatus "google.golang.org/grpc/status"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestReadRequestAndWriteResponse(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
var input bytes.Buffer
|
||||||
|
body, err := json.Marshal(Request{
|
||||||
|
Action: "find-logins",
|
||||||
|
BearerToken: "secret",
|
||||||
|
URL: "https://example.invalid/login",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Marshal() error = %v", err)
|
||||||
|
}
|
||||||
|
if err := binary.Write(&input, binary.LittleEndian, uint32(len(body))); err != nil {
|
||||||
|
t.Fatalf("binary.Write() error = %v", err)
|
||||||
|
}
|
||||||
|
if _, err := input.Write(body); err != nil {
|
||||||
|
t.Fatalf("Write() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := ReadRequest(&input)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ReadRequest() error = %v", err)
|
||||||
|
}
|
||||||
|
if req.Action != "find-logins" || req.BearerToken != "secret" {
|
||||||
|
t.Fatalf("ReadRequest() = %#v, want action and token preserved", req)
|
||||||
|
}
|
||||||
|
if conn, err := req.Connection("127.0.0.1:47777"); err != nil || conn.GRPCAddress != "127.0.0.1:47777" {
|
||||||
|
t.Fatalf("req.Connection(127.0.0.1:47777) = (%#v, %v), want explicit tcp address preserved", conn, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var output bytes.Buffer
|
||||||
|
if err := WriteResponse(&output, Response{Success: true, Version: "1"}); err != nil {
|
||||||
|
t.Fatalf("WriteResponse() error = %v", err)
|
||||||
|
}
|
||||||
|
var size uint32
|
||||||
|
if err := binary.Read(&output, binary.LittleEndian, &size); err != nil {
|
||||||
|
t.Fatalf("binary.Read() error = %v", err)
|
||||||
|
}
|
||||||
|
payload := make([]byte, size)
|
||||||
|
if _, err := output.Read(payload); err != nil {
|
||||||
|
t.Fatalf("Read() payload error = %v", err)
|
||||||
|
}
|
||||||
|
var resp Response
|
||||||
|
if err := json.Unmarshal(payload, &resp); err != nil {
|
||||||
|
t.Fatalf("Unmarshal() error = %v", err)
|
||||||
|
}
|
||||||
|
if !resp.Success || resp.Version != "1" {
|
||||||
|
t.Fatalf("response = %#v, want success version 1", resp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleRequestFindLogins(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
client := &fakeClient{
|
||||||
|
matches: []*keepassgov1.BrowserLoginMatch{
|
||||||
|
{Id: "vault-console", Title: "Vault Console", Username: "dannyocean", Url: "https://vault.example.invalid", Quality: "exact-host"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
resp := HandleRequest(context.Background(), Request{
|
||||||
|
Action: "find-logins",
|
||||||
|
BearerToken: "secret",
|
||||||
|
URL: "https://vault.example.invalid/login",
|
||||||
|
}, "", client)
|
||||||
|
if !resp.Success {
|
||||||
|
t.Fatalf("HandleRequest() success = false, error = %q", resp.Error)
|
||||||
|
}
|
||||||
|
if len(resp.Matches) != 1 || resp.Matches[0].ID != "vault-console" {
|
||||||
|
t.Fatalf("HandleRequest().Matches = %#v, want vault-console", resp.Matches)
|
||||||
|
}
|
||||||
|
if client.statusCalls != 0 {
|
||||||
|
t.Fatalf("HandleRequest(find-logins) statusCalls = %d, want 0", client.statusCalls)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleRequestStatusIncludesPendingApprovalCounts(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
client := &fakeClient{
|
||||||
|
status: &keepassgov1.GetSessionStatusResponse{
|
||||||
|
Locked: false,
|
||||||
|
EntryCount: 2,
|
||||||
|
PendingApprovalCount: 3,
|
||||||
|
TokenPendingApprovalCount: 1,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
resp := HandleRequest(context.Background(), Request{
|
||||||
|
Action: "status",
|
||||||
|
BearerToken: "secret",
|
||||||
|
}, "", client)
|
||||||
|
if !resp.Success {
|
||||||
|
t.Fatalf("HandleRequest(status) success = false, error = %q", resp.Error)
|
||||||
|
}
|
||||||
|
if resp.Status == nil {
|
||||||
|
t.Fatal("HandleRequest(status).Status = nil, want status")
|
||||||
|
}
|
||||||
|
if got := resp.Status.PendingApprovalCount; got != 3 {
|
||||||
|
t.Fatalf("HandleRequest(status).PendingApprovalCount = %d, want 3", got)
|
||||||
|
}
|
||||||
|
if got := resp.Status.TokenPendingApprovalCount; got != 1 {
|
||||||
|
t.Fatalf("HandleRequest(status).TokenPendingApprovalCount = %d, want 1", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleRequestGetLogin(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
client := &fakeClient{
|
||||||
|
credential: &keepassgov1.GetBrowserCredentialResponse{
|
||||||
|
Id: "vault-console",
|
||||||
|
Username: "dannyocean",
|
||||||
|
Password: "token-1",
|
||||||
|
Url: "https://vault.example.invalid",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
resp := HandleRequest(context.Background(), Request{
|
||||||
|
Action: "get-login",
|
||||||
|
BearerToken: "secret",
|
||||||
|
EntryID: "vault-console",
|
||||||
|
URL: "https://vault.example.invalid/login",
|
||||||
|
}, "", client)
|
||||||
|
if !resp.Success {
|
||||||
|
t.Fatalf("HandleRequest() success = false, error = %q", resp.Error)
|
||||||
|
}
|
||||||
|
if resp.Credential == nil || resp.Credential.ID != "vault-console" {
|
||||||
|
t.Fatalf("HandleRequest().Credential = %#v, want vault-console", resp.Credential)
|
||||||
|
}
|
||||||
|
if client.statusCalls != 0 {
|
||||||
|
t.Fatalf("HandleRequest(get-login) statusCalls = %d, want 0", client.statusCalls)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleRequestFindLoginsInfersLockedStatusFromRPC(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
client := &fakeClient{matchesErr: gstatus.Error(gcodes.FailedPrecondition, "vault is locked")}
|
||||||
|
resp := HandleRequest(context.Background(), Request{
|
||||||
|
Action: "find-logins",
|
||||||
|
BearerToken: "secret",
|
||||||
|
URL: "https://vault.example.invalid/login",
|
||||||
|
}, "", client)
|
||||||
|
if !resp.Success {
|
||||||
|
t.Fatalf("HandleRequest(find-logins locked) success = false, error = %q", resp.Error)
|
||||||
|
}
|
||||||
|
if resp.Status == nil || !resp.Status.Locked {
|
||||||
|
t.Fatalf("HandleRequest(find-logins locked).Status = %#v, want locked status", resp.Status)
|
||||||
|
}
|
||||||
|
if client.statusCalls != 0 {
|
||||||
|
t.Fatalf("HandleRequest(find-logins locked) statusCalls = %d, want 0", client.statusCalls)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleRequestRequiresBearerToken(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
resp := HandleRequest(context.Background(), Request{Action: "status"}, "", &fakeClient{})
|
||||||
|
if resp.Success {
|
||||||
|
t.Fatal("HandleRequest().Success = true, want false without token")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRequestConnectionDefaultsAddress(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
req := Request{Action: "status", BearerToken: "secret"}
|
||||||
|
conn, err := req.Connection("")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Connection(\"\") error = %v", err)
|
||||||
|
}
|
||||||
|
if conn.GRPCAddress == "" {
|
||||||
|
t.Fatal("Connection().GRPCAddress = empty, want default address")
|
||||||
|
}
|
||||||
|
if runtime.GOOS != "windows" && !strings.HasPrefix(conn.GRPCAddress, "unix://") && conn.GRPCAddress != "off" {
|
||||||
|
t.Fatalf("Connection().GRPCAddress = %q, want unix socket default on this platform", conn.GRPCAddress)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInstallManifest(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
tmp := t.TempDir()
|
||||||
|
binaryPath := filepath.Join(tmp, "keepassgo-browser-bridge")
|
||||||
|
if err := os.WriteFile(binaryPath, []byte("#!/bin/sh\n"), 0o755); err != nil {
|
||||||
|
t.Fatalf("WriteFile(binary) error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
path, err := InstallManifest(BrowserFirefox, binaryPath, "", filepath.Join(tmp, "firefox-host.json"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("InstallManifest() error = %v", err)
|
||||||
|
}
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ReadFile() error = %v", err)
|
||||||
|
}
|
||||||
|
var manifest NativeHostManifest
|
||||||
|
if err := json.Unmarshal(data, &manifest); err != nil {
|
||||||
|
t.Fatalf("Unmarshal() error = %v", err)
|
||||||
|
}
|
||||||
|
if manifest.Path != binaryPath {
|
||||||
|
t.Fatalf("manifest.Path = %q, want %q", manifest.Path, binaryPath)
|
||||||
|
}
|
||||||
|
if len(manifest.AllowedExtensions) != 1 || manifest.AllowedExtensions[0] != DefaultFirefoxExtensionID() {
|
||||||
|
t.Fatalf("manifest.AllowedExtensions = %#v, want default firefox extension id", manifest.AllowedExtensions)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestChromiumExtensionIDFromManifestKey(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
const publicKey = "MFwwDQYJKoZIhvcNAQEBBQADSwAwSAJBAMfW0u1k4K5A0uN2s0aH7uQKpM3x5Hf8mZfY1xVh0m7E2mJ7M8GiV4m0g0I2w9U9D1yqGQ6w8jzH5v8t7qB2RjMCAwEAAQ=="
|
||||||
|
got, err := ChromiumExtensionIDFromManifestKey(publicKey)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ChromiumExtensionIDFromManifestKey() error = %v", err)
|
||||||
|
}
|
||||||
|
if got != "okcdfigpojphpoecpglkkmkjmiaefmpd" {
|
||||||
|
t.Fatalf("ChromiumExtensionIDFromManifestKey() = %q, want %q", got, "okcdfigpojphpoecpglkkmkjmiaefmpd")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestManifestSetChromiumIncludesAllOrigins(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
manifest, err := ManifestSet(BrowserChromium, "/tmp/keepassgo-browser-bridge", []string{
|
||||||
|
"mjlnpdomnblnbblhacolncflebbgafhj",
|
||||||
|
"ddfbfpcgdjkffmjnialjpookcoedahcn",
|
||||||
|
"mjlnpdomnblnbblhacolncflebbgafhj",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ManifestSet() error = %v", err)
|
||||||
|
}
|
||||||
|
want := []string{
|
||||||
|
"chrome-extension://ddfbfpcgdjkffmjnialjpookcoedahcn/",
|
||||||
|
"chrome-extension://mjlnpdomnblnbblhacolncflebbgafhj/",
|
||||||
|
}
|
||||||
|
if !slices.Equal(manifest.AllowedOrigins, want) {
|
||||||
|
t.Fatalf("ManifestSet().AllowedOrigins = %#v, want %#v", manifest.AllowedOrigins, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDiscoverInstalledExtensionIDsInRoot(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
root := t.TempDir()
|
||||||
|
writeExtensionManifest(t, filepath.Join(root, "Default", "Extensions", "mjlnpdomnblnbblhacolncflebbgafhj", "1.0.0", "manifest.json"), browserExtensionName)
|
||||||
|
writeExtensionManifest(t, filepath.Join(root, "Profile 1", "Extensions", "ddfbfpcgdjkffmjnialjpookcoedahcn", "1.2.0", "manifest.json"), browserExtensionName)
|
||||||
|
writeExtensionManifest(t, filepath.Join(root, "Profile 2", "Extensions", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", "3.4.5", "manifest.json"), "Bellagio Notes")
|
||||||
|
writeExtensionManifest(t, filepath.Join(root, "Profile 3", "Extensions", "mjlnpdomnblnbblhacolncflebbgafhj", "1.1.0", "manifest.json"), browserExtensionName)
|
||||||
|
|
||||||
|
got, err := DiscoverInstalledExtensionIDsInRoot(root)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("DiscoverInstalledExtensionIDsInRoot() error = %v", err)
|
||||||
|
}
|
||||||
|
want := []string{
|
||||||
|
"ddfbfpcgdjkffmjnialjpookcoedahcn",
|
||||||
|
"mjlnpdomnblnbblhacolncflebbgafhj",
|
||||||
|
}
|
||||||
|
if !slices.Equal(got, want) {
|
||||||
|
t.Fatalf("DiscoverInstalledExtensionIDsInRoot() = %#v, want %#v", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEnsureNativeHostManifestsInstallsFirefoxAndDiscoveredChromium(t *testing.T) {
|
||||||
|
tmp := t.TempDir()
|
||||||
|
t.Setenv("HOME", filepath.Join(tmp, "home"))
|
||||||
|
appDir := filepath.Join(tmp, "app")
|
||||||
|
if err := os.MkdirAll(appDir, 0o755); err != nil {
|
||||||
|
t.Fatalf("MkdirAll(appDir) error = %v", err)
|
||||||
|
}
|
||||||
|
appBinaryPath := filepath.Join(appDir, "keepassgo")
|
||||||
|
if err := os.WriteFile(appBinaryPath, []byte("#!/bin/sh\n"), 0o755); err != nil {
|
||||||
|
t.Fatalf("WriteFile(appBinaryPath) error = %v", err)
|
||||||
|
}
|
||||||
|
bridgeBinaryPath := filepath.Join(appDir, "keepassgo-browser-bridge")
|
||||||
|
if err := os.WriteFile(bridgeBinaryPath, []byte("#!/bin/sh\n"), 0o755); err != nil {
|
||||||
|
t.Fatalf("WriteFile(bridgeBinaryPath) error = %v", err)
|
||||||
|
}
|
||||||
|
home := filepath.Join(tmp, "home")
|
||||||
|
writeExtensionManifest(t, filepath.Join(home, ".config", "chromium", "Default", "Extensions", "mjlnpdomnblnbblhacolncflebbgafhj", "1.0.0", "manifest.json"), browserExtensionName)
|
||||||
|
writeExtensionManifest(t, filepath.Join(home, ".config", "google-chrome", "Profile 7", "Extensions", "ddfbfpcgdjkffmjnialjpookcoedahcn", "1.0.0", "manifest.json"), browserExtensionName)
|
||||||
|
|
||||||
|
if err := EnsureNativeHostManifests(appBinaryPath); err != nil {
|
||||||
|
t.Fatalf("EnsureNativeHostManifests() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
assertManifestContainsExtension(t, filepath.Join(home, ".mozilla", "native-messaging-hosts", NativeHostName+".json"), "allowed_extensions", DefaultFirefoxExtensionID())
|
||||||
|
assertManifestContainsExtension(t, filepath.Join(home, ".config", "chromium", "NativeMessagingHosts", NativeHostName+".json"), "allowed_origins", "chrome-extension://mjlnpdomnblnbblhacolncflebbgafhj/")
|
||||||
|
assertManifestContainsExtension(t, filepath.Join(home, ".config", "google-chrome", "NativeMessagingHosts", NativeHostName+".json"), "allowed_origins", "chrome-extension://ddfbfpcgdjkffmjnialjpookcoedahcn/")
|
||||||
|
}
|
||||||
|
|
||||||
|
type fakeClient struct {
|
||||||
|
status *keepassgov1.GetSessionStatusResponse
|
||||||
|
matches []*keepassgov1.BrowserLoginMatch
|
||||||
|
credential *keepassgov1.GetBrowserCredentialResponse
|
||||||
|
err error
|
||||||
|
matchesErr error
|
||||||
|
credentialErr error
|
||||||
|
statusCalls int
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeExtensionManifest(t *testing.T, path, name string) {
|
||||||
|
t.Helper()
|
||||||
|
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||||
|
t.Fatalf("MkdirAll(%q) error = %v", filepath.Dir(path), err)
|
||||||
|
}
|
||||||
|
data, err := json.Marshal(map[string]string{"name": name})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Marshal(manifest %q) error = %v", path, err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(path, append(data, '\n'), 0o644); err != nil {
|
||||||
|
t.Fatalf("WriteFile(%q) error = %v", path, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func assertManifestContainsExtension(t *testing.T, path, field, want string) {
|
||||||
|
t.Helper()
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ReadFile(%q) error = %v", path, err)
|
||||||
|
}
|
||||||
|
var manifest map[string]any
|
||||||
|
if err := json.Unmarshal(data, &manifest); err != nil {
|
||||||
|
t.Fatalf("Unmarshal(%q) error = %v", path, err)
|
||||||
|
}
|
||||||
|
valuesAny, ok := manifest[field]
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("manifest %q missing field %q", path, field)
|
||||||
|
}
|
||||||
|
valuesRaw, ok := valuesAny.([]any)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("manifest %q field %q = %#v, want []any", path, field, valuesAny)
|
||||||
|
}
|
||||||
|
values := make([]string, 0, len(valuesRaw))
|
||||||
|
for _, raw := range valuesRaw {
|
||||||
|
text, ok := raw.(string)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("manifest %q field %q value = %#v, want string", path, field, raw)
|
||||||
|
}
|
||||||
|
values = append(values, text)
|
||||||
|
}
|
||||||
|
if !slices.Contains(values, want) {
|
||||||
|
t.Fatalf("manifest %q field %q = %#v, want to contain %q", path, field, values, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeClient) Status(context.Context) (*keepassgov1.GetSessionStatusResponse, error) {
|
||||||
|
f.statusCalls++
|
||||||
|
if f.err != nil {
|
||||||
|
return nil, f.err
|
||||||
|
}
|
||||||
|
if f.status == nil {
|
||||||
|
return &keepassgov1.GetSessionStatusResponse{}, nil
|
||||||
|
}
|
||||||
|
return f.status, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeClient) FindBrowserLogins(context.Context, string) ([]*keepassgov1.BrowserLoginMatch, error) {
|
||||||
|
if f.matchesErr != nil {
|
||||||
|
return nil, f.matchesErr
|
||||||
|
}
|
||||||
|
if f.err != nil {
|
||||||
|
return nil, f.err
|
||||||
|
}
|
||||||
|
return f.matches, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeClient) GetBrowserCredential(context.Context, string, string) (*keepassgov1.GetBrowserCredentialResponse, error) {
|
||||||
|
if f.credentialErr != nil {
|
||||||
|
return nil, f.credentialErr
|
||||||
|
}
|
||||||
|
if f.err != nil {
|
||||||
|
return nil, f.err
|
||||||
|
}
|
||||||
|
if f.credential == nil {
|
||||||
|
return &keepassgov1.GetBrowserCredentialResponse{}, nil
|
||||||
|
}
|
||||||
|
return f.credential, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
package browserbridge
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"git.julianfamily.org/keepassgo/internal/grpcaddr"
|
||||||
|
keepassgov1 "git.julianfamily.org/keepassgo/proto/keepassgo/v1"
|
||||||
|
"google.golang.org/grpc"
|
||||||
|
"google.golang.org/grpc/credentials/insecure"
|
||||||
|
"google.golang.org/grpc/metadata"
|
||||||
|
)
|
||||||
|
|
||||||
|
type GRPCClient struct {
|
||||||
|
client keepassgov1.VaultServiceClient
|
||||||
|
}
|
||||||
|
|
||||||
|
func DialRequest(ctx context.Context, req Request, grpcAddr string) (*grpc.ClientConn, *GRPCClient, context.Context, error) {
|
||||||
|
conn, err := req.Connection(grpcAddr)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, nil, err
|
||||||
|
}
|
||||||
|
return Dial(ctx, conn)
|
||||||
|
}
|
||||||
|
|
||||||
|
func Dial(ctx context.Context, conn Connection) (*grpc.ClientConn, *GRPCClient, context.Context, error) {
|
||||||
|
normalized, err := normalizeConnection(conn)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, nil, err
|
||||||
|
}
|
||||||
|
network, endpoint, err := grpcaddr.Parse(normalized.GRPCAddress)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, nil, err
|
||||||
|
}
|
||||||
|
target := endpoint
|
||||||
|
if network == "unix" {
|
||||||
|
target = "passthrough:///" + endpoint
|
||||||
|
}
|
||||||
|
grpcConn, err := grpc.NewClient(target,
|
||||||
|
grpc.WithTransportCredentials(insecure.NewCredentials()),
|
||||||
|
grpc.WithContextDialer(func(context.Context, string) (net.Conn, error) {
|
||||||
|
return net.Dial(network, endpoint)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, nil, fmt.Errorf("dial gRPC host %s: %w", normalized.GRPCAddress, err)
|
||||||
|
}
|
||||||
|
ctx = metadata.AppendToOutgoingContext(ctx, "authorization", "Bearer "+normalized.BearerToken)
|
||||||
|
return grpcConn, &GRPCClient{client: keepassgov1.NewVaultServiceClient(grpcConn)}, ctx, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *GRPCClient) Status(ctx context.Context) (*keepassgov1.GetSessionStatusResponse, error) {
|
||||||
|
return c.client.GetSessionStatus(ctx, &keepassgov1.GetSessionStatusRequest{})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *GRPCClient) FindBrowserLogins(ctx context.Context, pageURL string) ([]*keepassgov1.BrowserLoginMatch, error) {
|
||||||
|
resp, err := c.client.FindBrowserLogins(ctx, &keepassgov1.FindBrowserLoginsRequest{
|
||||||
|
PageUrl: strings.TrimSpace(pageURL),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return resp.GetMatches(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *GRPCClient) GetBrowserCredential(ctx context.Context, entryID, pageURL string) (*keepassgov1.GetBrowserCredentialResponse, error) {
|
||||||
|
return c.client.GetBrowserCredential(ctx, &keepassgov1.GetBrowserCredentialRequest{
|
||||||
|
Id: strings.TrimSpace(entryID),
|
||||||
|
PageUrl: strings.TrimSpace(pageURL),
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,210 @@
|
|||||||
|
package browserbridge
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"slices"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
const browserExtensionName = "KeePassGO Browser"
|
||||||
|
|
||||||
|
type extensionManifestMetadata struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func ResolveBridgeBinaryPath(appBinaryPath string) (string, error) {
|
||||||
|
path := strings.TrimSpace(appBinaryPath)
|
||||||
|
if path == "" {
|
||||||
|
var err error
|
||||||
|
path, err = os.Executable()
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("resolve app executable: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(path) == "" {
|
||||||
|
return "", fmt.Errorf("app executable path is required")
|
||||||
|
}
|
||||||
|
if filepath.Base(path) == "keepassgo-browser-bridge" {
|
||||||
|
return path, nil
|
||||||
|
}
|
||||||
|
candidate := filepath.Join(filepath.Dir(path), "keepassgo-browser-bridge")
|
||||||
|
if info, err := os.Stat(candidate); err == nil && !info.IsDir() {
|
||||||
|
return candidate, nil
|
||||||
|
}
|
||||||
|
resolved, err := exec.LookPath("keepassgo-browser-bridge")
|
||||||
|
if err == nil {
|
||||||
|
return resolved, nil
|
||||||
|
}
|
||||||
|
return "", fmt.Errorf("locate keepassgo-browser-bridge next to %q or in PATH: %w", path, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func EnsureNativeHostManifests(appBinaryPath string) error {
|
||||||
|
bridgePath, err := ResolveBridgeBinaryPath(appBinaryPath)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
var errs []error
|
||||||
|
if _, err := InstallManifest(BrowserFirefox, bridgePath, "", ""); err != nil {
|
||||||
|
errs = append(errs, fmt.Errorf("install firefox native host: %w", err))
|
||||||
|
}
|
||||||
|
for _, browser := range []Browser{BrowserChrome, BrowserChromium} {
|
||||||
|
ids, err := DiscoverInstalledExtensionIDs(browser)
|
||||||
|
if err != nil {
|
||||||
|
errs = append(errs, fmt.Errorf("discover %s extension ids: %w", browser, err))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if len(ids) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, err := InstallManifestSet(browser, bridgePath, ids, ""); err != nil {
|
||||||
|
errs = append(errs, fmt.Errorf("install %s native host: %w", browser, err))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return errors.Join(errs...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func DiscoverInstalledExtensionIDs(browser Browser) ([]string, error) {
|
||||||
|
root, err := defaultBrowserProfileRoot(browser)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return DiscoverInstalledExtensionIDsInRoot(root)
|
||||||
|
}
|
||||||
|
|
||||||
|
func DiscoverInstalledExtensionIDsInRoot(root string) ([]string, error) {
|
||||||
|
base := strings.TrimSpace(root)
|
||||||
|
if base == "" {
|
||||||
|
return nil, fmt.Errorf("browser profile root is required")
|
||||||
|
}
|
||||||
|
pattern := filepath.Join(base, "*", "Extensions", "*", "*", "manifest.json")
|
||||||
|
paths, err := filepath.Glob(pattern)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("glob browser extensions: %w", err)
|
||||||
|
}
|
||||||
|
ids := make(map[string]struct{}, len(paths))
|
||||||
|
for _, path := range paths {
|
||||||
|
ok, err := isKeePassGOExtensionManifest(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
id := filepath.Base(filepath.Dir(filepath.Dir(path)))
|
||||||
|
if strings.TrimSpace(id) == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
ids[id] = struct{}{}
|
||||||
|
}
|
||||||
|
out := make([]string, 0, len(ids))
|
||||||
|
for id := range ids {
|
||||||
|
out = append(out, id)
|
||||||
|
}
|
||||||
|
slices.Sort(out)
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func InstallManifestSet(browser Browser, binaryPath string, extensionIDs []string, outputPath string) (string, error) {
|
||||||
|
manifest, err := ManifestSet(browser, binaryPath, extensionIDs)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
path := strings.TrimSpace(outputPath)
|
||||||
|
if path == "" {
|
||||||
|
path, err = DefaultManifestPath(browser)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||||
|
return "", fmt.Errorf("create native host manifest dir: %w", err)
|
||||||
|
}
|
||||||
|
data, err := json.MarshalIndent(manifest, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("encode native host manifest: %w", err)
|
||||||
|
}
|
||||||
|
data = append(data, '\n')
|
||||||
|
if err := os.WriteFile(path, data, 0o644); err != nil {
|
||||||
|
return "", fmt.Errorf("write native host manifest: %w", err)
|
||||||
|
}
|
||||||
|
return path, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func ManifestSet(browser Browser, binaryPath string, extensionIDs []string) (NativeHostManifest, error) {
|
||||||
|
path := strings.TrimSpace(binaryPath)
|
||||||
|
if path == "" {
|
||||||
|
return NativeHostManifest{}, fmt.Errorf("native host binary path is required")
|
||||||
|
}
|
||||||
|
switch browser {
|
||||||
|
case BrowserFirefox:
|
||||||
|
return Manifest(browser, path, "")
|
||||||
|
case BrowserChrome, BrowserChromium:
|
||||||
|
ids := normalizedExtensionIDs(extensionIDs)
|
||||||
|
if len(ids) == 0 {
|
||||||
|
return NativeHostManifest{}, fmt.Errorf("%s extension id is required", browser)
|
||||||
|
}
|
||||||
|
origins := make([]string, 0, len(ids))
|
||||||
|
for _, id := range ids {
|
||||||
|
origins = append(origins, "chrome-extension://"+id+"/")
|
||||||
|
}
|
||||||
|
return NativeHostManifest{
|
||||||
|
Name: NativeHostName,
|
||||||
|
Description: "KeePassGO browser bridge",
|
||||||
|
Path: path,
|
||||||
|
Type: "stdio",
|
||||||
|
AllowedOrigins: origins,
|
||||||
|
}, nil
|
||||||
|
default:
|
||||||
|
return NativeHostManifest{}, fmt.Errorf("unsupported browser %q", browser)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func defaultBrowserProfileRoot(browser Browser) (string, error) {
|
||||||
|
home, err := os.UserHomeDir()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
switch browser {
|
||||||
|
case BrowserChrome:
|
||||||
|
return filepath.Join(home, ".config", "google-chrome"), nil
|
||||||
|
case BrowserChromium:
|
||||||
|
return filepath.Join(home, ".config", "chromium"), nil
|
||||||
|
default:
|
||||||
|
return "", fmt.Errorf("installed extension discovery is unsupported for %q", browser)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func isKeePassGOExtensionManifest(path string) (bool, error) {
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return false, fmt.Errorf("read extension manifest %q: %w", path, err)
|
||||||
|
}
|
||||||
|
var metadata extensionManifestMetadata
|
||||||
|
if err := json.Unmarshal(data, &metadata); err != nil {
|
||||||
|
return false, fmt.Errorf("decode extension manifest %q: %w", path, err)
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(metadata.Name) == browserExtensionName, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizedExtensionIDs(ids []string) []string {
|
||||||
|
seen := make(map[string]struct{}, len(ids))
|
||||||
|
out := make([]string, 0, len(ids))
|
||||||
|
for _, raw := range ids {
|
||||||
|
id := strings.TrimSpace(raw)
|
||||||
|
if id == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, ok := seen[id]; ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[id] = struct{}{}
|
||||||
|
out = append(out, id)
|
||||||
|
}
|
||||||
|
slices.Sort(out)
|
||||||
|
return out
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
package grpcaddr
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
const socketName = "keepassgo-grpc.sock"
|
||||||
|
|
||||||
|
func Default(goos string) string {
|
||||||
|
if strings.EqualFold(strings.TrimSpace(goos), "android") {
|
||||||
|
return "off"
|
||||||
|
}
|
||||||
|
if strings.EqualFold(strings.TrimSpace(goos), "windows") {
|
||||||
|
return "127.0.0.1:47777"
|
||||||
|
}
|
||||||
|
return "unix://" + DefaultSocketPath()
|
||||||
|
}
|
||||||
|
|
||||||
|
func DefaultSocketPath() string {
|
||||||
|
return filepath.Join(runtimeDir(), "keepassgo", socketName)
|
||||||
|
}
|
||||||
|
|
||||||
|
func runtimeDir() string {
|
||||||
|
if dir := strings.TrimSpace(os.Getenv("XDG_RUNTIME_DIR")); dir != "" {
|
||||||
|
return dir
|
||||||
|
}
|
||||||
|
if runtime.GOOS != "windows" {
|
||||||
|
uid := strconv.Itoa(os.Getuid())
|
||||||
|
runUserDir := filepath.Join("/run/user", uid)
|
||||||
|
if info, err := os.Stat(runUserDir); err == nil && info.IsDir() {
|
||||||
|
return runUserDir
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return filepath.Join(os.TempDir(), fmt.Sprintf("keepassgo-runtime-%d", os.Getuid()))
|
||||||
|
}
|
||||||
|
|
||||||
|
func Parse(raw string) (network, endpoint string, err error) {
|
||||||
|
value := strings.TrimSpace(raw)
|
||||||
|
switch {
|
||||||
|
case value == "":
|
||||||
|
return "", "", fmt.Errorf("gRPC address is required")
|
||||||
|
case strings.EqualFold(value, "off"):
|
||||||
|
return "", "", nil
|
||||||
|
case strings.HasPrefix(value, "unix://"):
|
||||||
|
path := strings.TrimSpace(strings.TrimPrefix(value, "unix://"))
|
||||||
|
if path == "" {
|
||||||
|
return "", "", fmt.Errorf("unix gRPC socket path is required")
|
||||||
|
}
|
||||||
|
return "unix", path, nil
|
||||||
|
case strings.HasPrefix(value, "tcp://"):
|
||||||
|
addr := strings.TrimSpace(strings.TrimPrefix(value, "tcp://"))
|
||||||
|
if addr == "" {
|
||||||
|
return "", "", fmt.Errorf("tcp gRPC address is required")
|
||||||
|
}
|
||||||
|
return "tcp", addr, nil
|
||||||
|
case strings.HasPrefix(value, "/"):
|
||||||
|
return "unix", value, nil
|
||||||
|
default:
|
||||||
|
return "tcp", value, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
package grpcaddr
|
||||||
|
|
||||||
|
import (
|
||||||
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestDefaultUsesUnixSocketOnUnixLikeSystems(t *testing.T) {
|
||||||
|
if runtime.GOOS == "windows" {
|
||||||
|
t.Skip("unix default is not expected on windows")
|
||||||
|
}
|
||||||
|
t.Setenv("XDG_RUNTIME_DIR", "/tmp/keepassgo-runtime-test")
|
||||||
|
|
||||||
|
got := Default("linux")
|
||||||
|
want := "unix:///tmp/keepassgo-runtime-test/keepassgo/keepassgo-grpc.sock"
|
||||||
|
if got != want {
|
||||||
|
t.Fatalf("Default() = %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParse(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
input string
|
||||||
|
wantNetwork string
|
||||||
|
wantEnd string
|
||||||
|
}{
|
||||||
|
{name: "unix scheme", input: "unix:///tmp/keepassgo.sock", wantNetwork: "unix", wantEnd: "/tmp/keepassgo.sock"},
|
||||||
|
{name: "tcp scheme", input: "tcp://127.0.0.1:47777", wantNetwork: "tcp", wantEnd: "127.0.0.1:47777"},
|
||||||
|
{name: "bare path", input: filepath.Clean("/tmp/keepassgo.sock"), wantNetwork: "unix", wantEnd: filepath.Clean("/tmp/keepassgo.sock")},
|
||||||
|
{name: "bare tcp", input: "127.0.0.1:47777", wantNetwork: "tcp", wantEnd: "127.0.0.1:47777"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
gotNetwork, gotEnd, err := Parse(tt.input)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Parse() error = %v", err)
|
||||||
|
}
|
||||||
|
if gotNetwork != tt.wantNetwork || gotEnd != tt.wantEnd {
|
||||||
|
t.Fatalf("Parse() = (%q, %q), want (%q, %q)", gotNetwork, gotEnd, tt.wantNetwork, tt.wantEnd)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
+76
-25
@@ -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,31 @@
|
|||||||
|
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 ""
|
||||||
|
}
|
||||||
|
return KeepassRoot
|
||||||
|
}
|
||||||
|
|
||||||
|
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,26 @@
|
|||||||
|
package vaultview
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.julianfamily.org/keepassgo/internal/vault"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestHiddenRootIgnoresRecycleBin(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
model := vault.Model{
|
||||||
|
Entries: []vault.Entry{
|
||||||
|
{ID: "entry-1", Title: "Vault Console", Path: []string{"keepass", "Crew", "Internet"}},
|
||||||
|
},
|
||||||
|
Groups: [][]string{
|
||||||
|
{"keepass"},
|
||||||
|
{"keepass", "Crew"},
|
||||||
|
{"Recycle Bin"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
if got := HiddenRoot(model); got != "keepass" {
|
||||||
|
t.Fatalf("HiddenRoot() = %q, want %q", got, "keepass")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -42,12 +42,34 @@ build() {
|
|||||||
local app_version
|
local app_version
|
||||||
app_version="$(git describe --tags --always --dirty)"
|
app_version="$(git describe --tags --always --dirty)"
|
||||||
go build -ldflags "-X git.julianfamily.org/keepassgo/internal/appui.appVersion=${app_version}" -o keepassgo ./cmd/keepassgo
|
go build -ldflags "-X git.julianfamily.org/keepassgo/internal/appui.appVersion=${app_version}" -o keepassgo ./cmd/keepassgo
|
||||||
|
go build -ldflags "-X git.julianfamily.org/keepassgo/internal/appui.appVersion=${app_version}" -o keepassgo-browser-bridge ./cmd/keepassgo-browser-bridge
|
||||||
}
|
}
|
||||||
|
|
||||||
package() {
|
package() {
|
||||||
cd "$(_repo_dir)"
|
cd "$(_repo_dir)"
|
||||||
|
|
||||||
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 -Dm644 browser/extension/README.md \
|
||||||
|
"${pkgdir}/usr/share/keepassgo/browser-extension/README.md"
|
||||||
|
install -Dm644 browser/extension/background.js \
|
||||||
|
"${pkgdir}/usr/share/keepassgo/browser-extension/background.js"
|
||||||
|
install -Dm644 browser/extension/content.js \
|
||||||
|
"${pkgdir}/usr/share/keepassgo/browser-extension/content.js"
|
||||||
|
install -Dm644 browser/extension/manifest.chromium.json \
|
||||||
|
"${pkgdir}/usr/share/keepassgo/browser-extension/manifest.chromium.json"
|
||||||
|
install -Dm644 browser/extension/manifest.firefox.json \
|
||||||
|
"${pkgdir}/usr/share/keepassgo/browser-extension/manifest.firefox.json"
|
||||||
|
install -Dm644 browser/extension/options.html \
|
||||||
|
"${pkgdir}/usr/share/keepassgo/browser-extension/options.html"
|
||||||
|
install -Dm644 browser/extension/options.js \
|
||||||
|
"${pkgdir}/usr/share/keepassgo/browser-extension/options.js"
|
||||||
|
install -Dm644 browser/extension/popup.html \
|
||||||
|
"${pkgdir}/usr/share/keepassgo/browser-extension/popup.html"
|
||||||
|
install -Dm644 browser/extension/popup.js \
|
||||||
|
"${pkgdir}/usr/share/keepassgo/browser-extension/popup.js"
|
||||||
|
install -Dm644 browser/extension/style.css \
|
||||||
|
"${pkgdir}/usr/share/keepassgo/browser-extension/style.css"
|
||||||
install -Dm644 internal/assets/keepassgo-icon.png \
|
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
|
||||||
+583
-242
File diff suppressed because it is too large
Load Diff
@@ -11,6 +11,8 @@ service VaultService {
|
|||||||
rpc SaveVault(SaveVaultRequest) returns (SaveVaultResponse);
|
rpc SaveVault(SaveVaultRequest) returns (SaveVaultResponse);
|
||||||
rpc LockVault(LockVaultRequest) returns (LockVaultResponse);
|
rpc LockVault(LockVaultRequest) returns (LockVaultResponse);
|
||||||
rpc UnlockVault(UnlockVaultRequest) returns (UnlockVaultResponse);
|
rpc UnlockVault(UnlockVaultRequest) returns (UnlockVaultResponse);
|
||||||
|
rpc FindBrowserLogins(FindBrowserLoginsRequest) returns (FindBrowserLoginsResponse);
|
||||||
|
rpc GetBrowserCredential(GetBrowserCredentialRequest) returns (GetBrowserCredentialResponse);
|
||||||
rpc ListEntries(ListEntriesRequest) returns (ListEntriesResponse);
|
rpc ListEntries(ListEntriesRequest) returns (ListEntriesResponse);
|
||||||
rpc ListGroups(ListGroupsRequest) returns (ListGroupsResponse);
|
rpc ListGroups(ListGroupsRequest) returns (ListGroupsResponse);
|
||||||
rpc CreateGroup(CreateGroupRequest) returns (CreateGroupResponse);
|
rpc CreateGroup(CreateGroupRequest) returns (CreateGroupResponse);
|
||||||
@@ -39,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 {
|
||||||
@@ -75,6 +79,35 @@ message UnlockVaultRequest {
|
|||||||
|
|
||||||
message UnlockVaultResponse {}
|
message UnlockVaultResponse {}
|
||||||
|
|
||||||
|
message FindBrowserLoginsRequest {
|
||||||
|
string page_url = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
message BrowserLoginMatch {
|
||||||
|
string id = 1;
|
||||||
|
string title = 2;
|
||||||
|
string username = 3;
|
||||||
|
string url = 4;
|
||||||
|
repeated string path = 5;
|
||||||
|
string quality = 6;
|
||||||
|
}
|
||||||
|
|
||||||
|
message FindBrowserLoginsResponse {
|
||||||
|
repeated BrowserLoginMatch matches = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
message GetBrowserCredentialRequest {
|
||||||
|
string id = 1;
|
||||||
|
string page_url = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
message GetBrowserCredentialResponse {
|
||||||
|
string id = 1;
|
||||||
|
string username = 2;
|
||||||
|
string password = 3;
|
||||||
|
string url = 4;
|
||||||
|
}
|
||||||
|
|
||||||
message ListEntriesRequest {
|
message ListEntriesRequest {
|
||||||
repeated string path = 1;
|
repeated string path = 1;
|
||||||
string query = 2;
|
string query = 2;
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
|
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
|
||||||
// versions:
|
// versions:
|
||||||
// - protoc-gen-go-grpc v1.5.1
|
// - protoc-gen-go-grpc v1.5.1
|
||||||
// - protoc v6.33.1
|
// - protoc v7.34.1
|
||||||
// source: proto/keepassgo/v1/keepassgo.proto
|
// source: proto/keepassgo/v1/keepassgo.proto
|
||||||
|
|
||||||
package keepassgov1
|
package keepassgov1
|
||||||
@@ -25,6 +25,8 @@ const (
|
|||||||
VaultService_SaveVault_FullMethodName = "/keepassgo.v1.VaultService/SaveVault"
|
VaultService_SaveVault_FullMethodName = "/keepassgo.v1.VaultService/SaveVault"
|
||||||
VaultService_LockVault_FullMethodName = "/keepassgo.v1.VaultService/LockVault"
|
VaultService_LockVault_FullMethodName = "/keepassgo.v1.VaultService/LockVault"
|
||||||
VaultService_UnlockVault_FullMethodName = "/keepassgo.v1.VaultService/UnlockVault"
|
VaultService_UnlockVault_FullMethodName = "/keepassgo.v1.VaultService/UnlockVault"
|
||||||
|
VaultService_FindBrowserLogins_FullMethodName = "/keepassgo.v1.VaultService/FindBrowserLogins"
|
||||||
|
VaultService_GetBrowserCredential_FullMethodName = "/keepassgo.v1.VaultService/GetBrowserCredential"
|
||||||
VaultService_ListEntries_FullMethodName = "/keepassgo.v1.VaultService/ListEntries"
|
VaultService_ListEntries_FullMethodName = "/keepassgo.v1.VaultService/ListEntries"
|
||||||
VaultService_ListGroups_FullMethodName = "/keepassgo.v1.VaultService/ListGroups"
|
VaultService_ListGroups_FullMethodName = "/keepassgo.v1.VaultService/ListGroups"
|
||||||
VaultService_CreateGroup_FullMethodName = "/keepassgo.v1.VaultService/CreateGroup"
|
VaultService_CreateGroup_FullMethodName = "/keepassgo.v1.VaultService/CreateGroup"
|
||||||
@@ -57,6 +59,8 @@ type VaultServiceClient interface {
|
|||||||
SaveVault(ctx context.Context, in *SaveVaultRequest, opts ...grpc.CallOption) (*SaveVaultResponse, error)
|
SaveVault(ctx context.Context, in *SaveVaultRequest, opts ...grpc.CallOption) (*SaveVaultResponse, error)
|
||||||
LockVault(ctx context.Context, in *LockVaultRequest, opts ...grpc.CallOption) (*LockVaultResponse, error)
|
LockVault(ctx context.Context, in *LockVaultRequest, opts ...grpc.CallOption) (*LockVaultResponse, error)
|
||||||
UnlockVault(ctx context.Context, in *UnlockVaultRequest, opts ...grpc.CallOption) (*UnlockVaultResponse, error)
|
UnlockVault(ctx context.Context, in *UnlockVaultRequest, opts ...grpc.CallOption) (*UnlockVaultResponse, error)
|
||||||
|
FindBrowserLogins(ctx context.Context, in *FindBrowserLoginsRequest, opts ...grpc.CallOption) (*FindBrowserLoginsResponse, error)
|
||||||
|
GetBrowserCredential(ctx context.Context, in *GetBrowserCredentialRequest, opts ...grpc.CallOption) (*GetBrowserCredentialResponse, error)
|
||||||
ListEntries(ctx context.Context, in *ListEntriesRequest, opts ...grpc.CallOption) (*ListEntriesResponse, error)
|
ListEntries(ctx context.Context, in *ListEntriesRequest, opts ...grpc.CallOption) (*ListEntriesResponse, error)
|
||||||
ListGroups(ctx context.Context, in *ListGroupsRequest, opts ...grpc.CallOption) (*ListGroupsResponse, error)
|
ListGroups(ctx context.Context, in *ListGroupsRequest, opts ...grpc.CallOption) (*ListGroupsResponse, error)
|
||||||
CreateGroup(ctx context.Context, in *CreateGroupRequest, opts ...grpc.CallOption) (*CreateGroupResponse, error)
|
CreateGroup(ctx context.Context, in *CreateGroupRequest, opts ...grpc.CallOption) (*CreateGroupResponse, error)
|
||||||
@@ -147,6 +151,26 @@ func (c *vaultServiceClient) UnlockVault(ctx context.Context, in *UnlockVaultReq
|
|||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (c *vaultServiceClient) FindBrowserLogins(ctx context.Context, in *FindBrowserLoginsRequest, opts ...grpc.CallOption) (*FindBrowserLoginsResponse, error) {
|
||||||
|
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||||
|
out := new(FindBrowserLoginsResponse)
|
||||||
|
err := c.cc.Invoke(ctx, VaultService_FindBrowserLogins_FullMethodName, in, out, cOpts...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *vaultServiceClient) GetBrowserCredential(ctx context.Context, in *GetBrowserCredentialRequest, opts ...grpc.CallOption) (*GetBrowserCredentialResponse, error) {
|
||||||
|
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||||
|
out := new(GetBrowserCredentialResponse)
|
||||||
|
err := c.cc.Invoke(ctx, VaultService_GetBrowserCredential_FullMethodName, in, out, cOpts...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (c *vaultServiceClient) ListEntries(ctx context.Context, in *ListEntriesRequest, opts ...grpc.CallOption) (*ListEntriesResponse, error) {
|
func (c *vaultServiceClient) ListEntries(ctx context.Context, in *ListEntriesRequest, opts ...grpc.CallOption) (*ListEntriesResponse, error) {
|
||||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||||
out := new(ListEntriesResponse)
|
out := new(ListEntriesResponse)
|
||||||
@@ -357,6 +381,8 @@ type VaultServiceServer interface {
|
|||||||
SaveVault(context.Context, *SaveVaultRequest) (*SaveVaultResponse, error)
|
SaveVault(context.Context, *SaveVaultRequest) (*SaveVaultResponse, error)
|
||||||
LockVault(context.Context, *LockVaultRequest) (*LockVaultResponse, error)
|
LockVault(context.Context, *LockVaultRequest) (*LockVaultResponse, error)
|
||||||
UnlockVault(context.Context, *UnlockVaultRequest) (*UnlockVaultResponse, error)
|
UnlockVault(context.Context, *UnlockVaultRequest) (*UnlockVaultResponse, error)
|
||||||
|
FindBrowserLogins(context.Context, *FindBrowserLoginsRequest) (*FindBrowserLoginsResponse, error)
|
||||||
|
GetBrowserCredential(context.Context, *GetBrowserCredentialRequest) (*GetBrowserCredentialResponse, error)
|
||||||
ListEntries(context.Context, *ListEntriesRequest) (*ListEntriesResponse, error)
|
ListEntries(context.Context, *ListEntriesRequest) (*ListEntriesResponse, error)
|
||||||
ListGroups(context.Context, *ListGroupsRequest) (*ListGroupsResponse, error)
|
ListGroups(context.Context, *ListGroupsRequest) (*ListGroupsResponse, error)
|
||||||
CreateGroup(context.Context, *CreateGroupRequest) (*CreateGroupResponse, error)
|
CreateGroup(context.Context, *CreateGroupRequest) (*CreateGroupResponse, error)
|
||||||
@@ -405,6 +431,12 @@ func (UnimplementedVaultServiceServer) LockVault(context.Context, *LockVaultRequ
|
|||||||
func (UnimplementedVaultServiceServer) UnlockVault(context.Context, *UnlockVaultRequest) (*UnlockVaultResponse, error) {
|
func (UnimplementedVaultServiceServer) UnlockVault(context.Context, *UnlockVaultRequest) (*UnlockVaultResponse, error) {
|
||||||
return nil, status.Errorf(codes.Unimplemented, "method UnlockVault not implemented")
|
return nil, status.Errorf(codes.Unimplemented, "method UnlockVault not implemented")
|
||||||
}
|
}
|
||||||
|
func (UnimplementedVaultServiceServer) FindBrowserLogins(context.Context, *FindBrowserLoginsRequest) (*FindBrowserLoginsResponse, error) {
|
||||||
|
return nil, status.Errorf(codes.Unimplemented, "method FindBrowserLogins not implemented")
|
||||||
|
}
|
||||||
|
func (UnimplementedVaultServiceServer) GetBrowserCredential(context.Context, *GetBrowserCredentialRequest) (*GetBrowserCredentialResponse, error) {
|
||||||
|
return nil, status.Errorf(codes.Unimplemented, "method GetBrowserCredential not implemented")
|
||||||
|
}
|
||||||
func (UnimplementedVaultServiceServer) ListEntries(context.Context, *ListEntriesRequest) (*ListEntriesResponse, error) {
|
func (UnimplementedVaultServiceServer) ListEntries(context.Context, *ListEntriesRequest) (*ListEntriesResponse, error) {
|
||||||
return nil, status.Errorf(codes.Unimplemented, "method ListEntries not implemented")
|
return nil, status.Errorf(codes.Unimplemented, "method ListEntries not implemented")
|
||||||
}
|
}
|
||||||
@@ -594,6 +626,42 @@ func _VaultService_UnlockVault_Handler(srv interface{}, ctx context.Context, dec
|
|||||||
return interceptor(ctx, in, info, handler)
|
return interceptor(ctx, in, info, handler)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func _VaultService_FindBrowserLogins_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||||
|
in := new(FindBrowserLoginsRequest)
|
||||||
|
if err := dec(in); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if interceptor == nil {
|
||||||
|
return srv.(VaultServiceServer).FindBrowserLogins(ctx, in)
|
||||||
|
}
|
||||||
|
info := &grpc.UnaryServerInfo{
|
||||||
|
Server: srv,
|
||||||
|
FullMethod: VaultService_FindBrowserLogins_FullMethodName,
|
||||||
|
}
|
||||||
|
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||||
|
return srv.(VaultServiceServer).FindBrowserLogins(ctx, req.(*FindBrowserLoginsRequest))
|
||||||
|
}
|
||||||
|
return interceptor(ctx, in, info, handler)
|
||||||
|
}
|
||||||
|
|
||||||
|
func _VaultService_GetBrowserCredential_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||||
|
in := new(GetBrowserCredentialRequest)
|
||||||
|
if err := dec(in); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if interceptor == nil {
|
||||||
|
return srv.(VaultServiceServer).GetBrowserCredential(ctx, in)
|
||||||
|
}
|
||||||
|
info := &grpc.UnaryServerInfo{
|
||||||
|
Server: srv,
|
||||||
|
FullMethod: VaultService_GetBrowserCredential_FullMethodName,
|
||||||
|
}
|
||||||
|
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||||
|
return srv.(VaultServiceServer).GetBrowserCredential(ctx, req.(*GetBrowserCredentialRequest))
|
||||||
|
}
|
||||||
|
return interceptor(ctx, in, info, handler)
|
||||||
|
}
|
||||||
|
|
||||||
func _VaultService_ListEntries_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
func _VaultService_ListEntries_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||||
in := new(ListEntriesRequest)
|
in := new(ListEntriesRequest)
|
||||||
if err := dec(in); err != nil {
|
if err := dec(in); err != nil {
|
||||||
@@ -985,6 +1053,14 @@ var VaultService_ServiceDesc = grpc.ServiceDesc{
|
|||||||
MethodName: "UnlockVault",
|
MethodName: "UnlockVault",
|
||||||
Handler: _VaultService_UnlockVault_Handler,
|
Handler: _VaultService_UnlockVault_Handler,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
MethodName: "FindBrowserLogins",
|
||||||
|
Handler: _VaultService_FindBrowserLogins_Handler,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
MethodName: "GetBrowserCredential",
|
||||||
|
Handler: _VaultService_GetBrowserCredential_Handler,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
MethodName: "ListEntries",
|
MethodName: "ListEntries",
|
||||||
Handler: _VaultService_ListEntries_Handler,
|
Handler: _VaultService_ListEntries_Handler,
|
||||||
|
|||||||
@@ -0,0 +1,109 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
keepassgov1 "git.julianfamily.org/keepassgo/proto/keepassgo/v1"
|
||||||
|
"google.golang.org/grpc"
|
||||||
|
)
|
||||||
|
|
||||||
|
type validationServer struct {
|
||||||
|
keepassgov1.UnimplementedVaultServiceServer
|
||||||
|
statePath string
|
||||||
|
pageURL string
|
||||||
|
}
|
||||||
|
|
||||||
|
func readState(path string) string {
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return "idle"
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(string(data))
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeState(path, value string) {
|
||||||
|
_ = os.WriteFile(path, []byte(value), 0o644)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *validationServer) GetSessionStatus(context.Context, *keepassgov1.GetSessionStatusRequest) (*keepassgov1.GetSessionStatusResponse, error) {
|
||||||
|
pending := uint32(0)
|
||||||
|
if readState(s.statePath) == "pending" {
|
||||||
|
pending = 1
|
||||||
|
}
|
||||||
|
return &keepassgov1.GetSessionStatusResponse{
|
||||||
|
Locked: false,
|
||||||
|
EntryCount: 1,
|
||||||
|
PendingApprovalCount: pending,
|
||||||
|
TokenPendingApprovalCount: pending,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *validationServer) FindBrowserLogins(context.Context, *keepassgov1.FindBrowserLoginsRequest) (*keepassgov1.FindBrowserLoginsResponse, error) {
|
||||||
|
return &keepassgov1.FindBrowserLoginsResponse{
|
||||||
|
Matches: []*keepassgov1.BrowserLoginMatch{
|
||||||
|
{
|
||||||
|
Id: "vault-console",
|
||||||
|
Title: "Vault Console",
|
||||||
|
Username: "dannyocean",
|
||||||
|
Url: s.pageURL,
|
||||||
|
Path: []string{"Root", "Crew"},
|
||||||
|
Quality: "exact-host",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *validationServer) GetBrowserCredential(ctx context.Context, req *keepassgov1.GetBrowserCredentialRequest) (*keepassgov1.GetBrowserCredentialResponse, error) {
|
||||||
|
writeState(s.statePath, "pending")
|
||||||
|
ticker := time.NewTicker(200 * time.Millisecond)
|
||||||
|
defer ticker.Stop()
|
||||||
|
timeout := time.After(20 * time.Second)
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return nil, ctx.Err()
|
||||||
|
case <-timeout:
|
||||||
|
return nil, fmt.Errorf("timed out waiting for browser-approval state")
|
||||||
|
case <-ticker.C:
|
||||||
|
if readState(s.statePath) == "approved" {
|
||||||
|
writeState(s.statePath, "done")
|
||||||
|
return &keepassgov1.GetBrowserCredentialResponse{
|
||||||
|
Id: req.GetId(),
|
||||||
|
Username: "dannyocean",
|
||||||
|
Password: "token-1",
|
||||||
|
Url: s.pageURL,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
listenAddr := flag.String("listen", "127.0.0.1:47779", "listen address")
|
||||||
|
statePath := flag.String("state", "", "path to mutable validation state file")
|
||||||
|
pageURL := flag.String("page-url", "http://127.0.0.1:18080/login.html", "login page URL returned by the stub")
|
||||||
|
flag.Parse()
|
||||||
|
|
||||||
|
if strings.TrimSpace(*statePath) == "" {
|
||||||
|
panic("validation state file is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
listener, err := net.Listen("tcp", strings.TrimSpace(*listenAddr))
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
server := grpc.NewServer()
|
||||||
|
keepassgov1.RegisterVaultServiceServer(server, &validationServer{
|
||||||
|
statePath: strings.TrimSpace(*statePath),
|
||||||
|
pageURL: strings.TrimSpace(*pageURL),
|
||||||
|
})
|
||||||
|
if err := server.Serve(listener); err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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