Compare commits
224 Commits
ed2118223f
..
v0.2.0
| Author | SHA1 | Date | |
|---|---|---|---|
| a9c15c2d23 | |||
| b7d6dbdc97 | |||
| 2deca549f5 | |||
| fe3c07e3dd | |||
| c4f110e0ad | |||
| 56a0711860 | |||
| 54f13d352c | |||
| 550d9f362c | |||
| ac3478889c | |||
| 44da1e6599 | |||
| b59cf8044b | |||
| 5838588fc5 | |||
| 0e9fd478e5 | |||
| 2f1cd7876c | |||
| ccaee9fa34 | |||
| c442a20d3e | |||
| cdf0c0c2c7 | |||
| 6ccff23804 | |||
| c3a9c0fddb | |||
| b593b1e6a7 | |||
| fe921b8790 | |||
| 7751b5472a | |||
| 07a071503a | |||
| b256a77d0c | |||
| 74d10535a1 | |||
| 16f603ccba | |||
| 9660369851 | |||
| 0a9201e0d1 | |||
| 74a2bbdc92 | |||
| 168927713c | |||
| 7a50138640 | |||
| d7741d14f5 | |||
| 5a98fe1a75 | |||
| 09e6425b1c | |||
| 4f9792d027 | |||
| 36c6687168 | |||
| 101a875837 | |||
| 81f1bcfca8 | |||
| b33f4905ab | |||
| edf0a9090d | |||
| 9b3f10f086 | |||
| cbfbe3be14 | |||
| f1f5d80ed8 | |||
| edac0f50a6 | |||
| 288cb34f1a | |||
| e88d1fd875 | |||
| a867ac4664 | |||
| 1aab5367a8 | |||
| 5d435f1f1f | |||
| 7868a77c8a | |||
| 43ef58936b | |||
| cb6fbd05a3 | |||
| 739d918c21 | |||
| 332ab58f58 | |||
| 8433d536f6 | |||
| 21b2e60df4 | |||
| 70f18e89bf | |||
| c361ec5ba3 | |||
| 0c6d707325 | |||
| 1c72a5009f | |||
| 9aeb98da58 | |||
| c19bdd48e2 | |||
| e5f42924f8 | |||
| 13eb9028c7 | |||
| 5fa79bdf08 | |||
| 1a11944423 | |||
| b0842566c0 | |||
| eb6624cba5 | |||
| 37f1a0ef8f | |||
| da4a8d4622 | |||
| 81df59ee15 | |||
| 69fac41003 | |||
| 1ee916b863 | |||
| 9c41b4814b | |||
| 2f4f016fe7 | |||
| 832aa86a34 | |||
| a4933f2127 | |||
| 6afdf49f5e | |||
| 9269c4b488 | |||
| 78313f130d | |||
| e75b3eb418 | |||
| ae7483e929 | |||
| db0501fca0 | |||
| 6629ba2483 | |||
| 3dc0f13af9 | |||
| b3db70db91 | |||
| e2addfc288 | |||
| e0fba15246 | |||
| a8bb5bf6b9 | |||
| b76cb7e1df | |||
| 3667fce5f4 | |||
| c2a4fa4087 | |||
| e4ebb2e723 | |||
| 7fc0661b32 | |||
| 5b6f7bb1de | |||
| b030b69c0d | |||
| 25c972263f | |||
| 818cb2d70e | |||
| c0afe96f56 | |||
| 6cfb5ea567 | |||
| ea1cf43cbf | |||
| 9a920f9a5a | |||
| b813c8f221 | |||
| 6f2b4d49b3 | |||
| 7ee969fb81 | |||
| 99a581674d | |||
| 004a0278a7 | |||
| 2aa859f7cb | |||
| 75fbd340aa | |||
| 1a8113d43b | |||
| dafc696053 | |||
| f205e753e8 | |||
| 3324eec9ec | |||
| 8c339eb309 | |||
| 9ec8ef12b0 | |||
| 468b2dc933 | |||
| a1cbec85da | |||
| 7de55d1041 | |||
| 1468bd5c5a | |||
| a2a7cb431d | |||
| baf3f27656 | |||
| 72e67039cf | |||
| b8b4ab9e1d | |||
| 84fea5e5d7 | |||
| 73ff0fb77d | |||
| bd99b928d9 | |||
| 06ea95f0b3 | |||
| b0721e5af1 | |||
| d9d1cf134d | |||
| cc7214b880 | |||
| 4cad54a696 | |||
| 8499b4304e | |||
| eb73cab155 | |||
| beefd51b15 | |||
| 0ab444b0b9 | |||
| 867aae5ffe | |||
| ff7f84c386 | |||
| 2000c27324 | |||
| 182b469b0b | |||
| 27fdd77aa1 | |||
| 88151dc780 | |||
| b2c26622e8 | |||
| 84f39b99de | |||
| fca121f170 | |||
| 794eed04d4 | |||
| e9eca73336 | |||
| 74dfe3f3d0 | |||
| f77a185e46 | |||
| 6a7594e128 | |||
| 666252f18c | |||
| f87b3f1989 | |||
| caf4ec6266 | |||
| 47d0ccc7ce | |||
| 96ec583c7e | |||
| 6e2760c514 | |||
| 59905ccd98 | |||
| 62bb20edb0 | |||
| 4e082c345a | |||
| 5bcd951e6a | |||
| c0747b62a0 | |||
| 3066442c1b | |||
| 4a3e52ec1c | |||
| daecb5ff32 | |||
| 99113aa26b | |||
| 4b78a649b1 | |||
| 2e9e2aae5f | |||
| db06fcd385 | |||
| d7026d5c0e | |||
| 20d5b3ba3d | |||
| 1c4fb59960 | |||
| fe3fa854bb | |||
| 37b83e654a | |||
| 761fae9b9b | |||
| fc9580403d | |||
| 92e8fce0e7 | |||
| cd21cc26c9 | |||
| fa75908552 | |||
| 7e2e396264 | |||
| 01e06deeef | |||
| 97f6a34472 | |||
| fc8630f894 | |||
| ef0fb7f5d9 | |||
| 3d0a6bc43a | |||
| e693aab13b | |||
| a2f1539027 | |||
| 508fe7abc1 | |||
| ed1e2c3e6b | |||
| 2b535a90e4 | |||
| 3f29fae12f | |||
| c050cb3e40 | |||
| cfec0c6703 | |||
| 00717f32ce | |||
| fc90e91174 | |||
| c248b89fcd | |||
| 73992a95cc | |||
| ba945b5527 | |||
| 43fc278fd1 | |||
| 484448b51c | |||
| a708e17b50 | |||
| 17ff374be7 | |||
| d05830335f | |||
| 4848b09be1 | |||
| 5a458c0c8c | |||
| beda174819 | |||
| 4e67328a09 | |||
| c5670a9a36 | |||
| a14101e415 | |||
| c5e2df4ca7 | |||
| 43d253aa21 | |||
| 6c5e9b42d3 | |||
| 5a6ba8ff57 | |||
| 6c1ccdad16 | |||
| 6748d31f87 | |||
| 40fd1bfde9 | |||
| 57f6ae658d | |||
| a857f972ea | |||
| f009573b4a | |||
| 4b4696ce30 | |||
| afa6c8821f | |||
| afe9680d7a | |||
| 422de535af | |||
| 44bba18149 | |||
| e15cfb1535 | |||
| a2a8fcbd14 |
@@ -0,0 +1,116 @@
|
||||
---
|
||||
name: keepassgo-apk-test
|
||||
description: Build and run the KeePassGO Android APK for emulator testing. Use when work requires `make apk`, APK install/launch, emulator validation, or checking known KeePassGO Android regressions such as black screens, clipboard, open flow, or local sync behavior.
|
||||
---
|
||||
|
||||
# KeePassGO APK Test
|
||||
|
||||
Use this skill together with the installed `android-emulator-debug` skill. That skill covers generic emulator and `adb` mechanics. This skill adds the KeePassGO-specific build, install, and validation requirements that have already been established in this repo.
|
||||
|
||||
## Use This Skill When
|
||||
|
||||
- Building `build/keepassgo.apk`.
|
||||
- Installing or launching KeePassGO in the Android emulator.
|
||||
- Verifying Android regressions such as black screen, clipboard, open flow, file picker, or Advanced Sync behavior.
|
||||
- Checking whether a Gio or Android toolchain change broke runtime behavior.
|
||||
|
||||
## Known Working Environment
|
||||
|
||||
- Preferred emulator AVD: `KeepassGoAPI35`
|
||||
- Package: `org.julianfamily.keepassgo`
|
||||
- Activity: `org.gioui.GioActivity`
|
||||
- Local APK build defaults:
|
||||
`ANDROID_SDK_ROOT=/opt/android-sdk`
|
||||
`ANDROID_NDK_ROOT=/opt/android-ndk`
|
||||
`JAVA_HOME=/usr/lib/jvm/java-25-openjdk`
|
||||
- CI APK build uses:
|
||||
`ANDROID_SDK_ROOT=/opt/android-sdk`
|
||||
`ANDROID_NDK_ROOT=/opt/android-sdk/ndk`
|
||||
`JAVA_HOME=/usr/lib/jvm/java-21-openjdk-amd64`
|
||||
|
||||
## Known Regression
|
||||
|
||||
- `gioui.org v0.9.0` caused a black screen in the `KeepassGoAPI35` emulator.
|
||||
- `gioui.org v0.8.0` rendered correctly in the same environment.
|
||||
- If Android rendering regresses after a Gio change, treat Gio as a primary suspect and verify against this known-bad versus known-good history before guessing about app logic.
|
||||
|
||||
## Safety Rules
|
||||
|
||||
- Do not clobber the user’s real KeePassGO state while testing.
|
||||
- For host-side validation, use isolated state such as:
|
||||
`KEEPASSGO_STATE_DIR="$(mktemp -d)" go test ./...`
|
||||
- Prefer sanitized demo or test vaults when seeding emulator tests.
|
||||
- If a real vault is absolutely required to reproduce a problem, do not modify it and do not overwrite the user’s existing recent-vault state.
|
||||
|
||||
## Build Workflow
|
||||
|
||||
1. Verify the JDK/SDK paths match the known working environment.
|
||||
2. Build with `make apk`.
|
||||
3. If `make apk` fails, inspect the effective `JAVA_HOME`, `ANDROID_SDK_ROOT`, and `ANDROID_NDK_ROOT` before changing code.
|
||||
4. If the problem is Android-only, avoid desktop-only conclusions from `go test ./...`.
|
||||
|
||||
Typical local build:
|
||||
|
||||
```sh
|
||||
JAVA_HOME=/usr/lib/jvm/java-25-openjdk make apk
|
||||
```
|
||||
|
||||
## Emulator Workflow
|
||||
|
||||
1. Reuse an existing emulator session if one is already running.
|
||||
2. Prefer the `KeepassGoAPI35` AVD.
|
||||
3. Install with `adb install -r build/keepassgo.apk`.
|
||||
4. Launch with:
|
||||
|
||||
```sh
|
||||
adb shell monkey -p org.julianfamily.keepassgo -c android.intent.category.LAUNCHER 1
|
||||
```
|
||||
|
||||
5. Confirm focus before drawing conclusions:
|
||||
|
||||
```sh
|
||||
adb shell dumpsys window | rg 'mCurrentFocus|mFocusedApp'
|
||||
```
|
||||
|
||||
6. Capture evidence before editing code:
|
||||
screenshot
|
||||
focused window
|
||||
relevant `logcat`
|
||||
|
||||
## Validation Checklist
|
||||
|
||||
- APK builds successfully with `make apk`.
|
||||
- App launches to `org.julianfamily.keepassgo/org.gioui.GioActivity`.
|
||||
- Screenshot shows the expected screen, not just a black frame.
|
||||
- `logcat` shows no app crash or Android runtime fatal error.
|
||||
- If the change is about user interaction, perform the tap-through in the emulator instead of stopping at install success.
|
||||
|
||||
## Issue-Specific Checks
|
||||
|
||||
### Black Screen
|
||||
|
||||
- Confirm the app is focused.
|
||||
- Capture a screenshot and `logcat` before changing anything.
|
||||
- Compare the current Gio version against the known `v0.9.0` regression history.
|
||||
- If needed, test whether a minimal Gio example renders in the same emulator before blaming KeePassGO layout code.
|
||||
|
||||
### Clipboard
|
||||
|
||||
- Verify behavior in the emulator, not just unit tests.
|
||||
- Android clipboard writes must use the Gio-native command path, not desktop clipboard backends.
|
||||
|
||||
### File Picker Or Local Sync
|
||||
|
||||
- Verify the picker flow on Android itself.
|
||||
- Prefer document-stream or content-URI based behavior over raw filesystem paths when Android permissions are involved.
|
||||
|
||||
## Report Back
|
||||
|
||||
When closing out work, state:
|
||||
|
||||
- the exact build command used
|
||||
- whether the emulator session was reused or newly started
|
||||
- whether install succeeded
|
||||
- whether the app actually rendered
|
||||
- what was verified manually in the emulator
|
||||
- what remains unverified
|
||||
@@ -0,0 +1,109 @@
|
||||
---
|
||||
name: keepassgo-ship-it
|
||||
description: KeePassGO-specific ship workflow. Use when the user says `ship it` in this repository and expects the current work to be committed, the Arch package rebuilt and installed, the Android APK rebuilt and zipped, the ZIP uploaded to Nextcloud, and the rebuilt app launched in the emulator with a controlled demo vault opened.
|
||||
---
|
||||
|
||||
# KeePassGO Ship It
|
||||
|
||||
Use this skill only in the KeePassGO repository. This is not a global shorthand.
|
||||
|
||||
Use it together with:
|
||||
- `android-emulator-debug` for emulator and `adb` mechanics
|
||||
- `keepass-credentials` for Nextcloud credentials
|
||||
- `public-repo-sanitization` before the commit/push step
|
||||
|
||||
## Meaning Of `ship it`
|
||||
|
||||
When the user says `ship it`, do all of this unless they narrow the scope:
|
||||
|
||||
1. Commit the relevant KeePassGO source changes first.
|
||||
2. Build and install the Arch package from that committed source.
|
||||
3. Build the Android APK from that same committed source.
|
||||
4. Zip the APK.
|
||||
5. Upload the ZIP to the user's configured Nextcloud DAV destination for this repository.
|
||||
6. Install the rebuilt APK in the emulator.
|
||||
7. Launch the rebuilt app in the emulator.
|
||||
8. Open a controlled demo vault in the emulator.
|
||||
|
||||
Do not stop after the commit or after the package build. `ship it` means finish the full loop.
|
||||
|
||||
## Required Sequence
|
||||
|
||||
### 1. Commit First
|
||||
|
||||
- Make sure the worktree state intended for shipping is committed before building.
|
||||
- If the repo is dirty in unrelated ways, commit only the relevant changes.
|
||||
- Before the commit or push, run the public-repo sanitization checks.
|
||||
|
||||
### 2. Build And Install The Arch Package
|
||||
|
||||
From the repo root:
|
||||
|
||||
```sh
|
||||
make archlinux-pkgbuild
|
||||
cd packaging/archlinux/keepassgo-git
|
||||
makepkg -si --noconfirm
|
||||
```
|
||||
|
||||
The installed package version must correspond to the committed source, not a dirty worktree.
|
||||
|
||||
### 3. Build The APK
|
||||
|
||||
Use the repo's known-good local JDK unless the environment already proves otherwise:
|
||||
|
||||
```sh
|
||||
JAVA_HOME=/usr/lib/jvm/java-25-openjdk make apk
|
||||
```
|
||||
|
||||
If that JDK is unavailable on the current host, use the working replacement already established for the machine and say so in the closeout.
|
||||
|
||||
### 4. Zip The APK
|
||||
|
||||
- Create the ZIP under the globally required temporary secret-safe directory.
|
||||
- Use a name that includes the commit, for example:
|
||||
`keepassgo-<shortsha>-apk.zip`
|
||||
|
||||
### 5. Upload To Nextcloud
|
||||
|
||||
- Get credentials and the DAV endpoint with `keepass-http`, not by asking the user if KeePass likely has them.
|
||||
- Prefer the established KeePass entry and DAV destination already in use for this repository's shipping workflow.
|
||||
- Use the globally required temporary secret-safe directory for any temporary curl config or secret material.
|
||||
- Ensure that directory exists with mode `700`.
|
||||
- Create secret temp files with mode `600`.
|
||||
- After upload, zero and unlink the temp secret file. Do not use `rm -f` or `rm -rf`.
|
||||
|
||||
### 6. Emulator Install And Launch
|
||||
|
||||
- Reuse the existing emulator session if one is already running.
|
||||
- Install with replacement:
|
||||
|
||||
```sh
|
||||
adb install -r build/keepassgo.apk
|
||||
```
|
||||
|
||||
- Launch KeePassGO and confirm it is focused.
|
||||
- Treat the emulator as timing-sensitive. If Android shows a transient "Wait" style ANR dialog and the user says the app is otherwise fine, do not misclassify that as an app-logic failure.
|
||||
|
||||
### 7. Open A Controlled Demo Vault
|
||||
|
||||
- Do not rely on the user's real vault for this step.
|
||||
- Use a controlled/sanitized demo vault that you can unlock yourself.
|
||||
- Open it in the emulator before closing out `ship it`.
|
||||
- Capture a screenshot if needed to verify the app really rendered and opened the vault.
|
||||
|
||||
## Closeout Requirements
|
||||
|
||||
When reporting back after `ship it`, include:
|
||||
- the commit that was shipped
|
||||
- the installed Arch package version
|
||||
- the APK path
|
||||
- the uploaded ZIP URL
|
||||
- confirmation that the emulator app was launched
|
||||
- confirmation that the controlled demo vault was opened
|
||||
|
||||
## Constraints
|
||||
|
||||
- Keep this workflow specific to KeePassGO.
|
||||
- Preserve emulator state; do not kill or reset it unless the user explicitly asks.
|
||||
- Do not use `rm -rf`.
|
||||
- Do not use `rm -f`.
|
||||
@@ -0,0 +1,179 @@
|
||||
name: ci
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
tags:
|
||||
- "v*"
|
||||
- "release-*"
|
||||
- "[0-9]+.[0-9]+.[0-9]+*"
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
env:
|
||||
GO_VERSION: "1.26.1"
|
||||
ANDROID_SDK_ROOT: /opt/android-sdk
|
||||
ANDROID_NDK_ROOT: /opt/android-sdk/ndk
|
||||
JAVA_HOME: /usr/lib/jvm/java-21-openjdk-amd64
|
||||
DIST_DIR: dist
|
||||
|
||||
jobs:
|
||||
lint-test:
|
||||
runs-on: keepassgo-android
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: ${{ env.GO_VERSION }}
|
||||
|
||||
- name: Install native build dependencies
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
export DEBIAN_FRONTEND=noninteractive
|
||||
apt-get update
|
||||
apt-get install -y --no-install-recommends \
|
||||
zsh \
|
||||
pkg-config \
|
||||
libx11-dev \
|
||||
libx11-xcb-dev \
|
||||
libxkbcommon-dev \
|
||||
libxkbcommon-x11-dev \
|
||||
libwayland-dev \
|
||||
libvulkan-dev \
|
||||
libegl1-mesa-dev \
|
||||
libxcursor-dev \
|
||||
libxfixes-dev
|
||||
|
||||
- name: Lint
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
state_dir="$(mktemp -d)"
|
||||
trap 'rm -rf -- "$state_dir"' EXIT
|
||||
KEEPASSGO_STATE_DIR="$state_dir" go tool golangci-lint run --build-tags nox11,nowayland,novulkan ./...
|
||||
|
||||
- name: Test
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
state_dir="$(mktemp -d)"
|
||||
trap 'rm -rf -- "$state_dir"' EXIT
|
||||
KEEPASSGO_STATE_DIR="$state_dir" go test -tags nox11,nowayland,novulkan ./...
|
||||
|
||||
build:
|
||||
needs: lint-test
|
||||
runs-on: keepassgo-android
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: ${{ env.GO_VERSION }}
|
||||
|
||||
- name: Install native build dependencies
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
export DEBIAN_FRONTEND=noninteractive
|
||||
apt-get update
|
||||
apt-get install -y --no-install-recommends \
|
||||
zsh \
|
||||
pkg-config \
|
||||
libx11-dev \
|
||||
libx11-xcb-dev \
|
||||
libxkbcommon-dev \
|
||||
libxkbcommon-x11-dev \
|
||||
libwayland-dev \
|
||||
libvulkan-dev \
|
||||
libegl1-mesa-dev \
|
||||
libxcursor-dev \
|
||||
libxfixes-dev
|
||||
|
||||
- name: Prepare dist directory
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
mkdir -p "${DIST_DIR}"
|
||||
|
||||
- name: Build desktop binaries
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
app_version="$(git describe --tags --always --dirty)"
|
||||
# Gio needs a Linux ARM64 cgo cross-toolchain for desktop builds.
|
||||
# Keep the CI matrix to targets this runner can build reproducibly.
|
||||
for target in \
|
||||
"linux amd64" \
|
||||
"windows amd64" \
|
||||
"windows arm64"
|
||||
do
|
||||
set -- ${target}
|
||||
goos="$1"
|
||||
goarch="$2"
|
||||
ext=""
|
||||
cgo_enabled=0
|
||||
if [[ "${goos}" == "linux" ]]; then
|
||||
cgo_enabled=1
|
||||
fi
|
||||
if [[ "${goos}" == "windows" ]]; then
|
||||
ext=".exe"
|
||||
fi
|
||||
out="${DIST_DIR}/keepassgo-${goos}-${goarch}${ext}"
|
||||
GOOS="${goos}" GOARCH="${goarch}" CGO_ENABLED="${cgo_enabled}" \
|
||||
go build -ldflags "-X git.julianfamily.org/keepassgo.appVersion=${app_version}" -o "${out}" ./cmd/keepassgo
|
||||
done
|
||||
|
||||
- name: Build APK
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
signkey_path="$(mktemp)"
|
||||
trap 'rm -f -- "$signkey_path"' EXIT
|
||||
printf '%s' '${{ secrets.APK_SIGNKEY_B64 }}' | base64 -d > "$signkey_path"
|
||||
export APP_VERSION="$(git describe --tags --always --dirty)"
|
||||
make apk SIGNKEY="$signkey_path" SIGNPASS='${{ secrets.APK_SIGNPASS }}'
|
||||
cp build/keepassgo.apk "${DIST_DIR}/keepassgo.apk"
|
||||
|
||||
- name: Upload CI artifacts
|
||||
uses: christopherhx/gitea-upload-artifact@v4
|
||||
env:
|
||||
NODE_OPTIONS: --use-system-ca
|
||||
SSL_CERT_FILE: /etc/ssl/certs/ca-certificates.crt
|
||||
with:
|
||||
name: keepassgo-${{ gitea.sha }}
|
||||
path: |
|
||||
dist/keepassgo-linux-amd64
|
||||
dist/keepassgo-windows-amd64.exe
|
||||
dist/keepassgo-windows-arm64.exe
|
||||
dist/keepassgo.apk
|
||||
retention-days: 30
|
||||
|
||||
- name: Publish release artifacts
|
||||
if: startsWith(gitea.ref, 'refs/tags/')
|
||||
env:
|
||||
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||
GITEA_SERVER_URL: ${{ gitea.server_url }}
|
||||
GITEA_REPOSITORY: ${{ gitea.repository }}
|
||||
GITEA_REF_NAME: ${{ gitea.ref_name }}
|
||||
SSL_CERT_FILE: /etc/ssl/certs/ca-certificates.crt
|
||||
REQUESTS_CA_BUNDLE: /etc/ssl/certs/ca-certificates.crt
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
python3 scripts/gitea_release.py \
|
||||
--server "${GITEA_SERVER_URL}" \
|
||||
--repo "${GITEA_REPOSITORY}" \
|
||||
--token "${GITEA_TOKEN}" \
|
||||
--tag "${GITEA_REF_NAME}" \
|
||||
"${DIST_DIR}/keepassgo-linux-amd64" \
|
||||
"${DIST_DIR}/keepassgo-windows-amd64.exe" \
|
||||
"${DIST_DIR}/keepassgo-windows-arm64.exe" \
|
||||
"${DIST_DIR}/keepassgo.apk"
|
||||
@@ -1,2 +1,9 @@
|
||||
build/
|
||||
*.apk
|
||||
/keepassgo
|
||||
android/keepassgo-android.jar
|
||||
packaging/archlinux/keepassgo-git/*.pkg.tar.zst
|
||||
packaging/archlinux/keepassgo-git/PKGBUILD
|
||||
packaging/archlinux/keepassgo-git/pkg/
|
||||
packaging/archlinux/keepassgo-git/src/
|
||||
packaging/archlinux/keepassgo-git/keepassgo/
|
||||
|
||||
@@ -1,8 +1,19 @@
|
||||
linters:
|
||||
enable:
|
||||
- errcheck
|
||||
- gocyclo
|
||||
- gosimple
|
||||
- govet
|
||||
- ineffassign
|
||||
- staticcheck
|
||||
- unused
|
||||
|
||||
linters-settings:
|
||||
gocyclo:
|
||||
min-complexity: 15
|
||||
|
||||
issues:
|
||||
exclude-rules:
|
||||
- path: _test\.go
|
||||
linters:
|
||||
- gocyclo
|
||||
|
||||
@@ -14,6 +14,8 @@ These instructions apply to all future work in this repository.
|
||||
## Skills
|
||||
|
||||
- Use the installed Go skills whenever they materially apply to the current slice of work.
|
||||
- Use the installed `android-emulator-debug` skill for Android emulator lifecycle, `adb`, screenshots, and log capture work.
|
||||
- Use the repo-local `keepassgo-apk-test` skill for KeePassGO-specific APK build and emulator test runs.
|
||||
- The available Go skills for this repository are:
|
||||
`go-code-review`,
|
||||
`go-concurrency`,
|
||||
@@ -93,6 +95,20 @@ These features are product requirements, not “nice to have” ideas.
|
||||
- Phone should optimize for low tap count, not purity of mobile patterns.
|
||||
- The stacked phone layout is the current preferred phone direction.
|
||||
- Do not reintroduce the abandoned phone flow mode unless explicitly requested.
|
||||
- Keep the product feeling like the same application on desktop, Android phone,
|
||||
and Android tablet.
|
||||
- Platform adaptation is allowed for layout and spacing, not for changing the
|
||||
user's mental model of the workflow.
|
||||
- Use the same action names, the same primary next steps, and the same workflow
|
||||
order across platforms unless there is a hard platform constraint.
|
||||
- Treat workflow prominence and reachability as product behavior, not visual
|
||||
polish. A feature is not parity-complete if it technically exists but is
|
||||
harder to discover or reach on one platform.
|
||||
- Prefer shared workflow decisions with platform-specific presentation, rather
|
||||
than platform-specific workflow branches.
|
||||
- Make all test strings `Heist Movie` themed. Use characters, crews, casinos,
|
||||
vaults, and locations from heist movies so test fixtures stay obviously fake
|
||||
and consistent with the product theme.
|
||||
|
||||
## Architecture
|
||||
|
||||
@@ -102,6 +118,20 @@ These features are product requirements, not “nice to have” ideas.
|
||||
- Prefer behavior-oriented tests that describe expected product behavior rather than implementation details.
|
||||
- Provide a secure gRPC API as a first-class programmatic surface, not as a thin wrapper around UI state.
|
||||
- Design browser-extension and automation integrations against the gRPC API, not against ad hoc local protocols.
|
||||
- Treat the vault model as local-first across all platforms:
|
||||
every usable vault is a local KDBX file first, and remote sync attaches to
|
||||
that local vault rather than replacing it as the primary object.
|
||||
- Keep the remote-sync model standardized across desktop, Android phone, and
|
||||
Android tablet:
|
||||
shared remote configuration belongs in the vault,
|
||||
cross-platform workflow stays the same,
|
||||
and only Android's initial KDBX share/import transport may differ.
|
||||
- Do not persist remote credentials in plaintext app-local state.
|
||||
Keep only non-secret binding metadata outside the vault.
|
||||
- When working on remote-sync behavior, preserve the local cache / local-first
|
||||
design:
|
||||
opening or creating the local vault is the main workflow,
|
||||
and remote setup, remote settings, and remote use attach to that vault.
|
||||
|
||||
## Delivery Discipline
|
||||
|
||||
@@ -112,6 +142,14 @@ These features are product requirements, not “nice to have” ideas.
|
||||
implement the minimum code to satisfy them,
|
||||
verify with `go test ./...` and relevant lint checks,
|
||||
and commit each completed behavior.
|
||||
- For cross-platform UI work, behavior tests must cover workflow parity, not
|
||||
just feature or label parity.
|
||||
- For lifecycle, open, unlock, sync, and other primary flows, tests should
|
||||
assert the same conceptual next step across desktop, phone, and tablet
|
||||
layouts.
|
||||
- When Android or phone UX is part of the slice, verify real reachability on an
|
||||
emulator or device for the exact flow being changed. Do not count “the same
|
||||
buttons exist somewhere on screen” as sufficient validation.
|
||||
- Only stop before the requirements are satisfied if the work is genuinely blocked by a missing decision, missing external dependency, or a hard technical constraint that cannot be resolved within the repo.
|
||||
- If blocked, state the blocker concretely and stop only at that point.
|
||||
|
||||
@@ -120,14 +158,39 @@ These features are product requirements, not “nice to have” ideas.
|
||||
- Plan for direct KDBX support.
|
||||
- Plan for direct WebDAV-based workflows.
|
||||
- Avoid adding npm-based or browser-stack dependencies.
|
||||
- Keep remote configuration and synchronization local-first:
|
||||
the app should maintain a live local KDBX cache even when using a remote
|
||||
store, so remote outage does not eliminate vault access.
|
||||
- Prefer vault-backed remote setup and lookup over ad hoc local credential
|
||||
storage.
|
||||
- On Android, use system picker/share mechanisms for local vault import/export
|
||||
rather than raw path entry when a user is selecting or sharing a vault file.
|
||||
|
||||
## Tooling
|
||||
|
||||
- Keep `golangci-lint` passing.
|
||||
- Keep `go test ./...` passing.
|
||||
- Track `gogio` as a Go tool and keep a reproducible `make apk` path for Android packaging.
|
||||
- Keep the Android build requirements aligned with the known working setup:
|
||||
`ANDROID_SDK_ROOT=/opt/android-sdk`,
|
||||
local `ANDROID_NDK_ROOT=/opt/android-ndk`,
|
||||
CI `ANDROID_NDK_ROOT=/opt/android-sdk/ndk`,
|
||||
local `JAVA_HOME=/usr/lib/jvm/java-25-openjdk`,
|
||||
CI `JAVA_HOME=/usr/lib/jvm/java-21-openjdk-amd64`.
|
||||
- Remember the known Android runtime regression:
|
||||
`gioui.org v0.9.0` produced a black screen on the `KeepassGoAPI35` emulator, while `gioui.org v0.8.0` rendered correctly. Treat Gio upgrades on Android as regression-sensitive and verify them on-device or in the emulator.
|
||||
- When validating an APK in the emulator, prefer the known KeePassGO setup:
|
||||
AVD `KeepassGoAPI35`,
|
||||
package `org.julianfamily.keepassgo`,
|
||||
activity `org.gioui.GioActivity`.
|
||||
- Do not run emulator/manual APK tests against the user’s real persisted app state.
|
||||
Use an isolated `KEEPASSGO_STATE_DIR` for host-side validation, and when emulator testing requires seeded vault data, use sanitized test/demo vaults rather than the user’s real vault files whenever possible.
|
||||
- When running tests or other automated validation that may touch persisted UI state, set `KEEPASSGO_STATE_DIR` to an isolated temporary directory so recent-vault history and other local state do not pollute the user’s real config.
|
||||
- Prefer commands shaped like `KEEPASSGO_STATE_DIR=\"$(mktemp -d)\" go test ./...` for ad hoc local validation unless a test already manages its own isolated state directory.
|
||||
- For the Arch package, treat
|
||||
`packaging/archlinux/keepassgo-git/PKGBUILD.tmpl`
|
||||
as the source of truth and regenerate `PKGBUILD` from the template before
|
||||
building.
|
||||
- Do not assume the agent can decrypt SOPS-encrypted secrets in this repository.
|
||||
- If work requires plaintext from a SOPS-encrypted secret, stop and ask the user to decrypt it or otherwise provide the needed plaintext.
|
||||
- Do not commit generated binaries.
|
||||
|
||||
@@ -12,6 +12,7 @@ Environment:
|
||||
- `ANDROID_NDK_ROOT` defaults to `/opt/android-ndk`.
|
||||
- `JAVA_HOME` defaults to `/usr/lib/jvm/java-25-openjdk`.
|
||||
- `APP_ID` overrides the Android application id.
|
||||
- `APP_VERSION` overrides the version shown inside KeePassGO itself.
|
||||
- `APK_OUT` overrides the output path.
|
||||
- `APK_VERSION` overrides the packaged app version.
|
||||
- `ANDROID_MIN_SDK` overrides the minimum supported Android SDK.
|
||||
@@ -23,10 +24,26 @@ Installed machine prerequisites expected by this repo:
|
||||
- `android-sdk-build-tools`
|
||||
- `android-platform-35`
|
||||
- `android-sdk-platform-tools`
|
||||
- JDK 17 or newer
|
||||
- a working JDK install
|
||||
|
||||
The repo tracks `gogio` as a Go tool, so the build runs through:
|
||||
|
||||
```sh
|
||||
go tool gogio -target android ...
|
||||
go tool gogio -target android ./cmd/keepassgo ...
|
||||
```
|
||||
|
||||
The Android build uses the branded icon asset at:
|
||||
|
||||
- `internal/assets/keepassgo-icon.png`
|
||||
|
||||
Note:
|
||||
|
||||
- Gio's Android doc currently references Java 1.8, but the Android build-tools
|
||||
installed on this machine (`d8` from build-tools 37) do not run on Java 8.
|
||||
- In this environment, KeePassGO's APK build requires a newer JDK runtime on
|
||||
`PATH`, which is why the repo defaults `JAVA_HOME` to `/usr/lib/jvm/java-25-openjdk`.
|
||||
- Android runtime testing on the `KeepassGoAPI35` emulator showed a black-screen
|
||||
regression with `gioui.org v0.9.0` while a stock Gio example and KeePassGO both
|
||||
rendered correctly with `gioui.org v0.8.0` on the same emulator and SDK/JDK
|
||||
pipeline. KeePassGO is pinned to the working Gio line until that regression is
|
||||
understood upstream.
|
||||
|
||||
@@ -5,12 +5,29 @@ PATH := $(JAVA_HOME)/bin:$(ANDROID_SDK_ROOT)/cmdline-tools/latest/bin:$(ANDROID_
|
||||
APP_ID ?= org.julianfamily.keepassgo
|
||||
APK_OUT ?= build/keepassgo.apk
|
||||
APK_VERSION ?= 0.1.0.1
|
||||
APP_VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo dev)
|
||||
GO_LDFLAGS ?= -X git.julianfamily.org/keepassgo/internal/appui.appVersion=$(APP_VERSION)
|
||||
ANDROID_MIN_SDK ?= 28
|
||||
ANDROID_TARGET_SDK ?= 35
|
||||
SIGNKEY ?=
|
||||
SIGNPASS ?=
|
||||
ARCH_PKG_DIR ?= packaging/archlinux/keepassgo-git
|
||||
ARCH_PKG_TMPL ?= $(ARCH_PKG_DIR)/PKGBUILD.tmpl
|
||||
ARCH_PKGBUILD ?= $(ARCH_PKG_DIR)/PKGBUILD
|
||||
ARCH_PKGVER ?= $(shell printf 'r%s.%s' "$$(git rev-list --count HEAD 2>/dev/null || echo 0)" "$$(git rev-parse --short HEAD 2>/dev/null || echo dev)")
|
||||
ARCH_REPO_DIR ?= $(CURDIR)
|
||||
|
||||
.PHONY: apk
|
||||
apk:
|
||||
@test -x "$(JAVA_HOME)/bin/java" || { echo "JAVA_HOME must point to a JDK 17+ install"; exit 1; }
|
||||
GOGIO_SIGN_FLAGS :=
|
||||
ifneq ($(strip $(SIGNKEY)),)
|
||||
GOGIO_SIGN_FLAGS += -signkey $(SIGNKEY)
|
||||
endif
|
||||
ifneq ($(strip $(SIGNPASS)),)
|
||||
GOGIO_SIGN_FLAGS += -signpass $(SIGNPASS)
|
||||
endif
|
||||
|
||||
.PHONY: apk archlinux-pkgbuild
|
||||
apk: android/keepassgo-android.jar
|
||||
@test -x "$(JAVA_HOME)/bin/java" || { echo "JAVA_HOME must point to a working JDK install"; exit 1; }
|
||||
@test -d "$(ANDROID_SDK_ROOT)" || { echo "ANDROID_SDK_ROOT must point to an Android SDK install"; exit 1; }
|
||||
@test -d "$(ANDROID_NDK_ROOT)" || { echo "ANDROID_NDK_ROOT must point to an Android NDK install"; exit 1; }
|
||||
@test -x "$(ANDROID_SDK_ROOT)/cmdline-tools/latest/bin/sdkmanager" || { echo "Android SDK cmdline-tools are missing"; exit 1; }
|
||||
@@ -24,8 +41,30 @@ apk:
|
||||
go tool gogio -target android \
|
||||
-buildmode exe \
|
||||
-appid $(APP_ID) \
|
||||
-ldflags "$(GO_LDFLAGS)" \
|
||||
$(GOGIO_SIGN_FLAGS) \
|
||||
-o $(APK_OUT) \
|
||||
-version $(APK_VERSION) \
|
||||
-minsdk $(ANDROID_MIN_SDK) \
|
||||
-targetsdk $(ANDROID_TARGET_SDK) \
|
||||
.
|
||||
-icon internal/assets/keepassgo-icon.png \
|
||||
./cmd/keepassgo
|
||||
|
||||
android/keepassgo-android.jar: $(shell find androidsrc -type f | sort)
|
||||
@test -x "$(JAVA_HOME)/bin/javac" || { echo "JAVA_HOME must point to a working JDK install"; exit 1; }
|
||||
@test -f "$(ANDROID_SDK_ROOT)/platforms/android-$(ANDROID_TARGET_SDK)/android.jar" || { echo "Android platform android-$(ANDROID_TARGET_SDK) is missing"; exit 1; }
|
||||
@mkdir -p android
|
||||
@zsh -lc 'tmpdir=$$(mktemp -d); \
|
||||
trap '\''python3 -c "import shutil,sys; shutil.rmtree(sys.argv[1], ignore_errors=True)" "$$tmpdir"'\'' EXIT; \
|
||||
"$(JAVA_HOME)/bin/javac" \
|
||||
-classpath "$(ANDROID_SDK_ROOT)/platforms/android-$(ANDROID_TARGET_SDK)/android.jar" \
|
||||
-d "$$tmpdir" \
|
||||
$$(find androidsrc -name '\''*.java'\'' | sort); \
|
||||
"$(JAVA_HOME)/bin/jar" --create --file "$$(pwd)/android/keepassgo-android.jar" -C "$$tmpdir" .'
|
||||
|
||||
archlinux-pkgbuild: $(ARCH_PKG_TMPL) Makefile
|
||||
@mkdir -p "$(ARCH_PKG_DIR)"
|
||||
@sed \
|
||||
-e 's|@PKGVER@|$(ARCH_PKGVER)|g' \
|
||||
-e 's|@REPO_DIR@|$(ARCH_REPO_DIR)|g' \
|
||||
"$(ARCH_PKG_TMPL)" > "$(ARCH_PKGBUILD)"
|
||||
|
||||
@@ -38,23 +38,58 @@ KDBX security and KDF compatibility notes are documented in [`docs/kdbx-compatib
|
||||
Desktop build:
|
||||
|
||||
```bash
|
||||
go build ./...
|
||||
go build ./cmd/keepassgo
|
||||
```
|
||||
|
||||
By default, build outputs stamp the app version from `git describe --tags --always --dirty`.
|
||||
You can override the version shown in KeePassGO with:
|
||||
|
||||
```bash
|
||||
go build -ldflags "-X git.julianfamily.org/keepassgo/internal/appui.appVersion=v0.0.1" ./cmd/keepassgo
|
||||
```
|
||||
|
||||
## Arch Linux Package
|
||||
|
||||
An AUR-style package definition for the Linux desktop client lives under:
|
||||
|
||||
- `packaging/archlinux/keepassgo-git/`
|
||||
|
||||
From that directory you can build and install it with:
|
||||
|
||||
```bash
|
||||
makepkg -si
|
||||
```
|
||||
|
||||
The package installs:
|
||||
|
||||
- `/usr/bin/keepassgo`
|
||||
- a desktop entry at `/usr/share/applications/keepassgo.desktop`
|
||||
- application icons under the hicolor theme
|
||||
|
||||
## Android Packaging
|
||||
|
||||
KeePassGO uses Gio, so Android packaging is done with `gogio`.
|
||||
|
||||
The repo now has automated tests for the packaging contract:
|
||||
- default APK build arguments
|
||||
- required Android SDK / NDK / JDK layout checks
|
||||
|
||||
Those are covered by normal test runs:
|
||||
|
||||
```bash
|
||||
go test ./...
|
||||
```
|
||||
|
||||
Install:
|
||||
|
||||
```bash
|
||||
go install gioui.org/cmd/gogio@latest
|
||||
go get -tool gioui.org/cmd/gogio@latest
|
||||
```
|
||||
|
||||
Package:
|
||||
|
||||
```bash
|
||||
gogio -target android .
|
||||
go tool gogio -target android -icon internal/assets/keepassgo-icon.png ./cmd/keepassgo
|
||||
```
|
||||
|
||||
You will need the Android SDK and NDK installed and configured for real device or release packaging.
|
||||
|
||||
@@ -6,6 +6,132 @@ These segments are intended to be independently executable wherever possible.
|
||||
Each segment has its own local exit criteria.
|
||||
The product is not complete until the global exit criteria at the end of this file are also met.
|
||||
|
||||
## UI Review Follow-Ups
|
||||
|
||||
These items came from a hands-on emulator and desktop walkthrough.
|
||||
They should be treated as usability work, not just polish.
|
||||
|
||||
### Cross-Platform Workflow Parity
|
||||
|
||||
These items are required to keep desktop, Android phone, and Android tablet
|
||||
feeling like the same application rather than three related UIs.
|
||||
|
||||
- Workflow parity:
|
||||
define canonical workflows for open, unlock, set up remote sync, use remote
|
||||
sync, browse entries, and edit entries.
|
||||
- Workflow parity:
|
||||
ensure desktop, phone, and tablet use the same action names and the same
|
||||
primary next steps for those workflows.
|
||||
- Workflow parity:
|
||||
remove or reduce platform-specific workflow exceptions where the same user
|
||||
intent currently takes a different route on different form factors.
|
||||
- Testing:
|
||||
add cross-mode behavior tests that assert workflow order and action
|
||||
prominence, not just label presence.
|
||||
- Testing:
|
||||
add explicit lifecycle/open-screen tests for reachability of the primary
|
||||
action on desktop, phone, and tablet layouts.
|
||||
- Testing:
|
||||
add explicit remote-sync workflow tests that prove setup, settings, use, and
|
||||
removal are reachable from the same primary affordance family across modes.
|
||||
- Android verification:
|
||||
validate changed lifecycle/open/sync workflows on the emulator or a device,
|
||||
including with the on-screen keyboard visible.
|
||||
- Android verification:
|
||||
treat “present but below the fold or behind an unexpected branch” as a parity
|
||||
failure, not as acceptable platform variation.
|
||||
|
||||
### Primary Workflow Changes
|
||||
|
||||
These should remain in the main user flow rather than being hidden behind a settings gear.
|
||||
|
||||
- Local open flow:
|
||||
make the start screen primarily about opening a vault, not configuring one.
|
||||
- Local open flow:
|
||||
keep recent vault selection visually obvious and clearly tappable.
|
||||
- Local open flow:
|
||||
once a recent vault is preselected, collapse the full path into a compact summary with a `Change...` affordance.
|
||||
- Local open flow:
|
||||
improve Android field focus and IME behavior so the master-password field reliably takes focus and summons the keyboard.
|
||||
- Local open flow:
|
||||
show an explicit progress state and allow cancel or retry while opening a vault, especially on Android.
|
||||
- Remote open flow:
|
||||
break the remote form into clearer sections such as `Location` and `Authentication`.
|
||||
- Remote open flow:
|
||||
make recent remote connections easier to scan with a friendlier label than raw URL and path.
|
||||
- Locked screen:
|
||||
show clear vault identity and target summary so the user knows what is being unlocked.
|
||||
- Entries screen:
|
||||
tighten the top strip on phone so tabs, breadcrumbs, and group controls do not fight for the same row.
|
||||
- Entries screen:
|
||||
make breadcrumbs compress more aggressively on phone.
|
||||
- Entries screen:
|
||||
improve entry-row hierarchy and selected-state contrast.
|
||||
- Entries screen:
|
||||
provide section-specific empty states for search, recycle bin, API tokens, and empty groups.
|
||||
- Group navigation:
|
||||
make the distinction between root, current group, and child groups more obvious.
|
||||
- Group navigation:
|
||||
separate navigation controls from group-management controls more clearly.
|
||||
- Entry detail:
|
||||
tighten field spacing and reduce unnecessary whitespace.
|
||||
- Entry detail:
|
||||
group password reveal and copy actions more clearly.
|
||||
- Entry detail:
|
||||
make attachments more visible and actionable.
|
||||
- Entry edit:
|
||||
break the editor into clearer subsections such as `Basics`, `Notes`, `Custom Fields`, `History`, and `Attachments`.
|
||||
- Entry edit:
|
||||
make add/remove affordances for custom fields more visually obvious.
|
||||
- Entry edit:
|
||||
make generated-password draft state more explicit before save.
|
||||
- Recycle bin:
|
||||
make it visually distinct from normal entry browsing.
|
||||
- API tokens:
|
||||
give token list, token detail, and policy editing a clearer dedicated management surface.
|
||||
- API tokens:
|
||||
make policy rows easier to scan by separating effect, operation, and resource visually.
|
||||
- API audit:
|
||||
improve empty-state guidance and provide quick filtering by token, decision, and operation.
|
||||
- Synchronize:
|
||||
keep the split-button pattern, but reduce the visual weight of the sync controls and make advanced sync affordances clearer.
|
||||
- Synchronize:
|
||||
avoid layout-shifting success banners and keep noncritical notifications ephemeral.
|
||||
- Phone layout:
|
||||
continue reducing header and control density so content appears sooner.
|
||||
- Mobile reliability:
|
||||
fix Android local-open ANR behavior before deeper mobile polish.
|
||||
- Autofill UX:
|
||||
surface whether a fill candidate was found, ambiguous, blocked, or awaiting approval.
|
||||
|
||||
### Settings Gear Candidates
|
||||
|
||||
These are important, but they should likely move behind a dedicated settings gear or advanced/settings surface instead of occupying first-run or day-to-day credential screens.
|
||||
|
||||
- Vault security:
|
||||
move `Cipher` and `KDF` off the main local-open screen and into `Advanced` or `Vault Settings`.
|
||||
- Vault security:
|
||||
frame security settings as vault configuration rather than freeform text fields in the primary workflow.
|
||||
- Remote preferences:
|
||||
move remembered-auth behavior details and retention policy explanations into settings/help rather than the main open flow.
|
||||
- UI preferences:
|
||||
save and expose view preferences such as group-tools collapse state and any future dense/comfortable layout toggle under settings.
|
||||
- Autofill behavior:
|
||||
app and browser allowlists, package rules, and first-fill approval preferences should live under a settings/privacy area.
|
||||
- Sync defaults:
|
||||
source/direction defaults, conflict preferences, and any future background sync behavior should live under settings.
|
||||
- Notification preferences:
|
||||
banner timeout, ephemeral notices, and other noncritical UI feedback tuning should live under settings.
|
||||
- Accessibility preferences:
|
||||
future display-density, contrast, reduced-motion, or keyboard-focus tuning should live under settings.
|
||||
|
||||
### Exit Criteria
|
||||
|
||||
- The main workflow screens prioritize opening, browsing, copying, editing, and synchronizing credentials.
|
||||
- Advanced vault/security and behavior preferences are no longer cluttering the primary open and browsing flows.
|
||||
- Phone and desktop layouts both present a clear information hierarchy.
|
||||
- The Android open flow is reliable enough to review and use without ANR during ordinary vault-open operations.
|
||||
|
||||
## API Token And gRPC Authorization Parallel Segments
|
||||
|
||||
These segments define the work for programmatic access control over gRPC.
|
||||
@@ -449,6 +575,21 @@ Exit criteria:
|
||||
- Focus and accessibility states are visible and intentional.
|
||||
- `go test ./...` passes.
|
||||
|
||||
### Segment 21: Accessibility Fill Generalization
|
||||
|
||||
Scope:
|
||||
- Extend Android accessibility-based fill beyond the current Chrome demo path.
|
||||
- Support package-specific rules so apps with stable package identities can have tailored matching behavior.
|
||||
- Support view-id matching so custom login forms can be identified more reliably than by generic hints alone.
|
||||
- Support app allowlists so only approved apps/packages are eligible for accessibility-based credential fill.
|
||||
- Require an approval step before filling into a newly seen app/package unless the user has already made a persistent decision.
|
||||
|
||||
Exit criteria:
|
||||
- The design for package-specific rules, view-id matching, app allowlists, and first-fill approval is implemented or broken into executable sub-slices.
|
||||
- Accessibility fill no longer depends solely on Chrome-style generic username/password heuristics.
|
||||
- New app/package fill attempts can be allowed, denied, or made persistent by the user.
|
||||
- `go test ./...` passes.
|
||||
|
||||
### Segment 17: UI Completion And Error Surfaces
|
||||
|
||||
Scope:
|
||||
@@ -533,48 +674,8 @@ Do not treat the product as complete until all of the following are true:
|
||||
|
||||
## Remaining Gaps Against AGENTS.md
|
||||
|
||||
This section tracks requirements from `AGENTS.md` that are not fully satisfied by the current landed code, even if the segment work and tests are green.
|
||||
None currently identified.
|
||||
|
||||
### 1. KDBX Security Settings Are Only Preserved, Not Fully Product-Configurable
|
||||
|
||||
Evidence:
|
||||
- `docs/kdbx-compatibility.md` states that KeePassGO preserves the original opened vault's cipher and KDF selection during save.
|
||||
- The same document also states:
|
||||
- KeePassGO does not yet provide a UI for editing cipher or KDF parameters directly.
|
||||
- New vault creation still uses library default KDBX header settings.
|
||||
|
||||
Why this is still a gap:
|
||||
- `AGENTS.md` requires support for the major KeePass-style encryption and KDF configuration choices represented in KDBX databases.
|
||||
- Preserving existing settings is good, but it is weaker than allowing the product user to choose those settings for new vaults or change them explicitly for existing vaults.
|
||||
|
||||
Remaining work:
|
||||
- Expose major KDBX cipher/KDF choices in the product UI for vault creation.
|
||||
- Expose supported security-setting changes for an existing unlocked vault.
|
||||
- Add behavior tests covering explicit user selection of supported cipher/KDF options.
|
||||
- Update `docs/kdbx-compatibility.md` once those product-facing controls exist.
|
||||
|
||||
Exit criteria for this gap:
|
||||
- A user can create a vault with a selected supported cipher/KDF combination through the product.
|
||||
- A user can change supported cipher/KDF settings for an existing vault through the product, where the underlying library supports it.
|
||||
- Tests cover those choices end to end.
|
||||
|
||||
### 2. Accessibility Requirement Needs Explicit Screen-Reader Review
|
||||
|
||||
Evidence:
|
||||
- Keyboard-first behavior and focus handling exist in the landed code.
|
||||
- Accessibility labels exist in `ui_accessibility.go`.
|
||||
- The repository does not currently contain a documented review of what screen-reader-conscious behavior Gio can and cannot provide on the supported desktop targets.
|
||||
|
||||
Why this is still a gap:
|
||||
- `AGENTS.md` explicitly calls for screen-reader-conscious design, not only keyboard shortcuts and focus states.
|
||||
- The current code suggests intent, but the repo does not yet document a concrete accessibility support boundary or validation result.
|
||||
|
||||
Remaining work:
|
||||
- Audit the current Gio accessibility surface on Linux and Windows for the controls used by KeePassGO.
|
||||
- Document what is currently exposed, what is intentionally labeled, and what remains limited by the toolkit.
|
||||
- Add targeted tests for any label/focus mapping that can be verified in-repo.
|
||||
|
||||
Exit criteria for this gap:
|
||||
- The repo includes a documented accessibility review for current desktop targets.
|
||||
- Screen-reader-conscious behavior is explicitly described rather than implied.
|
||||
- Any in-repo verifiable accessibility mappings have tests.
|
||||
The last explicitly tracked gaps are now closed:
|
||||
- KDBX security settings are product-configurable at the major cipher/KDF family level for both new vault creation and existing sessions.
|
||||
- The current accessibility support boundary is documented in `docs/accessibility.md`, while in-repo focus and labeling behavior remains tested.
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
<service
|
||||
android:name="org.julianfamily.keepassgo.KeePassGOAutofillService"
|
||||
android:exported="true"
|
||||
android:label="KeePassGO Autofill"
|
||||
android:permission="android.permission.BIND_AUTOFILL_SERVICE">
|
||||
<intent-filter>
|
||||
<action android:name="android.service.autofill.AutofillService" />
|
||||
</intent-filter>
|
||||
<meta-data
|
||||
android:name="android.autofill"
|
||||
android:resource="@xml/keepassgo_autofill_service" />
|
||||
</service>
|
||||
<service
|
||||
android:name="org.julianfamily.keepassgo.KeePassGOAccessibilityService"
|
||||
android:exported="true"
|
||||
android:label="KeePassGO Accessibility Fill"
|
||||
android:permission="android.permission.BIND_ACCESSIBILITY_SERVICE">
|
||||
<intent-filter>
|
||||
<action android:name="android.accessibilityservice.AccessibilityService" />
|
||||
</intent-filter>
|
||||
<meta-data
|
||||
android:name="android.accessibilityservice"
|
||||
android:resource="@xml/keepassgo_accessibility_service" />
|
||||
</service>
|
||||
<provider
|
||||
android:name="org.julianfamily.keepassgo.SharedVaultProvider"
|
||||
android:authorities="org.julianfamily.keepassgo.share"
|
||||
android:exported="false"
|
||||
android:grantUriPermissions="true" />
|
||||
<activity
|
||||
android:name="org.julianfamily.keepassgo.SharedVaultImportActivity"
|
||||
android:exported="true"
|
||||
android:theme="@android:style/Theme.Translucent.NoTitleBar">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.SEND" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<data android:mimeType="application/octet-stream" />
|
||||
<data android:mimeType="application/x-keepass2" />
|
||||
<data android:mimeType="application/vnd.keepass" />
|
||||
</intent-filter>
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<data android:scheme="content" android:pathPattern=".*\\.kdbx" />
|
||||
<data android:scheme="file" android:pathPattern=".*\\.kdbx" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<accessibility-service xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:accessibilityEventTypes="typeViewFocused|typeViewTextChanged|typeWindowContentChanged|typeWindowStateChanged"
|
||||
android:accessibilityFeedbackType="feedbackGeneric"
|
||||
android:accessibilityFlags="flagReportViewIds|flagRetrieveInteractiveWindows"
|
||||
android:canRetrieveWindowContent="true"
|
||||
android:notificationTimeout="100"
|
||||
android:settingsActivity="" />
|
||||
@@ -0,0 +1,2 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<autofill-service xmlns:android="http://schemas.android.com/apk/res/android" />
|
||||
@@ -0,0 +1,81 @@
|
||||
package org.julianfamily.keepassgo;
|
||||
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.net.Uri;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.FileInputStream;
|
||||
import java.io.FileOutputStream;
|
||||
import java.io.IOException;
|
||||
|
||||
public final class AndroidShare {
|
||||
private static final String DEFAULT_TITLE = "KeePassGO Vault";
|
||||
|
||||
private AndroidShare() {
|
||||
}
|
||||
|
||||
public static void shareVault(Context context, String path, String title) throws IOException {
|
||||
File source = new File(path);
|
||||
if (!source.isFile()) {
|
||||
throw new IOException("vault file not found: " + path);
|
||||
}
|
||||
File shared = copyToSharedExport(context, source);
|
||||
Uri uri = SharedVaultProvider.uriForFile(shared.getName());
|
||||
|
||||
Intent send = new Intent(Intent.ACTION_SEND);
|
||||
send.setType("application/x-keepass2");
|
||||
send.putExtra(Intent.EXTRA_STREAM, uri);
|
||||
send.putExtra(Intent.EXTRA_TITLE, sanitizeTitle(title, source.getName()));
|
||||
send.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION);
|
||||
|
||||
Intent chooser = Intent.createChooser(send, "Share vault");
|
||||
chooser.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
|
||||
chooser.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION);
|
||||
context.startActivity(chooser);
|
||||
}
|
||||
|
||||
static File sharedDirectory(Context context) {
|
||||
return new File(new File(context.getFilesDir(), "keepassgo"), "shared");
|
||||
}
|
||||
|
||||
private static File copyToSharedExport(Context context, File source) throws IOException {
|
||||
File dir = sharedDirectory(context);
|
||||
if (!dir.exists() && !dir.mkdirs()) {
|
||||
throw new IOException("failed to create " + dir.getAbsolutePath());
|
||||
}
|
||||
File target = new File(dir, sanitizeFilename(source.getName()));
|
||||
try (FileInputStream in = new FileInputStream(source);
|
||||
FileOutputStream out = new FileOutputStream(target, false)) {
|
||||
byte[] buffer = new byte[8192];
|
||||
int count;
|
||||
while ((count = in.read(buffer)) >= 0) {
|
||||
out.write(buffer, 0, count);
|
||||
}
|
||||
}
|
||||
return target;
|
||||
}
|
||||
|
||||
private static String sanitizeFilename(String name) {
|
||||
String trimmed = name == null ? "" : name.trim();
|
||||
if (trimmed.isEmpty()) {
|
||||
return "shared-vault.kdbx";
|
||||
}
|
||||
if (trimmed.endsWith(".kdbx")) {
|
||||
return trimmed;
|
||||
}
|
||||
return trimmed + ".kdbx";
|
||||
}
|
||||
|
||||
private static String sanitizeTitle(String title, String fallbackName) {
|
||||
String trimmed = title == null ? "" : title.trim();
|
||||
if (!trimmed.isEmpty()) {
|
||||
return trimmed;
|
||||
}
|
||||
String fallback = fallbackName == null ? "" : fallbackName.trim();
|
||||
if (!fallback.isEmpty()) {
|
||||
return fallback;
|
||||
}
|
||||
return DEFAULT_TITLE;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,334 @@
|
||||
package org.julianfamily.keepassgo;
|
||||
|
||||
import android.content.Context;
|
||||
import android.util.JsonReader;
|
||||
import android.util.Log;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.FileInputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStreamReader;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
|
||||
final class AutofillCacheStore {
|
||||
private static final String TAG = "KeePassGOAutofill";
|
||||
|
||||
private AutofillCacheStore() {
|
||||
}
|
||||
|
||||
static Entry findBestMatch(Context context, String webDomain) {
|
||||
File cacheFile = findCacheFile(context);
|
||||
if (cacheFile == null) {
|
||||
Log.i(TAG, "autofill cache file not found");
|
||||
return null;
|
||||
}
|
||||
List<Entry> entries;
|
||||
try {
|
||||
entries = readEntries(cacheFile);
|
||||
} catch (IOException err) {
|
||||
Log.e(TAG, "failed to read autofill cache", err);
|
||||
return null;
|
||||
}
|
||||
if (entries.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
NormalizedTarget target = normalizeURL(webDomain);
|
||||
if (target.host.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
List<Entry> exactHost = new ArrayList<>();
|
||||
List<Entry> parentHost = new ArrayList<>();
|
||||
for (Entry entry : entries) {
|
||||
if (entryMatchesHost(entry, target.host)) {
|
||||
exactHost.add(entry);
|
||||
continue;
|
||||
}
|
||||
if (entryMatchesParentHost(entry, target.host)) {
|
||||
parentHost.add(entry);
|
||||
}
|
||||
}
|
||||
Entry matched = chooseEntry(target, exactHost);
|
||||
if (matched != null) {
|
||||
return matched;
|
||||
}
|
||||
return chooseEntry(target, parentHost);
|
||||
}
|
||||
|
||||
private static File findCacheFile(Context context) {
|
||||
List<File> candidates = new ArrayList<>();
|
||||
File filesDir = context.getFilesDir();
|
||||
if (filesDir != null) {
|
||||
candidates.add(new File(filesDir, "keepassgo/autofill-cache.json"));
|
||||
candidates.add(new File(filesDir, ".config/keepassgo/autofill-cache.json"));
|
||||
candidates.add(new File(filesDir, "config/keepassgo/autofill-cache.json"));
|
||||
}
|
||||
File baseDir = context.getDataDir();
|
||||
if (baseDir != null) {
|
||||
candidates.add(new File(baseDir, "files/keepassgo/autofill-cache.json"));
|
||||
candidates.add(new File(baseDir, "files/.config/keepassgo/autofill-cache.json"));
|
||||
candidates.add(new File(baseDir, "files/config/keepassgo/autofill-cache.json"));
|
||||
}
|
||||
for (File candidate : candidates) {
|
||||
if (candidate.isFile()) {
|
||||
Log.i(TAG, "using autofill cache " + candidate.getAbsolutePath());
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
Log.i(TAG, "no autofill cache file in " + candidates);
|
||||
return null;
|
||||
}
|
||||
|
||||
private static List<Entry> readEntries(File cacheFile) throws IOException {
|
||||
List<Entry> entries = new ArrayList<>();
|
||||
try (JsonReader reader = new JsonReader(new InputStreamReader(new FileInputStream(cacheFile), StandardCharsets.UTF_8))) {
|
||||
reader.beginObject();
|
||||
while (reader.hasNext()) {
|
||||
String name = reader.nextName();
|
||||
if ("entries".equals(name)) {
|
||||
reader.beginArray();
|
||||
while (reader.hasNext()) {
|
||||
entries.add(readEntry(reader));
|
||||
}
|
||||
reader.endArray();
|
||||
} else {
|
||||
reader.skipValue();
|
||||
}
|
||||
}
|
||||
reader.endObject();
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
private static Entry readEntry(JsonReader reader) throws IOException {
|
||||
String title = "";
|
||||
String username = "";
|
||||
String password = "";
|
||||
String host = "";
|
||||
String url = "";
|
||||
List<String> targets = new ArrayList<>();
|
||||
reader.beginObject();
|
||||
while (reader.hasNext()) {
|
||||
String name = reader.nextName();
|
||||
switch (name) {
|
||||
case "title":
|
||||
title = nextString(reader);
|
||||
break;
|
||||
case "username":
|
||||
username = nextString(reader);
|
||||
break;
|
||||
case "password":
|
||||
password = nextString(reader);
|
||||
break;
|
||||
case "url":
|
||||
url = nextString(reader);
|
||||
break;
|
||||
case "host":
|
||||
host = normalizeHost(nextString(reader));
|
||||
break;
|
||||
case "targets":
|
||||
reader.beginArray();
|
||||
while (reader.hasNext()) {
|
||||
targets.add(nextString(reader));
|
||||
}
|
||||
reader.endArray();
|
||||
break;
|
||||
default:
|
||||
reader.skipValue();
|
||||
break;
|
||||
}
|
||||
}
|
||||
reader.endObject();
|
||||
return new Entry(title, username, password, host, url, targets);
|
||||
}
|
||||
|
||||
private static String nextString(JsonReader reader) throws IOException {
|
||||
if (reader.peek() == android.util.JsonToken.NULL) {
|
||||
reader.nextNull();
|
||||
return "";
|
||||
}
|
||||
return reader.nextString();
|
||||
}
|
||||
|
||||
private static String normalizeHost(String raw) {
|
||||
return normalizeURL(raw).host;
|
||||
}
|
||||
|
||||
private static NormalizedTarget normalizeURL(String raw) {
|
||||
if (raw == null) {
|
||||
return new NormalizedTarget("", "", "");
|
||||
}
|
||||
String value = raw.trim().toLowerCase(Locale.US);
|
||||
if (value.startsWith("http://")) {
|
||||
value = value.substring("http://".length());
|
||||
} else if (value.startsWith("https://")) {
|
||||
value = value.substring("https://".length());
|
||||
}
|
||||
int slash = value.indexOf('/');
|
||||
if (slash >= 0) {
|
||||
value = value.substring(0, slash);
|
||||
}
|
||||
int colon = value.indexOf(':');
|
||||
if (colon >= 0) {
|
||||
value = value.substring(0, colon);
|
||||
}
|
||||
String host = value;
|
||||
String path = "/";
|
||||
int schemeSep = raw.indexOf("://");
|
||||
String original = raw.trim();
|
||||
if (schemeSep < 0) {
|
||||
original = "https://" + original;
|
||||
}
|
||||
try {
|
||||
java.net.URI uri = java.net.URI.create(original);
|
||||
if (uri.getHost() != null) {
|
||||
host = uri.getHost().toLowerCase(Locale.US);
|
||||
}
|
||||
path = cleanPath(uri.getPath());
|
||||
} catch (IllegalArgumentException ignored) {
|
||||
path = "/";
|
||||
}
|
||||
return new NormalizedTarget(host, path, host + path);
|
||||
}
|
||||
|
||||
private static String cleanPath(String raw) {
|
||||
if (raw == null || raw.trim().isEmpty() || "/".equals(raw.trim())) {
|
||||
return "/";
|
||||
}
|
||||
String value = raw.trim();
|
||||
while (value.endsWith("/") && value.length() > 1) {
|
||||
value = value.substring(0, value.length() - 1);
|
||||
}
|
||||
if (!value.startsWith("/")) {
|
||||
value = "/" + value;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
private static Entry chooseEntry(NormalizedTarget target, List<Entry> entries) {
|
||||
if (entries.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
if (entries.size() == 1) {
|
||||
return entries.get(0);
|
||||
}
|
||||
|
||||
List<Entry> exact = new ArrayList<>();
|
||||
List<Entry> prefix = new ArrayList<>();
|
||||
int bestPrefixLen = -1;
|
||||
for (Entry entry : entries) {
|
||||
MatchQuality quality = bestTargetMatch(entry, target);
|
||||
if (quality.exact) {
|
||||
exact.add(entry);
|
||||
continue;
|
||||
}
|
||||
if (quality.prefixLength <= 0) {
|
||||
continue;
|
||||
}
|
||||
if (quality.prefixLength > bestPrefixLen) {
|
||||
prefix.clear();
|
||||
prefix.add(entry);
|
||||
bestPrefixLen = quality.prefixLength;
|
||||
} else if (quality.prefixLength == bestPrefixLen) {
|
||||
prefix.add(entry);
|
||||
}
|
||||
}
|
||||
if (exact.size() == 1) {
|
||||
return exact.get(0);
|
||||
}
|
||||
if (exact.size() > 1 || prefix.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return prefix.size() == 1 ? prefix.get(0) : null;
|
||||
}
|
||||
|
||||
private static boolean entryMatchesHost(Entry entry, String host) {
|
||||
for (NormalizedTarget target : entryTargets(entry)) {
|
||||
if (target.host.equals(host)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private static boolean entryMatchesParentHost(Entry entry, String host) {
|
||||
for (NormalizedTarget target : entryTargets(entry)) {
|
||||
if (!target.host.isEmpty() && host.endsWith("." + target.host)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private static List<NormalizedTarget> entryTargets(Entry entry) {
|
||||
List<String> rawTargets = entry.targets;
|
||||
if (rawTargets.isEmpty()) {
|
||||
rawTargets = new ArrayList<>();
|
||||
rawTargets.add(entry.url);
|
||||
}
|
||||
List<NormalizedTarget> targets = new ArrayList<>();
|
||||
for (String rawTarget : rawTargets) {
|
||||
NormalizedTarget target = normalizeURL(rawTarget);
|
||||
if (!target.host.isEmpty()) {
|
||||
targets.add(target);
|
||||
}
|
||||
}
|
||||
return targets;
|
||||
}
|
||||
|
||||
private static MatchQuality bestTargetMatch(Entry entry, NormalizedTarget target) {
|
||||
int bestPrefixLen = -1;
|
||||
for (NormalizedTarget entryTarget : entryTargets(entry)) {
|
||||
if (entryTarget.url.equals(target.url)) {
|
||||
return new MatchQuality(true, 0);
|
||||
}
|
||||
if (!"/".equals(entryTarget.path) && target.path.startsWith(entryTarget.path)) {
|
||||
bestPrefixLen = Math.max(bestPrefixLen, entryTarget.path.length());
|
||||
}
|
||||
}
|
||||
return new MatchQuality(false, bestPrefixLen);
|
||||
}
|
||||
|
||||
static final class Entry {
|
||||
final String title;
|
||||
final String username;
|
||||
final String password;
|
||||
final String host;
|
||||
final String url;
|
||||
final List<String> targets;
|
||||
|
||||
Entry(String title, String username, String password, String host, String url, List<String> targets) {
|
||||
this.title = title;
|
||||
this.username = username;
|
||||
this.password = password;
|
||||
this.host = host;
|
||||
this.url = url;
|
||||
this.targets = new ArrayList<>(targets);
|
||||
}
|
||||
}
|
||||
|
||||
private static final class MatchQuality {
|
||||
final boolean exact;
|
||||
final int prefixLength;
|
||||
|
||||
MatchQuality(boolean exact, int prefixLength) {
|
||||
this.exact = exact;
|
||||
this.prefixLength = prefixLength;
|
||||
}
|
||||
}
|
||||
|
||||
private static final class NormalizedTarget {
|
||||
final String host;
|
||||
final String path;
|
||||
final String url;
|
||||
|
||||
NormalizedTarget(String host, String path, String url) {
|
||||
this.host = host;
|
||||
this.path = path;
|
||||
this.url = url;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
package org.julianfamily.keepassgo;
|
||||
|
||||
import android.accessibilityservice.AccessibilityService;
|
||||
import android.os.Build;
|
||||
import android.os.Bundle;
|
||||
import android.util.Log;
|
||||
import android.view.accessibility.AccessibilityEvent;
|
||||
import android.view.accessibility.AccessibilityNodeInfo;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
public final class KeePassGOAccessibilityService extends AccessibilityService {
|
||||
private static final String TAG = "KeePassGOA11y";
|
||||
|
||||
private String lastFilledSignature = "";
|
||||
|
||||
@Override
|
||||
public void onAccessibilityEvent(AccessibilityEvent event) {
|
||||
try {
|
||||
AccessibilityNodeInfo root = getRootInActiveWindow();
|
||||
if (root == null) {
|
||||
return;
|
||||
}
|
||||
CharSequence packageName = root.getPackageName();
|
||||
if (packageName == null || !"com.android.chrome".contentEquals(packageName)) {
|
||||
return;
|
||||
}
|
||||
|
||||
ChromeForm form = inspectChrome(root);
|
||||
if (form == null || form.passwordField == null) {
|
||||
return;
|
||||
}
|
||||
AutofillCacheStore.Entry entry = AutofillCacheStore.findBestMatch(this, form.url);
|
||||
if (entry == null) {
|
||||
Log.i(TAG, "no accessibility-fill match for " + form.url);
|
||||
return;
|
||||
}
|
||||
|
||||
String signature = form.url + "|" + entry.username + "|" + nodeKey(form.passwordField);
|
||||
if (signature.equals(lastFilledSignature)) {
|
||||
return;
|
||||
}
|
||||
|
||||
boolean filled = false;
|
||||
if (form.usernameField != null) {
|
||||
filled |= setNodeText(form.usernameField, entry.username);
|
||||
}
|
||||
filled |= setNodeText(form.passwordField, entry.password);
|
||||
if (filled) {
|
||||
lastFilledSignature = signature;
|
||||
Log.i(TAG, "filled login form for " + form.url + " with " + entry.username);
|
||||
}
|
||||
} catch (Exception err) {
|
||||
Log.e(TAG, "accessibility fill failed", err);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onInterrupt() {
|
||||
Log.i(TAG, "accessibility service interrupted");
|
||||
}
|
||||
|
||||
private static ChromeForm inspectChrome(AccessibilityNodeInfo root) {
|
||||
List<AccessibilityNodeInfo> editables = new ArrayList<>();
|
||||
ChromeForm form = new ChromeForm();
|
||||
walk(root, editables, form);
|
||||
if (form.url.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
if (form.passwordField == null) {
|
||||
for (AccessibilityNodeInfo node : editables) {
|
||||
if (isPasswordNode(node)) {
|
||||
form.passwordField = node;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (form.usernameField == null && form.passwordField != null) {
|
||||
for (AccessibilityNodeInfo node : editables) {
|
||||
if (node.equals(form.passwordField)) {
|
||||
continue;
|
||||
}
|
||||
if (isUsernameNode(node)) {
|
||||
form.usernameField = node;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (form.usernameField == null) {
|
||||
for (AccessibilityNodeInfo node : editables) {
|
||||
if (node.equals(form.passwordField)) {
|
||||
continue;
|
||||
}
|
||||
form.usernameField = node;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
return form;
|
||||
}
|
||||
|
||||
private static void walk(
|
||||
AccessibilityNodeInfo node,
|
||||
List<AccessibilityNodeInfo> editables,
|
||||
ChromeForm form
|
||||
) {
|
||||
if (node == null) {
|
||||
return;
|
||||
}
|
||||
CharSequence viewID = node.getViewIdResourceName();
|
||||
if (viewID != null && viewID.toString().endsWith(":id/url_bar")) {
|
||||
CharSequence text = node.getText();
|
||||
if (text != null) {
|
||||
form.url = text.toString().trim();
|
||||
}
|
||||
}
|
||||
if (node.isEditable()) {
|
||||
editables.add(node);
|
||||
if (form.passwordField == null && isPasswordNode(node)) {
|
||||
form.passwordField = node;
|
||||
} else if (form.usernameField == null && isUsernameNode(node)) {
|
||||
form.usernameField = node;
|
||||
}
|
||||
}
|
||||
for (int i = 0; i < node.getChildCount(); i++) {
|
||||
AccessibilityNodeInfo child = node.getChild(i);
|
||||
if (child != null) {
|
||||
walk(child, editables, form);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean isPasswordNode(AccessibilityNodeInfo node) {
|
||||
if (node.isPassword()) {
|
||||
return true;
|
||||
}
|
||||
return nodeMatches(node, "password");
|
||||
}
|
||||
|
||||
private static boolean isUsernameNode(AccessibilityNodeInfo node) {
|
||||
if (isPasswordNode(node)) {
|
||||
return false;
|
||||
}
|
||||
return nodeMatches(node, "username")
|
||||
|| nodeMatches(node, "email")
|
||||
|| nodeMatches(node, "login")
|
||||
|| nodeMatches(node, "user");
|
||||
}
|
||||
|
||||
private static boolean nodeMatches(AccessibilityNodeInfo node, String term) {
|
||||
String lowerTerm = term.toLowerCase();
|
||||
return containsLower(node.getHintText(), lowerTerm)
|
||||
|| containsLower(node.getText(), lowerTerm)
|
||||
|| containsLower(node.getContentDescription(), lowerTerm)
|
||||
|| containsLower(node.getViewIdResourceName(), lowerTerm)
|
||||
|| containsLower(node.getPaneTitle(), lowerTerm);
|
||||
}
|
||||
|
||||
private static boolean containsLower(CharSequence value, String term) {
|
||||
return value != null && value.toString().toLowerCase().contains(term);
|
||||
}
|
||||
|
||||
private static boolean setNodeText(AccessibilityNodeInfo node, String value) {
|
||||
if (node == null || !node.isEditable() || value == null) {
|
||||
return false;
|
||||
}
|
||||
Bundle args = new Bundle();
|
||||
args.putCharSequence(
|
||||
AccessibilityNodeInfo.ACTION_ARGUMENT_SET_TEXT_CHARSEQUENCE,
|
||||
value
|
||||
);
|
||||
return node.performAction(AccessibilityNodeInfo.ACTION_SET_TEXT, args);
|
||||
}
|
||||
|
||||
private static String nodeKey(AccessibilityNodeInfo node) {
|
||||
CharSequence viewID = node.getViewIdResourceName();
|
||||
if (viewID != null) {
|
||||
return viewID.toString();
|
||||
}
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
return String.valueOf(node.getUniqueId());
|
||||
}
|
||||
CharSequence hint = node.getHintText();
|
||||
if (hint != null) {
|
||||
return hint.toString();
|
||||
}
|
||||
return String.valueOf(node.hashCode());
|
||||
}
|
||||
|
||||
private static final class ChromeForm {
|
||||
String url = "";
|
||||
AccessibilityNodeInfo usernameField;
|
||||
AccessibilityNodeInfo passwordField;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
package org.julianfamily.keepassgo;
|
||||
|
||||
import android.app.assist.AssistStructure;
|
||||
import android.os.CancellationSignal;
|
||||
import android.service.autofill.AutofillService;
|
||||
import android.service.autofill.Dataset;
|
||||
import android.service.autofill.FillCallback;
|
||||
import android.service.autofill.FillContext;
|
||||
import android.service.autofill.FillRequest;
|
||||
import android.service.autofill.FillResponse;
|
||||
import android.service.autofill.SaveCallback;
|
||||
import android.service.autofill.SaveRequest;
|
||||
import android.text.InputType;
|
||||
import android.util.Log;
|
||||
import android.view.View;
|
||||
import android.view.autofill.AutofillId;
|
||||
import android.view.autofill.AutofillValue;
|
||||
import android.widget.RemoteViews;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public final class KeePassGOAutofillService extends AutofillService {
|
||||
private static final String TAG = "KeePassGOAutofill";
|
||||
private static final String APP_SCHEME = "androidapp://";
|
||||
|
||||
@Override
|
||||
public void onConnected() {
|
||||
super.onConnected();
|
||||
Log.i(TAG, "service connected");
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onDisconnected() {
|
||||
Log.i(TAG, "service disconnected");
|
||||
super.onDisconnected();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onFillRequest(
|
||||
FillRequest request,
|
||||
CancellationSignal cancellationSignal,
|
||||
FillCallback callback
|
||||
) {
|
||||
try {
|
||||
Log.i(TAG, "fill request flags=" + request.getFlags());
|
||||
List<FillContext> contexts = request.getFillContexts();
|
||||
if (contexts.isEmpty()) {
|
||||
Log.i(TAG, "fill request had no contexts");
|
||||
callback.onSuccess(null);
|
||||
return;
|
||||
}
|
||||
|
||||
AssistStructure structure = contexts.get(contexts.size() - 1).getStructure();
|
||||
ParsedFields fields = new ParsedFields();
|
||||
ParsedTarget target = parseWindow(structure, fields);
|
||||
Log.i(
|
||||
TAG,
|
||||
"parsed target=" + target.matchTarget
|
||||
+ " package=" + target.packageName
|
||||
+ " usernameId=" + fields.usernameId
|
||||
+ " passwordId=" + fields.passwordId
|
||||
);
|
||||
if (fields.passwordId == null) {
|
||||
Log.i(TAG, "no password field found");
|
||||
callback.onSuccess(null);
|
||||
return;
|
||||
}
|
||||
|
||||
AutofillCacheStore.Entry entry = AutofillCacheStore.findBestMatch(this, target.matchTarget);
|
||||
if (entry == null) {
|
||||
Log.i(TAG, "no autofill cache match");
|
||||
callback.onSuccess(null);
|
||||
return;
|
||||
}
|
||||
Log.i(TAG, "matched entry title=" + entry.title + " user=" + entry.username + " host=" + entry.host);
|
||||
|
||||
RemoteViews presentation = new RemoteViews(getPackageName(), android.R.layout.simple_list_item_1);
|
||||
presentation.setTextViewText(
|
||||
android.R.id.text1,
|
||||
entry.title + " (" + entry.username + ")"
|
||||
);
|
||||
|
||||
Dataset.Builder dataset = new Dataset.Builder(presentation);
|
||||
if (fields.usernameId != null) {
|
||||
dataset.setValue(fields.usernameId, AutofillValue.forText(entry.username));
|
||||
}
|
||||
dataset.setValue(fields.passwordId, AutofillValue.forText(entry.password));
|
||||
|
||||
FillResponse response = new FillResponse.Builder()
|
||||
.addDataset(dataset.build())
|
||||
.build();
|
||||
Log.i(TAG, "returning dataset");
|
||||
callback.onSuccess(response);
|
||||
} catch (Exception err) {
|
||||
Log.e(TAG, "fill request failed", err);
|
||||
callback.onFailure(err.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onSaveRequest(SaveRequest request, SaveCallback callback) {
|
||||
Log.i(TAG, "save request");
|
||||
callback.onSuccess();
|
||||
}
|
||||
|
||||
private static ParsedTarget parseWindow(AssistStructure structure, ParsedFields fields) {
|
||||
String domain = "";
|
||||
final int windowCount = structure.getWindowNodeCount();
|
||||
for (int i = 0; i < windowCount; i++) {
|
||||
AssistStructure.ViewNode root = structure.getWindowNodeAt(i).getRootViewNode();
|
||||
String next = parseNode(root, fields);
|
||||
if (!next.isEmpty()) {
|
||||
domain = next;
|
||||
}
|
||||
}
|
||||
String packageName = "";
|
||||
if (structure.getActivityComponent() != null) {
|
||||
packageName = structure.getActivityComponent().getPackageName();
|
||||
}
|
||||
if (!domain.isEmpty()) {
|
||||
return new ParsedTarget(domain, packageName);
|
||||
}
|
||||
if (packageName != null && !packageName.isEmpty()) {
|
||||
return new ParsedTarget(APP_SCHEME + packageName, packageName);
|
||||
}
|
||||
return new ParsedTarget("", "");
|
||||
}
|
||||
|
||||
private static String parseNode(AssistStructure.ViewNode node, ParsedFields fields) {
|
||||
String domain = "";
|
||||
if (node.getWebDomain() != null) {
|
||||
domain = node.getWebDomain();
|
||||
}
|
||||
|
||||
AutofillId id = node.getAutofillId();
|
||||
if (id != null) {
|
||||
if (fields.passwordId == null && isPasswordNode(node)) {
|
||||
fields.passwordId = id;
|
||||
} else if (fields.usernameId == null && isUsernameNode(node)) {
|
||||
fields.usernameId = id;
|
||||
}
|
||||
}
|
||||
|
||||
for (int i = 0; i < node.getChildCount(); i++) {
|
||||
String childDomain = parseNode(node.getChildAt(i), fields);
|
||||
if (!childDomain.isEmpty()) {
|
||||
domain = childDomain;
|
||||
}
|
||||
}
|
||||
return domain;
|
||||
}
|
||||
|
||||
private static boolean isPasswordNode(AssistStructure.ViewNode node) {
|
||||
int inputType = node.getInputType();
|
||||
int variation = inputType & InputType.TYPE_MASK_VARIATION;
|
||||
if (variation == InputType.TYPE_TEXT_VARIATION_PASSWORD
|
||||
|| variation == InputType.TYPE_TEXT_VARIATION_VISIBLE_PASSWORD
|
||||
|| variation == InputType.TYPE_TEXT_VARIATION_WEB_PASSWORD) {
|
||||
return true;
|
||||
}
|
||||
return nodeMatches(node, "password");
|
||||
}
|
||||
|
||||
private static boolean isUsernameNode(AssistStructure.ViewNode node) {
|
||||
int autofillType = node.getAutofillType();
|
||||
if (autofillType != View.AUTOFILL_TYPE_TEXT) {
|
||||
return false;
|
||||
}
|
||||
if (isPasswordNode(node)) {
|
||||
return false;
|
||||
}
|
||||
return nodeMatches(node, "username")
|
||||
|| nodeMatches(node, "email")
|
||||
|| nodeMatches(node, "login")
|
||||
|| nodeMatches(node, "user");
|
||||
}
|
||||
|
||||
private static boolean nodeMatches(AssistStructure.ViewNode node, String term) {
|
||||
String lowerTerm = term.toLowerCase();
|
||||
String[] hints = node.getAutofillHints();
|
||||
if (hints != null) {
|
||||
for (String hint : hints) {
|
||||
if (hint != null && hint.toLowerCase().contains(lowerTerm)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (containsLower(node.getHint(), lowerTerm)
|
||||
|| containsLower(node.getIdEntry(), lowerTerm)
|
||||
|| containsLower(node.getText(), lowerTerm)
|
||||
|| containsLower(node.getContentDescription(), lowerTerm)) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private static boolean containsLower(CharSequence value, String term) {
|
||||
return value != null && value.toString().toLowerCase().contains(term);
|
||||
}
|
||||
|
||||
private static final class ParsedFields {
|
||||
AutofillId usernameId;
|
||||
AutofillId passwordId;
|
||||
}
|
||||
|
||||
private static final class ParsedTarget {
|
||||
final String matchTarget;
|
||||
final String packageName;
|
||||
|
||||
ParsedTarget(String matchTarget, String packageName) {
|
||||
this.matchTarget = matchTarget;
|
||||
this.packageName = packageName;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
package org.julianfamily.keepassgo;
|
||||
|
||||
import android.app.Activity;
|
||||
import android.content.Intent;
|
||||
import android.database.Cursor;
|
||||
import android.net.Uri;
|
||||
import android.os.Bundle;
|
||||
import android.provider.OpenableColumns;
|
||||
import android.util.Log;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.FileOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
|
||||
public final class SharedVaultImportActivity extends Activity {
|
||||
private static final String TAG = "KeePassGOImport";
|
||||
private static final String DEFAULT_NAME = "shared-vault.kdbx";
|
||||
|
||||
@Override
|
||||
protected void onCreate(Bundle state) {
|
||||
super.onCreate(state);
|
||||
handleIntent(getIntent());
|
||||
launchMainActivity();
|
||||
finish();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onNewIntent(Intent intent) {
|
||||
super.onNewIntent(intent);
|
||||
setIntent(intent);
|
||||
handleIntent(intent);
|
||||
launchMainActivity();
|
||||
finish();
|
||||
}
|
||||
|
||||
private void handleIntent(Intent intent) {
|
||||
Uri uri = resolveSharedUri(intent);
|
||||
if (uri == null) {
|
||||
Log.i(TAG, "no shared vault URI on intent");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
persistPendingImport(uri);
|
||||
Log.i(TAG, "queued shared vault import from " + uri);
|
||||
} catch (IOException | RuntimeException err) {
|
||||
Log.e(TAG, "failed to queue shared vault import", err);
|
||||
}
|
||||
}
|
||||
|
||||
private Uri resolveSharedUri(Intent intent) {
|
||||
if (intent == null) {
|
||||
return null;
|
||||
}
|
||||
String action = intent.getAction();
|
||||
if (Intent.ACTION_SEND.equals(action)) {
|
||||
return intent.getParcelableExtra(Intent.EXTRA_STREAM);
|
||||
}
|
||||
if (Intent.ACTION_VIEW.equals(action)) {
|
||||
return intent.getData();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private void persistPendingImport(Uri uri) throws IOException {
|
||||
File dir = new File(getFilesDir(), "keepassgo");
|
||||
if (!dir.exists() && !dir.mkdirs()) {
|
||||
throw new IOException("failed to create " + dir.getAbsolutePath());
|
||||
}
|
||||
File pendingFile = new File(dir, "pending-shared-vault.kdbx");
|
||||
try (InputStream in = getContentResolver().openInputStream(uri)) {
|
||||
if (in == null) {
|
||||
throw new IOException("failed to open shared vault stream");
|
||||
}
|
||||
try (FileOutputStream out = new FileOutputStream(pendingFile, false)) {
|
||||
byte[] buffer = new byte[8192];
|
||||
int count;
|
||||
while ((count = in.read(buffer)) >= 0) {
|
||||
out.write(buffer, 0, count);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
File nameFile = new File(dir, "pending-shared-vault-name.txt");
|
||||
try (FileOutputStream out = new FileOutputStream(nameFile, false)) {
|
||||
out.write(resolveDisplayName(uri).getBytes(StandardCharsets.UTF_8));
|
||||
}
|
||||
}
|
||||
|
||||
private String resolveDisplayName(Uri uri) {
|
||||
String displayName = queryDisplayName(uri);
|
||||
if (!displayName.isEmpty()) {
|
||||
return displayName;
|
||||
}
|
||||
String lastSegment = uri.getLastPathSegment();
|
||||
if (lastSegment != null && !lastSegment.trim().isEmpty()) {
|
||||
return lastSegment.trim();
|
||||
}
|
||||
return DEFAULT_NAME;
|
||||
}
|
||||
|
||||
private String queryDisplayName(Uri uri) {
|
||||
Cursor cursor = null;
|
||||
try {
|
||||
cursor = getContentResolver().query(uri, new String[]{OpenableColumns.DISPLAY_NAME}, null, null, null);
|
||||
if (cursor != null && cursor.moveToFirst()) {
|
||||
int index = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME);
|
||||
if (index >= 0) {
|
||||
String value = cursor.getString(index);
|
||||
if (value != null) {
|
||||
return value.trim();
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (RuntimeException err) {
|
||||
Log.w(TAG, "failed to query display name", err);
|
||||
} finally {
|
||||
if (cursor != null) {
|
||||
cursor.close();
|
||||
}
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
private void launchMainActivity() {
|
||||
Intent launch = new Intent();
|
||||
launch.setClassName(this, "org.gioui.GioActivity");
|
||||
startActivity(launch);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
package org.julianfamily.keepassgo;
|
||||
|
||||
import android.content.ContentProvider;
|
||||
import android.content.ContentValues;
|
||||
import android.database.Cursor;
|
||||
import android.database.MatrixCursor;
|
||||
import android.net.Uri;
|
||||
import android.os.ParcelFileDescriptor;
|
||||
import android.provider.OpenableColumns;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.FileNotFoundException;
|
||||
|
||||
public final class SharedVaultProvider extends ContentProvider {
|
||||
private static final String AUTHORITY = "org.julianfamily.keepassgo.share";
|
||||
|
||||
@Override
|
||||
public boolean onCreate() {
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Cursor query(Uri uri, String[] projection, String selection, String[] selectionArgs, String sortOrder) {
|
||||
File file = resolveSharedFile(uri);
|
||||
String[] columns = projection;
|
||||
if (columns == null || columns.length == 0) {
|
||||
columns = new String[]{OpenableColumns.DISPLAY_NAME, OpenableColumns.SIZE};
|
||||
}
|
||||
MatrixCursor cursor = new MatrixCursor(columns, 1);
|
||||
Object[] row = new Object[columns.length];
|
||||
for (int i = 0; i < columns.length; i++) {
|
||||
switch (columns[i]) {
|
||||
case OpenableColumns.DISPLAY_NAME:
|
||||
row[i] = file.getName();
|
||||
break;
|
||||
case OpenableColumns.SIZE:
|
||||
row[i] = file.length();
|
||||
break;
|
||||
default:
|
||||
row[i] = null;
|
||||
break;
|
||||
}
|
||||
}
|
||||
cursor.addRow(row);
|
||||
return cursor;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getType(Uri uri) {
|
||||
return "application/x-keepass2";
|
||||
}
|
||||
|
||||
@Override
|
||||
public Uri insert(Uri uri, ContentValues values) {
|
||||
throw new UnsupportedOperationException("insert is not supported");
|
||||
}
|
||||
|
||||
@Override
|
||||
public int delete(Uri uri, String selection, String[] selectionArgs) {
|
||||
throw new UnsupportedOperationException("delete is not supported");
|
||||
}
|
||||
|
||||
@Override
|
||||
public int update(Uri uri, ContentValues values, String selection, String[] selectionArgs) {
|
||||
throw new UnsupportedOperationException("update is not supported");
|
||||
}
|
||||
|
||||
@Override
|
||||
public ParcelFileDescriptor openFile(Uri uri, String mode) throws FileNotFoundException {
|
||||
File file = resolveSharedFile(uri);
|
||||
return ParcelFileDescriptor.open(file, ParcelFileDescriptor.MODE_READ_ONLY);
|
||||
}
|
||||
|
||||
static Uri uriForFile(String name) {
|
||||
return new Uri.Builder()
|
||||
.scheme("content")
|
||||
.authority(AUTHORITY)
|
||||
.appendPath(name)
|
||||
.build();
|
||||
}
|
||||
|
||||
private File resolveSharedFile(Uri uri) {
|
||||
if (getContext() == null) {
|
||||
throw new IllegalStateException("provider context is unavailable");
|
||||
}
|
||||
String name = sanitizeFilename(uri.getLastPathSegment());
|
||||
return new File(AndroidShare.sharedDirectory(getContext()), name);
|
||||
}
|
||||
|
||||
private static String sanitizeFilename(String name) {
|
||||
if (name == null) {
|
||||
return "shared-vault.kdbx";
|
||||
}
|
||||
String trimmed = name.trim();
|
||||
if (trimmed.isEmpty()) {
|
||||
return "shared-vault.kdbx";
|
||||
}
|
||||
return new File(trimmed).getName();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
package buildapk
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
const (
|
||||
DefaultSDKRoot = "/opt/android-sdk"
|
||||
DefaultNDKRoot = "/opt/android-ndk"
|
||||
DefaultJavaHome = "/usr/lib/jvm/java-25-openjdk"
|
||||
DefaultAppID = "org.julianfamily.keepassgo"
|
||||
DefaultAPKOut = "build/keepassgo.apk"
|
||||
DefaultVersion = "0.1.0.1"
|
||||
DefaultLdflags = "-X git.julianfamily.org/keepassgo/internal/appui.appVersion=dev"
|
||||
DefaultMinSDK = "28"
|
||||
DefaultTargetSDK = "35"
|
||||
DefaultIconPath = "internal/assets/keepassgo-icon.png"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
SDKRoot string
|
||||
NDKRoot string
|
||||
JavaHome string
|
||||
AppID string
|
||||
APKOut string
|
||||
Version string
|
||||
Ldflags string
|
||||
MinSDK string
|
||||
TargetSDK string
|
||||
IconPath string
|
||||
}
|
||||
|
||||
func DefaultConfig() Config {
|
||||
return Config{
|
||||
SDKRoot: DefaultSDKRoot,
|
||||
NDKRoot: DefaultNDKRoot,
|
||||
JavaHome: DefaultJavaHome,
|
||||
AppID: DefaultAppID,
|
||||
APKOut: DefaultAPKOut,
|
||||
Version: DefaultVersion,
|
||||
Ldflags: DefaultLdflags,
|
||||
MinSDK: DefaultMinSDK,
|
||||
TargetSDK: DefaultTargetSDK,
|
||||
IconPath: DefaultIconPath,
|
||||
}
|
||||
}
|
||||
|
||||
func (c Config) GogioArgs() []string {
|
||||
return []string{
|
||||
"-target", "android",
|
||||
"-buildmode", "exe",
|
||||
"-appid", c.AppID,
|
||||
"-ldflags", c.Ldflags,
|
||||
"-o", c.APKOut,
|
||||
"-version", c.Version,
|
||||
"-minsdk", c.MinSDK,
|
||||
"-targetsdk", c.TargetSDK,
|
||||
"-icon", c.IconPath,
|
||||
"./cmd/keepassgo",
|
||||
}
|
||||
}
|
||||
|
||||
func (c Config) Validate() error {
|
||||
if !isExecutable(filepath.Join(c.JavaHome, "bin", "java")) {
|
||||
return fmt.Errorf("JAVA_HOME must point to a JDK 17+ install")
|
||||
}
|
||||
if !isDir(c.SDKRoot) {
|
||||
return fmt.Errorf("ANDROID_SDK_ROOT must point to an Android SDK install")
|
||||
}
|
||||
if !isDir(c.NDKRoot) {
|
||||
return fmt.Errorf("ANDROID_NDK_ROOT must point to an Android NDK install")
|
||||
}
|
||||
if !isExecutable(filepath.Join(c.SDKRoot, "cmdline-tools", "latest", "bin", "sdkmanager")) {
|
||||
return fmt.Errorf("Android SDK cmdline-tools are missing")
|
||||
}
|
||||
if !isDir(filepath.Join(c.SDKRoot, "platforms", "android-"+c.TargetSDK)) {
|
||||
return fmt.Errorf("Android platform android-%s is missing", c.TargetSDK)
|
||||
}
|
||||
if !isDir(filepath.Join(c.SDKRoot, "build-tools")) {
|
||||
return fmt.Errorf("Android build-tools are missing")
|
||||
}
|
||||
if !isFile(c.IconPath) {
|
||||
return fmt.Errorf("Android icon asset is missing: %s", c.IconPath)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func isDir(path string) bool {
|
||||
info, err := os.Stat(path)
|
||||
return err == nil && info.IsDir()
|
||||
}
|
||||
|
||||
func isExecutable(path string) bool {
|
||||
info, err := os.Stat(path)
|
||||
if err != nil || info.IsDir() {
|
||||
return false
|
||||
}
|
||||
return info.Mode()&0o111 != 0
|
||||
}
|
||||
|
||||
func isFile(path string) bool {
|
||||
info, err := os.Stat(path)
|
||||
return err == nil && !info.IsDir()
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
package buildapk
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestDefaultConfigGogioArgs(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cfg := DefaultConfig()
|
||||
want := []string{
|
||||
"-target", "android",
|
||||
"-buildmode", "exe",
|
||||
"-appid", DefaultAppID,
|
||||
"-ldflags", DefaultLdflags,
|
||||
"-o", DefaultAPKOut,
|
||||
"-version", DefaultVersion,
|
||||
"-minsdk", DefaultMinSDK,
|
||||
"-targetsdk", DefaultTargetSDK,
|
||||
"-icon", DefaultIconPath,
|
||||
"./cmd/keepassgo",
|
||||
}
|
||||
|
||||
if got := cfg.GogioArgs(); !slices.Equal(got, want) {
|
||||
t.Fatalf("GogioArgs() = %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateAcceptsCompleteAndroidToolchainLayout(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
root := t.TempDir()
|
||||
sdkRoot := filepath.Join(root, "sdk")
|
||||
ndkRoot := filepath.Join(root, "ndk")
|
||||
javaHome := filepath.Join(root, "java")
|
||||
|
||||
mkdirAll(t, filepath.Join(javaHome, "bin"))
|
||||
mkdirAll(t, filepath.Join(sdkRoot, "cmdline-tools", "latest", "bin"))
|
||||
mkdirAll(t, filepath.Join(sdkRoot, "platforms", "android-"+DefaultTargetSDK))
|
||||
mkdirAll(t, filepath.Join(sdkRoot, "build-tools"))
|
||||
mkdirAll(t, ndkRoot)
|
||||
|
||||
makeExecutable(t, filepath.Join(javaHome, "bin", "java"))
|
||||
makeExecutable(t, filepath.Join(sdkRoot, "cmdline-tools", "latest", "bin", "sdkmanager"))
|
||||
|
||||
cfg := Config{
|
||||
SDKRoot: sdkRoot,
|
||||
NDKRoot: ndkRoot,
|
||||
JavaHome: javaHome,
|
||||
AppID: DefaultAppID,
|
||||
APKOut: DefaultAPKOut,
|
||||
Version: DefaultVersion,
|
||||
Ldflags: DefaultLdflags,
|
||||
MinSDK: DefaultMinSDK,
|
||||
TargetSDK: DefaultTargetSDK,
|
||||
IconPath: filepath.Join(root, "icon.png"),
|
||||
}
|
||||
|
||||
if err := os.WriteFile(cfg.IconPath, []byte("png"), 0o644); err != nil {
|
||||
t.Fatalf("WriteFile(%q) error = %v", cfg.IconPath, err)
|
||||
}
|
||||
|
||||
if err := cfg.Validate(); err != nil {
|
||||
t.Fatalf("Validate() error = %v, want nil", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRejectsMissingPrerequisites(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
root := t.TempDir()
|
||||
cfg := Config{
|
||||
SDKRoot: filepath.Join(root, "missing-sdk"),
|
||||
NDKRoot: filepath.Join(root, "missing-ndk"),
|
||||
JavaHome: filepath.Join(root, "missing-java"),
|
||||
AppID: DefaultAppID,
|
||||
APKOut: DefaultAPKOut,
|
||||
Version: DefaultVersion,
|
||||
Ldflags: DefaultLdflags,
|
||||
MinSDK: DefaultMinSDK,
|
||||
TargetSDK: DefaultTargetSDK,
|
||||
}
|
||||
|
||||
if err := cfg.Validate(); err == nil {
|
||||
t.Fatal("Validate() error = nil, want missing prerequisite error")
|
||||
}
|
||||
}
|
||||
|
||||
func mkdirAll(t *testing.T, path string) {
|
||||
t.Helper()
|
||||
if err := os.MkdirAll(path, 0o755); err != nil {
|
||||
t.Fatalf("MkdirAll(%q) error = %v", path, err)
|
||||
}
|
||||
}
|
||||
|
||||
func makeExecutable(t *testing.T, path string) {
|
||||
t.Helper()
|
||||
if err := os.WriteFile(path, []byte("#!/bin/sh\n"), 0o755); err != nil {
|
||||
t.Fatalf("WriteFile(%q) error = %v", path, err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package main
|
||||
|
||||
import "git.julianfamily.org/keepassgo/internal/appui"
|
||||
|
||||
func main() {
|
||||
appui.Main()
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
# Accessibility Review
|
||||
|
||||
KeePassGO currently targets keyboard-first desktop use on Linux and Windows.
|
||||
|
||||
## What is intentionally supported
|
||||
|
||||
- Keyboard focus is explicit for:
|
||||
- vault search
|
||||
- breadcrumb navigation
|
||||
- entry list selection
|
||||
- detail/editor fields
|
||||
- Focus styling is visible and distinct from the unfocused field treatment.
|
||||
- Common keyboard workflows are covered in-repo by tests for:
|
||||
- tab navigation
|
||||
- list navigation
|
||||
- search focus
|
||||
- new-entry focus transitions
|
||||
- Controls that participate in keyboard navigation have intent-revealing accessibility labels through `accessibilityLabel` in [`ui_accessibility.go`](/workspace/keepassgo/ui_accessibility.go).
|
||||
|
||||
## Current screen-reader boundary
|
||||
|
||||
- Gio does not currently give KeePassGO a full native accessibility tree comparable to mature desktop UI toolkits.
|
||||
- KeePassGO therefore treats screen-reader support as:
|
||||
- label-conscious where the toolkit exposes focusable controls
|
||||
- limited where platform assistive APIs are not surfaced by Gio in the same way as native toolkit widgets
|
||||
- In practice, this means keyboard and focus behavior are first-class and tested, while spoken output quality still depends on Gio/platform limitations outside this repo.
|
||||
|
||||
## Current review result
|
||||
|
||||
- Linux:
|
||||
- keyboard/focus behavior is intentionally supported
|
||||
- visible focus states and control naming are present in code
|
||||
- full Orca-style semantic verification is not something this repo can assert automatically today
|
||||
- Windows:
|
||||
- the same keyboard/focus behavior and explicit labels are present in-app
|
||||
- full UI Automation parity cannot be claimed from inside this codebase without broader Gio support
|
||||
|
||||
## What KeePassGO should continue doing
|
||||
|
||||
- Keep every major workflow operable without a pointer device.
|
||||
- Add explicit labels for any new focusable control.
|
||||
- Preserve visible focus treatment for new form fields, buttons, and dialogs.
|
||||
- Prefer dialogs and panels that keep keyboard focus predictable.
|
||||
|
||||
## What remains toolkit-limited
|
||||
|
||||
- Rich screen-reader semantics beyond the control labeling and focus management done in this repository.
|
||||
- Native assistive-technology parity with toolkits that expose a fuller accessibility object model.
|
||||
@@ -10,6 +10,9 @@ KeePassGO supports the following KDBX security workflows today:
|
||||
- preserve the original opened vault's KDBX format version during save
|
||||
- preserve the original opened vault's cipher selection during save
|
||||
- preserve the original opened vault's KDF selection during save
|
||||
- choose the cipher family for new vault creation
|
||||
- choose the KDF family for new vault creation
|
||||
- change the cipher family and KDF family for an existing unlocked session before the next save
|
||||
|
||||
What "preserve" means:
|
||||
|
||||
@@ -18,11 +21,11 @@ What "preserve" means:
|
||||
|
||||
Current explicit limitations:
|
||||
|
||||
- KeePassGO does not yet provide a UI for editing cipher or KDF parameters directly
|
||||
- new vault creation still uses the library default KDBX header settings for freshly created databases
|
||||
- KeePassGO currently exposes major cipher/KDF family choices, not every low-level tuning parameter from KeePass
|
||||
- advanced KDF tuning such as custom Argon2 memory/parallelism and AES-KDF round-count editing is not yet a product-facing control
|
||||
- unsupported or unknown header fields outside the preserved header structures are not guaranteed to round-trip if they are not represented by the underlying library
|
||||
|
||||
Practical expectation:
|
||||
|
||||
- existing KeePass/KeePass2Android-compatible vaults keep their major format, cipher, and KDF family when edited and saved through KeePassGO
|
||||
- KeePassGO does not yet try to be a full advanced database-settings editor
|
||||
- KeePassGO now lets a user select the major cipher/KDF family, while still avoiding a full low-level database-header editor
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
# Local-First Remote Sync Plan
|
||||
|
||||
## Goal
|
||||
|
||||
Redesign remote-backed vault handling so every platform uses the same local-first model:
|
||||
|
||||
- every vault is a local KDBX file first
|
||||
- remote sync is an optional binding on top of that local file
|
||||
- shared remote configuration lives in the vault
|
||||
- user-specific remote credentials live in the vault
|
||||
- app-local state stores only non-secret binding metadata
|
||||
|
||||
Android adds only one platform-specific capability on top of that model:
|
||||
|
||||
- share/import the initial local KDBX file between devices
|
||||
|
||||
## Product Rules
|
||||
|
||||
1. A remote-backed vault must always have a local cache KDBX file.
|
||||
2. Opening a remote-backed vault should open the local KDBX first.
|
||||
3. Shared remote configuration must be stored in the vault, not only in app state.
|
||||
4. Remote credentials must not be stored in plaintext app-local state.
|
||||
5. Remote credentials should be stored in the vault and resolved by a stable reference.
|
||||
6. The app state file should keep only the metadata needed to reopen the local vault and find the remote binding.
|
||||
7. Sync must support both manual and automatic modes.
|
||||
8. Android-specific sharing should transfer the KDBX file, not a bespoke remote-secret bundle.
|
||||
|
||||
## Target Data Model
|
||||
|
||||
### In-Vault Shared Remote Profile
|
||||
|
||||
Store a reusable remote profile in the vault with fields such as:
|
||||
|
||||
- profile ID
|
||||
- profile name
|
||||
- backend type, initially WebDAV
|
||||
- base URL
|
||||
- remote object path
|
||||
- optional notes or labels
|
||||
- default sync policy, if shared defaults are desirable
|
||||
|
||||
### In-Vault User Credential Binding
|
||||
|
||||
Store user-specific credentials in the vault as normal vault data, referenced by:
|
||||
|
||||
- remote profile ID
|
||||
- credential entry UUID, or another stable internal reference
|
||||
- optional username field override if needed
|
||||
|
||||
The credential entry should contain the actual username/password or token.
|
||||
|
||||
### Local App State
|
||||
|
||||
Persist only non-secret binding state such as:
|
||||
|
||||
- local vault path
|
||||
- selected remote profile ID
|
||||
- selected credential entry reference
|
||||
- sync policy override
|
||||
- last sync metadata
|
||||
- conflict or recovery markers
|
||||
|
||||
## Core Flows
|
||||
|
||||
### Create Or Configure Remote Sync
|
||||
|
||||
1. Open or create a local vault.
|
||||
2. Create or edit a shared remote profile in that vault.
|
||||
3. Create or select a credential entry in that vault.
|
||||
4. Bind the local vault to the selected remote profile and credential reference.
|
||||
5. Choose manual or automatic sync behavior.
|
||||
|
||||
### Reopen Existing Remote-Backed Vault
|
||||
|
||||
1. Open the local vault file from app state.
|
||||
2. Resolve the selected remote profile from vault contents.
|
||||
3. Resolve the credential entry from vault contents.
|
||||
4. Offer or perform sync based on the binding policy.
|
||||
|
||||
### Bootstrap A New Android Device
|
||||
|
||||
1. Share the local KDBX file through Android Sharesheet.
|
||||
2. Import and open that KDBX locally on the new device.
|
||||
3. Select a remote profile stored in the vault.
|
||||
4. Select or create that user’s credential entry in the vault.
|
||||
5. Bind the local vault as the cache for the remote-backed setup.
|
||||
|
||||
## Migration Requirements
|
||||
|
||||
1. Migrate existing remote connections that save credentials in app state.
|
||||
2. On first open after upgrade, move any recoverable remote credentials into the vault.
|
||||
3. Replace saved plaintext credential state with a vault credential reference.
|
||||
4. If migration cannot write into the vault yet, hold the old state only long enough to prompt the user to complete migration.
|
||||
5. Remove legacy local plaintext credential persistence after migration is complete.
|
||||
|
||||
## Implementation Phases
|
||||
|
||||
### Phase 1: Domain Model
|
||||
|
||||
- define remote profile structures independent of Gio UI
|
||||
- define credential reference structures independent of Gio UI
|
||||
- define sync binding state independent of Gio UI
|
||||
- add behavior tests for local-first remote-backed vaults
|
||||
|
||||
### Phase 2: Vault Storage
|
||||
|
||||
- persist remote profiles in the vault
|
||||
- persist credential references in the vault
|
||||
- resolve credentials from normal vault entries
|
||||
- add behavior tests for read/write and lookup semantics
|
||||
|
||||
### Phase 3: State And Open Flow
|
||||
|
||||
- shrink app state to non-secret metadata only
|
||||
- update open flows to always prefer the local cache vault
|
||||
- update reopen behavior on all platforms to use the same model
|
||||
- add migration coverage for old remote state
|
||||
|
||||
### Phase 4: Sync Binding
|
||||
|
||||
- bind a local vault to a selected remote profile
|
||||
- support manual sync
|
||||
- support automatic sync on open/save
|
||||
- define conflict and remote-failure handling for the local cache model
|
||||
|
||||
### Phase 5: Android Bootstrap
|
||||
|
||||
- add Android Sharesheet export of the current local KDBX
|
||||
- add Android import flow for a shared KDBX
|
||||
- keep the remote pivot flow consistent with desktop after local open
|
||||
|
||||
## Open Questions
|
||||
|
||||
1. Where in the vault should remote profiles live: custom metadata, dedicated entries, or another KDBX-compatible structure?
|
||||
2. Should credential references point to entry UUIDs directly, or should KeePassGO maintain an additional logical identifier?
|
||||
3. Should automatic sync run only on open/save initially, or also on app resume?
|
||||
4. How should multiple remote profiles per vault be presented in the UI?
|
||||
5. What should happen when the credential entry reference no longer resolves?
|
||||
|
||||
## Recommended First Slice
|
||||
|
||||
Implement the shared domain model and tests first:
|
||||
|
||||
- model a local vault plus optional remote binding
|
||||
- define in-vault remote profile and credential reference semantics
|
||||
- add tests proving app state no longer needs plaintext remote credentials
|
||||
|
||||
That slice standardizes the architecture before any Android-specific sharing work begins.
|
||||
@@ -3,7 +3,8 @@ module git.julianfamily.org/keepassgo
|
||||
go 1.26
|
||||
|
||||
require (
|
||||
gioui.org v0.9.0
|
||||
gioui.org v0.8.0
|
||||
gioui.org/x v0.8.0
|
||||
github.com/atotto/clipboard v0.1.4
|
||||
github.com/tobischo/gokeepasslib/v3 v3.6.2
|
||||
golang.org/x/exp/shiny v0.0.0-20260312153236-7ab1446f8b90
|
||||
@@ -14,8 +15,9 @@ require (
|
||||
require (
|
||||
4d63.com/gocheckcompilerdirectives v1.3.0 // indirect
|
||||
4d63.com/gochecknoglobals v0.2.2 // indirect
|
||||
gioui.org/cmd v0.9.0 // indirect
|
||||
gioui.org/cmd v0.8.0 // indirect
|
||||
gioui.org/shader v1.0.8 // indirect
|
||||
git.wow.st/gmp/jni v0.0.0-20210610011705-34026c7e22d0 // indirect
|
||||
github.com/4meepo/tagalign v1.4.2 // indirect
|
||||
github.com/Abirdcfly/dupword v0.1.3 // indirect
|
||||
github.com/Antonboom/errname v1.0.0 // indirect
|
||||
@@ -72,6 +74,7 @@ require (
|
||||
github.com/go-viper/mapstructure/v2 v2.2.1 // indirect
|
||||
github.com/go-xmlfmt/xmlfmt v1.1.3 // indirect
|
||||
github.com/gobwas/glob v0.2.3 // indirect
|
||||
github.com/godbus/dbus/v5 v5.0.6 // indirect
|
||||
github.com/gofrs/flock v0.12.1 // indirect
|
||||
github.com/golang/protobuf v1.5.4 // indirect
|
||||
github.com/golangci/dupl v0.0.0-20250308024227-f665c8d69b32 // indirect
|
||||
@@ -190,6 +193,7 @@ require (
|
||||
go.uber.org/multierr v1.6.0 // indirect
|
||||
go.uber.org/zap v1.24.0 // indirect
|
||||
golang.org/x/crypto v0.48.0 // indirect
|
||||
golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 // indirect
|
||||
golang.org/x/exp/typeparams v0.0.0-20250210185358-939b2ce775ac // indirect
|
||||
golang.org/x/image v0.37.0 // indirect
|
||||
golang.org/x/mod v0.33.0 // indirect
|
||||
|
||||
@@ -37,13 +37,17 @@ cloud.google.com/go/storage v1.10.0/go.mod h1:FLPqc6j+Ki4BU591ie1oL6qBQGu2Bl/tZ9
|
||||
dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU=
|
||||
eliasnaur.com/font v0.0.0-20230308162249-dd43949cb42d h1:ARo7NCVvN2NdhLlJE9xAbKweuI9L6UgfTbYb0YwPacY=
|
||||
eliasnaur.com/font v0.0.0-20230308162249-dd43949cb42d/go.mod h1:OYVuxibdk9OSLX8vAqydtRPP87PyTFcT9uH3MlEGBQA=
|
||||
gioui.org v0.9.0 h1:4u7XZwnb5kzQW91Nz/vR0wKD6LdW9CaVF96r3rfy4kc=
|
||||
gioui.org v0.9.0/go.mod h1:CjNig0wAhLt9WZxOPAusgFD8x8IRvqt26LdDBa3Jvao=
|
||||
gioui.org/cmd v0.9.0 h1:H1F2u3vBAd8TDRvaJd4IbrbbiOPBWc7Z3ZykOYoq/20=
|
||||
gioui.org/cmd v0.9.0/go.mod h1:RBQfFU8JCgMjQ2wKU9DG3zMC38TnY97E5MKoBGhGl3s=
|
||||
gioui.org v0.8.0 h1:QV5p5JvsmSmGiIXVYOKn6d9YDliTfjtLlVf5J+BZ9Pg=
|
||||
gioui.org v0.8.0/go.mod h1:vEMmpxMOd/iwJhXvGVIzWEbxMWhnMQ9aByOGQdlQ8rc=
|
||||
gioui.org/cmd v0.8.0 h1:oy5qOlc1UXcglc5HBCMZQELiIzQ2obhT98mw+SuWafQ=
|
||||
gioui.org/cmd v0.8.0/go.mod h1:wKLAyAgRR25VMYFzGX2Ecia0m0Td562wDcZ3LaPHPTI=
|
||||
gioui.org/cpu v0.0.0-20210808092351-bfe733dd3334/go.mod h1:A8M0Cn5o+vY5LTMlnRoK3O5kG+rH0kWfJjeKd9QpBmQ=
|
||||
gioui.org/shader v1.0.8 h1:6ks0o/A+b0ne7RzEqRZK5f4Gboz2CfG+mVliciy6+qA=
|
||||
gioui.org/shader v1.0.8/go.mod h1:mWdiME581d/kV7/iEhLmUgUK5iZ09XR5XpduXzbePVM=
|
||||
gioui.org/x v0.8.0 h1:RhIlQNOFKKn8D8FeaKKaXCo7vB3x+fq4VcD10HW/YpA=
|
||||
gioui.org/x v0.8.0/go.mod h1:aXtQb+kyqoUOjDl5/uMqAopjzVzMkeHBbMQOGT5KnSE=
|
||||
git.wow.st/gmp/jni v0.0.0-20210610011705-34026c7e22d0 h1:bGG/g4ypjrCJoSvFrP5hafr9PPB5aw8SjcOWWila7ZI=
|
||||
git.wow.st/gmp/jni v0.0.0-20210610011705-34026c7e22d0/go.mod h1:+axXBRUTIDlCeE73IKeD/os7LoEnTKdkp8/gQOFjqyo=
|
||||
github.com/4meepo/tagalign v1.4.2 h1:0hcLHPGMjDyM1gHG58cS73aQF8J4TdVR96TZViorO9E=
|
||||
github.com/4meepo/tagalign v1.4.2/go.mod h1:+p4aMyFM+ra7nb41CnFG6aSDXqRxU/w1VQqScKqDARI=
|
||||
github.com/Abirdcfly/dupword v0.1.3 h1:9Pa1NuAsZvpFPi9Pqkd93I7LIYRURj+A//dFd5tgBeE=
|
||||
@@ -128,6 +132,10 @@ github.com/charithe/durationcheck v0.0.10 h1:wgw73BiocdBDQPik+zcEoBG/ob8uyBHf2iy
|
||||
github.com/charithe/durationcheck v0.0.10/go.mod h1:bCWXb7gYRysD1CU3C+u4ceO49LoGOY1C1L6uouGNreQ=
|
||||
github.com/chavacava/garif v0.1.0 h1:2JHa3hbYf5D9dsgseMKAmc/MZ109otzgNFk5s87H9Pc=
|
||||
github.com/chavacava/garif v0.1.0/go.mod h1:XMyYCkEL58DF0oyW4qDjjnPWONs2HBqYKI+UIPD+Gww=
|
||||
github.com/chromedp/cdproto v0.0.0-20191114225735-6626966fbae4 h1:QD3KxSJ59L2lxG6MXBjNHxiQO2RmxTQ3XcK+wO44WOg=
|
||||
github.com/chromedp/cdproto v0.0.0-20191114225735-6626966fbae4/go.mod h1:PfAWWKJqjlGFYJEidUM6aVIWPr0EpobeyVWEEmplX7g=
|
||||
github.com/chromedp/chromedp v0.5.2 h1:W8xBXQuUnd2dZK0SN/lyVwsQM7KgW+kY5HGnntms194=
|
||||
github.com/chromedp/chromedp v0.5.2/go.mod h1:rsTo/xRo23KZZwFmWk2Ui79rBaVRRATCjLzNQlOFSiA=
|
||||
github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI=
|
||||
github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI=
|
||||
github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU=
|
||||
@@ -216,6 +224,14 @@ github.com/go-xmlfmt/xmlfmt v1.1.3 h1:t8Ey3Uy7jDSEisW2K3somuMKIpzktkWptA0iFCnRUW
|
||||
github.com/go-xmlfmt/xmlfmt v1.1.3/go.mod h1:aUCEOzzezBEjDBbFBoSiya/gduyIiWYRP6CnSFIV8AM=
|
||||
github.com/gobwas/glob v0.2.3 h1:A4xDbljILXROh+kObIiy5kIaPYD8e96x1tgBhUI5J+Y=
|
||||
github.com/gobwas/glob v0.2.3/go.mod h1:d3Ez4x06l9bZtSvzIay5+Yzi0fmZzPgnTbPcKjJAkT8=
|
||||
github.com/gobwas/httphead v0.0.0-20180130184737-2c6c146eadee h1:s+21KNqlpePfkah2I+gwHF8xmJWRjooY+5248k6m4A0=
|
||||
github.com/gobwas/httphead v0.0.0-20180130184737-2c6c146eadee/go.mod h1:L0fX3K22YWvt/FAX9NnzrNzcI4wNYi9Yku4O0LKYflo=
|
||||
github.com/gobwas/pool v0.2.0 h1:QEmUOlnSjWtnpRGHF3SauEiOsy82Cup83Vf2LcMlnc8=
|
||||
github.com/gobwas/pool v0.2.0/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw=
|
||||
github.com/gobwas/ws v1.0.2 h1:CoAavW/wd/kulfZmSIBt6p24n4j7tHgNVCjsfHVNUbo=
|
||||
github.com/gobwas/ws v1.0.2/go.mod h1:szmBTxLgaFppYjEmNtny/v3w89xOydFnnZMcgRRu/EM=
|
||||
github.com/godbus/dbus/v5 v5.0.6 h1:mkgN1ofwASrYnJ5W6U/BxG15eXXXjirgZc7CLqkcaro=
|
||||
github.com/godbus/dbus/v5 v5.0.6/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
|
||||
github.com/gofrs/flock v0.12.1 h1:MTLVXXHf8ekldpJk3AKicLij9MdwOWkZ+a/jHHZby9E=
|
||||
github.com/gofrs/flock v0.12.1/go.mod h1:9zxTsyu5xtJ9DK+1tFZyibEV7y3uwDxPPfbxeeHCoD0=
|
||||
github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ=
|
||||
@@ -354,6 +370,8 @@ github.com/kisielk/errcheck v1.9.0/go.mod h1:kQxWMMVZgIkDq7U8xtG/n2juOjbLgZtedi0
|
||||
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
|
||||
github.com/kkHAIKE/contextcheck v1.1.6 h1:7HIyRcnyzxL9Lz06NGhiKvenXq7Zw6Q0UQu/ttjfJCE=
|
||||
github.com/kkHAIKE/contextcheck v1.1.6/go.mod h1:3dDbMRNBFaq8HFXWC1JyvDSPm43CmE6IuHam8Wr0rkg=
|
||||
github.com/knq/sysutil v0.0.0-20191005231841-15668db23d08 h1:V0an7KRw92wmJysvFvtqtKMAPmvS5O0jtB0nYo6t+gs=
|
||||
github.com/knq/sysutil v0.0.0-20191005231841-15668db23d08/go.mod h1:dFWs1zEqDjFtnBXsd1vPOZaLsESovai349994nHx3e0=
|
||||
github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
|
||||
github.com/konsorten/go-windows-terminal-sequences v1.0.3/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
|
||||
github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc=
|
||||
@@ -386,6 +404,8 @@ github.com/macabu/inamedparam v0.1.3 h1:2tk/phHkMlEL/1GNe/Yf6kkR/hkcUdAEY3L0hjYV
|
||||
github.com/macabu/inamedparam v0.1.3/go.mod h1:93FLICAIk/quk7eaPPQvbzihUdn/QkGDwIZEoLtpH6I=
|
||||
github.com/magiconair/properties v1.8.6 h1:5ibWZ6iY0NctNGWo87LalDlEZ6R41TqbbDamhfG/Qzo=
|
||||
github.com/magiconair/properties v1.8.6/go.mod h1:y3VJvCyxH9uVvJTWEGAELF3aiYNyPKd5NZ3oSwXrF60=
|
||||
github.com/mailru/easyjson v0.7.0 h1:aizVhC/NAAcKWb+5QsU1iNOZb4Yws5UO2I+aIprQITM=
|
||||
github.com/mailru/easyjson v0.7.0/go.mod h1:KAzv3t3aY1NaHWoQz1+4F1ccyAH66Jk7yos7ldAVICs=
|
||||
github.com/maratori/testableexamples v1.0.0 h1:dU5alXRrD8WKSjOUnmJZuzdxWOEQ57+7s93SLMxb2vI=
|
||||
github.com/maratori/testableexamples v1.0.0/go.mod h1:4rhjL1n20TUTT4vdh3RDqSizKLyXp7K2u6HgraZCGzE=
|
||||
github.com/maratori/testpackage v1.1.1 h1:S58XVV5AD7HADMmD0fNnziNHqKvSdDuEKdPD1rNTU04=
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"git.julianfamily.org/keepassgo/internal/clipboard"
|
||||
"git.julianfamily.org/keepassgo/internal/passwords"
|
||||
"git.julianfamily.org/keepassgo/internal/session"
|
||||
"git.julianfamily.org/keepassgo/internal/vault"
|
||||
keepassgov1 "git.julianfamily.org/keepassgo/proto/keepassgo/v1"
|
||||
"google.golang.org/grpc"
|
||||
)
|
||||
|
||||
type DirtyProvider func() bool
|
||||
|
||||
type Host struct {
|
||||
server *Server
|
||||
grpcServer *grpc.Server
|
||||
listener net.Listener
|
||||
lifecycle lifecycleBackend
|
||||
dirty DirtyProvider
|
||||
mu sync.Mutex
|
||||
lastModel vault.Model
|
||||
started bool
|
||||
listenAddr string
|
||||
}
|
||||
|
||||
func StartHost(addr string, lifecycle lifecycleBackend, profiles map[string]passwords.Profile, clipboardWriter clipboard.Writer, dirty DirtyProvider) (*Host, error) {
|
||||
addr = strings.TrimSpace(addr)
|
||||
if addr == "" || strings.EqualFold(addr, "off") {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
listener, err := net.Listen("tcp", addr)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("listen gRPC host %s: %w", addr, err)
|
||||
}
|
||||
|
||||
service := NewServerWithLifecycle(vault.Model{}, profiles, clipboardWriter, lifecycle)
|
||||
server := grpc.NewServer(grpc.UnaryInterceptor(AuthInterceptor(service)))
|
||||
keepassgov1.RegisterVaultServiceServer(server, service)
|
||||
|
||||
host := &Host{
|
||||
server: service,
|
||||
grpcServer: server,
|
||||
listener: listener,
|
||||
lifecycle: lifecycle,
|
||||
dirty: dirty,
|
||||
listenAddr: listener.Addr().String(),
|
||||
started: true,
|
||||
}
|
||||
if err := host.SyncFromLifecycle(); err != nil && !errors.Is(err, session.ErrLocked) {
|
||||
_ = listener.Close()
|
||||
server.Stop()
|
||||
return nil, err
|
||||
}
|
||||
|
||||
go func() {
|
||||
_ = server.Serve(listener)
|
||||
}()
|
||||
|
||||
return host, nil
|
||||
}
|
||||
|
||||
func (h *Host) Address() string {
|
||||
if h == nil {
|
||||
return ""
|
||||
}
|
||||
return h.listenAddr
|
||||
}
|
||||
|
||||
func (h *Host) Server() *Server {
|
||||
if h == nil {
|
||||
return nil
|
||||
}
|
||||
return h.server
|
||||
}
|
||||
|
||||
func (h *Host) Stop() error {
|
||||
if h == nil {
|
||||
return nil
|
||||
}
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
if !h.started {
|
||||
return nil
|
||||
}
|
||||
h.started = false
|
||||
h.grpcServer.Stop()
|
||||
return h.listener.Close()
|
||||
}
|
||||
|
||||
func (h *Host) SyncFromLifecycle() error {
|
||||
if h == nil || h.lifecycle == nil || h.server == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
|
||||
model, err := h.lifecycle.Current()
|
||||
locked := false
|
||||
switch {
|
||||
case err == nil:
|
||||
h.lastModel = model
|
||||
case errors.Is(err, session.ErrLocked):
|
||||
locked = true
|
||||
default:
|
||||
return err
|
||||
}
|
||||
|
||||
dirty := false
|
||||
if h.dirty != nil {
|
||||
dirty = h.dirty()
|
||||
}
|
||||
h.server.SetSessionState(h.lastModel, locked, dirty)
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net"
|
||||
"testing"
|
||||
|
||||
"git.julianfamily.org/keepassgo/internal/passwords"
|
||||
"git.julianfamily.org/keepassgo/internal/session"
|
||||
"git.julianfamily.org/keepassgo/internal/vault"
|
||||
keepassgov1 "git.julianfamily.org/keepassgo/proto/keepassgo/v1"
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/credentials/insecure"
|
||||
)
|
||||
|
||||
func TestStartHostServesVaultLifecycleAndSyncsSessionState(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
lifecycle := &session.Manager{}
|
||||
if err := lifecycle.Create(vault.Model{
|
||||
Entries: []vault.Entry{
|
||||
testAPITokenEntry(t),
|
||||
{ID: "entry-1", Title: "Vault Console", Path: []string{"Root", "Internet"}},
|
||||
},
|
||||
}, vault.MasterKey{Password: "correct horse battery staple"}); err != nil {
|
||||
t.Fatalf("Create() error = %v", err)
|
||||
}
|
||||
|
||||
host, err := StartHost("127.0.0.1:0", lifecycle, passwords.DefaultProfiles(), nil, func() bool { return true })
|
||||
if err != nil {
|
||||
t.Fatalf("StartHost() error = %v", err)
|
||||
}
|
||||
defer func() { _ = host.Stop() }()
|
||||
|
||||
conn, err := grpc.NewClient("passthrough:///"+host.Address(),
|
||||
grpc.WithTransportCredentials(insecure.NewCredentials()),
|
||||
grpc.WithContextDialer(func(context.Context, string) (net.Conn, error) {
|
||||
return net.Dial("tcp", host.Address())
|
||||
}),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("grpc.NewClient() error = %v", err)
|
||||
}
|
||||
defer func() { _ = conn.Close() }()
|
||||
|
||||
client := keepassgov1.NewVaultServiceClient(conn)
|
||||
statusResp, err := client.GetSessionStatus(tokenContext(defaultTestTokenSecret), &keepassgov1.GetSessionStatusRequest{})
|
||||
if err != nil {
|
||||
t.Fatalf("GetSessionStatus() error = %v", err)
|
||||
}
|
||||
if statusResp.Locked {
|
||||
t.Fatal("GetSessionStatus().Locked = true, want false")
|
||||
}
|
||||
if !statusResp.Dirty {
|
||||
t.Fatal("GetSessionStatus().Dirty = false, want true from dirty provider")
|
||||
}
|
||||
|
||||
if err := lifecycle.Lock(); err != nil {
|
||||
t.Fatalf("Lock() error = %v", err)
|
||||
}
|
||||
if err := host.SyncFromLifecycle(); err != nil {
|
||||
t.Fatalf("SyncFromLifecycle() after lock error = %v", err)
|
||||
}
|
||||
|
||||
statusResp, err = client.GetSessionStatus(tokenContext(defaultTestTokenSecret), &keepassgov1.GetSessionStatusRequest{})
|
||||
if err != nil {
|
||||
t.Fatalf("GetSessionStatus() after lock error = %v", err)
|
||||
}
|
||||
if !statusResp.Locked {
|
||||
t.Fatal("GetSessionStatus().Locked = false, want true after lifecycle lock")
|
||||
}
|
||||
}
|
||||
@@ -8,13 +8,17 @@ import (
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"git.julianfamily.org/keepassgo/clipboard"
|
||||
"git.julianfamily.org/keepassgo/passwords"
|
||||
"git.julianfamily.org/keepassgo/internal/apiapproval"
|
||||
"git.julianfamily.org/keepassgo/internal/apiaudit"
|
||||
"git.julianfamily.org/keepassgo/internal/apitokens"
|
||||
"git.julianfamily.org/keepassgo/internal/clipboard"
|
||||
"git.julianfamily.org/keepassgo/internal/passwords"
|
||||
"git.julianfamily.org/keepassgo/internal/session"
|
||||
"git.julianfamily.org/keepassgo/internal/vault"
|
||||
"git.julianfamily.org/keepassgo/internal/webdav"
|
||||
keepassgov1 "git.julianfamily.org/keepassgo/proto/keepassgo/v1"
|
||||
"git.julianfamily.org/keepassgo/session"
|
||||
"git.julianfamily.org/keepassgo/vault"
|
||||
"git.julianfamily.org/keepassgo/webdav"
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/metadata"
|
||||
@@ -31,6 +35,8 @@ type Server struct {
|
||||
lifecycle lifecycleBackend
|
||||
profiles map[string]passwords.Profile
|
||||
clipboard clipboard.Writer
|
||||
approvals *apiapproval.Broker
|
||||
audit *apiaudit.Log
|
||||
}
|
||||
|
||||
type lifecycleBackend interface {
|
||||
@@ -42,11 +48,18 @@ type lifecycleBackend interface {
|
||||
Unlock(vault.MasterKey) error
|
||||
}
|
||||
|
||||
type modelReplaceableLifecycle interface {
|
||||
lifecycleBackend
|
||||
Replace(vault.Model)
|
||||
}
|
||||
|
||||
func NewServer(model vault.Model, profiles map[string]passwords.Profile, clipboardWriter clipboard.Writer) *Server {
|
||||
return &Server{
|
||||
model: model,
|
||||
profiles: profiles,
|
||||
clipboard: clipboardWriter,
|
||||
approvals: apiapproval.NewBroker(30 * time.Second),
|
||||
audit: apiaudit.New(200),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,6 +69,26 @@ func NewServerWithLifecycle(model vault.Model, profiles map[string]passwords.Pro
|
||||
return server
|
||||
}
|
||||
|
||||
func (s *Server) ApprovalBroker() *apiapproval.Broker {
|
||||
return s.approvals
|
||||
}
|
||||
|
||||
func (s *Server) AuditLog() *apiaudit.Log {
|
||||
return s.audit
|
||||
}
|
||||
|
||||
func (s *Server) ResolveApproval(id string, outcome apiapproval.Outcome) (apiapproval.Request, *apitokens.PolicyRule, error) {
|
||||
return s.approvals.Resolve(id, outcome)
|
||||
}
|
||||
|
||||
func (s *Server) SetSessionState(model vault.Model, locked, dirty bool) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.model = model
|
||||
s.locked = locked
|
||||
s.dirty = dirty
|
||||
}
|
||||
|
||||
func (s *Server) GetSessionStatus(_ context.Context, _ *keepassgov1.GetSessionStatusRequest) (*keepassgov1.GetSessionStatusResponse, error) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
@@ -190,23 +223,25 @@ func mapLifecycleError(operation string, err error) error {
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) ListEntries(_ context.Context, req *keepassgov1.ListEntriesRequest) (*keepassgov1.ListEntriesResponse, error) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
if s.locked {
|
||||
func (s *Server) ListEntries(ctx context.Context, req *keepassgov1.ListEntriesRequest) (*keepassgov1.ListEntriesResponse, error) {
|
||||
model, locked := s.snapshotModel()
|
||||
if locked {
|
||||
return nil, status.Error(codes.FailedPrecondition, "vault is locked")
|
||||
}
|
||||
if _, err := s.authorizePathRequest(ctx, apitokens.OperationListEntries, req.GetPath()); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
model = visibleModel(model)
|
||||
var entries []vault.Entry
|
||||
if strings.TrimSpace(req.GetQuery()) != "" {
|
||||
results := s.model.Search(req.GetQuery())
|
||||
results := model.Search(req.GetQuery())
|
||||
entries = make([]vault.Entry, 0, len(results))
|
||||
for _, result := range results {
|
||||
entries = append(entries, result.Entry)
|
||||
}
|
||||
} else {
|
||||
entries = s.model.EntriesInPath(req.GetPath())
|
||||
entries = model.EntriesInPath(req.GetPath())
|
||||
}
|
||||
|
||||
resp := &keepassgov1.ListEntriesResponse{
|
||||
@@ -219,23 +254,27 @@ func (s *Server) ListEntries(_ context.Context, req *keepassgov1.ListEntriesRequ
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
func (s *Server) ListGroups(_ context.Context, req *keepassgov1.ListGroupsRequest) (*keepassgov1.ListGroupsResponse, error) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
if s.locked {
|
||||
func (s *Server) ListGroups(ctx context.Context, req *keepassgov1.ListGroupsRequest) (*keepassgov1.ListGroupsResponse, error) {
|
||||
model, locked := s.snapshotModel()
|
||||
if locked {
|
||||
return nil, status.Error(codes.FailedPrecondition, "vault is locked")
|
||||
}
|
||||
if _, err := s.authorizePathRequest(ctx, apitokens.OperationListGroups, req.GetPath()); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &keepassgov1.ListGroupsResponse{
|
||||
Names: s.model.ChildGroups(req.GetPath()),
|
||||
Names: visibleModel(model).ChildGroups(req.GetPath()),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Server) CreateGroup(_ context.Context, req *keepassgov1.CreateGroupRequest) (*keepassgov1.CreateGroupResponse, error) {
|
||||
func (s *Server) CreateGroup(ctx context.Context, req *keepassgov1.CreateGroupRequest) (*keepassgov1.CreateGroupResponse, error) {
|
||||
if _, err := s.authorizePathRequest(ctx, apitokens.OperationMutateGroup, req.GetParentPath()); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
if s.locked {
|
||||
return nil, status.Error(codes.FailedPrecondition, "vault is locked")
|
||||
}
|
||||
@@ -245,10 +284,13 @@ func (s *Server) CreateGroup(_ context.Context, req *keepassgov1.CreateGroupRequ
|
||||
return &keepassgov1.CreateGroupResponse{}, nil
|
||||
}
|
||||
|
||||
func (s *Server) RenameGroup(_ 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 {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
if s.locked {
|
||||
return nil, status.Error(codes.FailedPrecondition, "vault is locked")
|
||||
}
|
||||
@@ -264,10 +306,13 @@ func (s *Server) RenameGroup(_ context.Context, req *keepassgov1.RenameGroupRequ
|
||||
return &keepassgov1.RenameGroupResponse{}, nil
|
||||
}
|
||||
|
||||
func (s *Server) DeleteGroup(_ 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 {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
if s.locked {
|
||||
return nil, status.Error(codes.FailedPrecondition, "vault is locked")
|
||||
}
|
||||
@@ -287,12 +332,15 @@ func (s *Server) DeleteGroup(_ context.Context, req *keepassgov1.DeleteGroupRequ
|
||||
return &keepassgov1.DeleteGroupResponse{}, nil
|
||||
}
|
||||
|
||||
func (s *Server) UpsertEntry(_ context.Context, req *keepassgov1.UpsertEntryRequest) (*keepassgov1.UpsertEntryResponse, error) {
|
||||
func (s *Server) UpsertEntry(ctx context.Context, req *keepassgov1.UpsertEntryRequest) (*keepassgov1.UpsertEntryResponse, error) {
|
||||
if req.GetEntry() == nil {
|
||||
return nil, status.Error(codes.InvalidArgument, "missing entry")
|
||||
}
|
||||
|
||||
entry := entryFromProto(req.GetEntry())
|
||||
if _, err := s.authorizeEntryRequest(ctx, apitokens.OperationMutateEntry, entry); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
if s.locked {
|
||||
@@ -306,13 +354,19 @@ func (s *Server) UpsertEntry(_ context.Context, req *keepassgov1.UpsertEntryRequ
|
||||
return &keepassgov1.UpsertEntryResponse{Entry: entryToProto(entry)}, nil
|
||||
}
|
||||
|
||||
func (s *Server) DeleteEntry(_ context.Context, req *keepassgov1.DeleteEntryRequest) (*keepassgov1.DeleteEntryResponse, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
if s.locked {
|
||||
func (s *Server) DeleteEntry(ctx context.Context, req *keepassgov1.DeleteEntryRequest) (*keepassgov1.DeleteEntryResponse, error) {
|
||||
model, locked := s.snapshotModel()
|
||||
if locked {
|
||||
return nil, status.Error(codes.FailedPrecondition, "vault is locked")
|
||||
}
|
||||
if entry, err := findEntryByID(model, req.GetId()); err == nil {
|
||||
if _, err := s.authorizeEntryRequest(ctx, apitokens.OperationMutateEntry, entry); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
if err := s.model.DeleteEntry(req.GetId()); err != nil {
|
||||
if errors.Is(err, vault.ErrEntryNotFound) {
|
||||
@@ -325,21 +379,27 @@ func (s *Server) DeleteEntry(_ context.Context, req *keepassgov1.DeleteEntryRequ
|
||||
return &keepassgov1.DeleteEntryResponse{}, nil
|
||||
}
|
||||
|
||||
func (s *Server) RestoreEntry(_ context.Context, req *keepassgov1.RestoreEntryRequest) (*keepassgov1.RestoreEntryResponse, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
if s.locked {
|
||||
func (s *Server) RestoreEntry(ctx context.Context, req *keepassgov1.RestoreEntryRequest) (*keepassgov1.RestoreEntryResponse, error) {
|
||||
model, locked := s.snapshotModel()
|
||||
if locked {
|
||||
return nil, status.Error(codes.FailedPrecondition, "vault is locked")
|
||||
}
|
||||
|
||||
var restored vault.Entry
|
||||
for _, entry := range s.model.RecycleBin {
|
||||
for _, entry := range model.RecycleBin {
|
||||
if entry.ID == req.GetId() {
|
||||
restored = entry
|
||||
break
|
||||
}
|
||||
}
|
||||
if restored.ID != "" {
|
||||
if _, err := s.authorizeEntryRequest(ctx, apitokens.OperationMutateEntry, restored); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
if err := s.model.RestoreEntry(req.GetId()); err != nil {
|
||||
if errors.Is(err, vault.ErrEntryNotFound) {
|
||||
@@ -352,18 +412,19 @@ func (s *Server) RestoreEntry(_ context.Context, req *keepassgov1.RestoreEntryRe
|
||||
return &keepassgov1.RestoreEntryResponse{Entry: entryToProto(restored)}, nil
|
||||
}
|
||||
|
||||
func (s *Server) ListEntryHistory(_ context.Context, req *keepassgov1.ListEntryHistoryRequest) (*keepassgov1.ListEntryHistoryResponse, error) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
if s.locked {
|
||||
func (s *Server) ListEntryHistory(ctx context.Context, req *keepassgov1.ListEntryHistoryRequest) (*keepassgov1.ListEntryHistoryResponse, error) {
|
||||
model, locked := s.snapshotModel()
|
||||
if locked {
|
||||
return nil, status.Error(codes.FailedPrecondition, "vault is locked")
|
||||
}
|
||||
|
||||
entry, err := findEntryByID(s.model, req.GetId())
|
||||
entry, err := findEntryByID(model, req.GetId())
|
||||
if err != nil {
|
||||
return nil, status.Error(codes.NotFound, err.Error())
|
||||
}
|
||||
if _, err := s.authorizeEntryRequest(ctx, apitokens.OperationReadEntry, entry); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
resp := &keepassgov1.ListEntryHistoryResponse{
|
||||
Entries: make([]*keepassgov1.Entry, 0, len(entry.History)),
|
||||
@@ -374,14 +435,21 @@ func (s *Server) ListEntryHistory(_ context.Context, req *keepassgov1.ListEntryH
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
func (s *Server) RestoreEntryHistory(_ context.Context, req *keepassgov1.RestoreEntryHistoryRequest) (*keepassgov1.RestoreEntryHistoryResponse, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
if s.locked {
|
||||
func (s *Server) RestoreEntryHistory(ctx context.Context, req *keepassgov1.RestoreEntryHistoryRequest) (*keepassgov1.RestoreEntryHistoryResponse, error) {
|
||||
model, locked := s.snapshotModel()
|
||||
if locked {
|
||||
return nil, status.Error(codes.FailedPrecondition, "vault is locked")
|
||||
}
|
||||
entry, err := findEntryByID(model, req.GetId())
|
||||
if err != nil {
|
||||
return nil, status.Error(codes.NotFound, err.Error())
|
||||
}
|
||||
if _, err := s.authorizeEntryRequest(ctx, apitokens.OperationMutateEntry, entry); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if err := s.model.RestoreEntryVersion(req.GetId(), int(req.GetHistoryIndex())); err != nil {
|
||||
if errors.Is(err, vault.ErrEntryNotFound) {
|
||||
return nil, status.Error(codes.NotFound, err.Error())
|
||||
@@ -389,7 +457,7 @@ func (s *Server) RestoreEntryHistory(_ context.Context, req *keepassgov1.Restore
|
||||
return nil, status.Errorf(codes.Internal, "restore entry history: %v", err)
|
||||
}
|
||||
|
||||
entry, err := findEntryByID(s.model, req.GetId())
|
||||
entry, err = findEntryByID(s.model, req.GetId())
|
||||
if err != nil {
|
||||
return nil, status.Error(codes.NotFound, err.Error())
|
||||
}
|
||||
@@ -477,18 +545,19 @@ func (s *Server) InstantiateTemplate(_ context.Context, req *keepassgov1.Instant
|
||||
return &keepassgov1.InstantiateTemplateResponse{Entry: entryToProto(entry)}, nil
|
||||
}
|
||||
|
||||
func (s *Server) ListAttachments(_ context.Context, req *keepassgov1.ListAttachmentsRequest) (*keepassgov1.ListAttachmentsResponse, error) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
if s.locked {
|
||||
func (s *Server) ListAttachments(ctx context.Context, req *keepassgov1.ListAttachmentsRequest) (*keepassgov1.ListAttachmentsResponse, error) {
|
||||
model, locked := s.snapshotModel()
|
||||
if locked {
|
||||
return nil, status.Error(codes.FailedPrecondition, "vault is locked")
|
||||
}
|
||||
|
||||
entry, err := findEntryByID(s.model, req.GetEntryId())
|
||||
entry, err := findEntryByID(model, req.GetEntryId())
|
||||
if err != nil {
|
||||
return nil, status.Error(codes.NotFound, err.Error())
|
||||
}
|
||||
if _, err := s.authorizeEntryRequest(ctx, apitokens.OperationReadEntry, entry); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
names := make([]string, 0, len(entry.Attachments))
|
||||
for name := range entry.Attachments {
|
||||
@@ -499,14 +568,21 @@ func (s *Server) ListAttachments(_ context.Context, req *keepassgov1.ListAttachm
|
||||
return &keepassgov1.ListAttachmentsResponse{Names: names}, nil
|
||||
}
|
||||
|
||||
func (s *Server) UploadAttachment(_ context.Context, req *keepassgov1.UploadAttachmentRequest) (*keepassgov1.UploadAttachmentResponse, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
if s.locked {
|
||||
func (s *Server) UploadAttachment(ctx context.Context, req *keepassgov1.UploadAttachmentRequest) (*keepassgov1.UploadAttachmentResponse, error) {
|
||||
model, locked := s.snapshotModel()
|
||||
if locked {
|
||||
return nil, status.Error(codes.FailedPrecondition, "vault is locked")
|
||||
}
|
||||
entry, err := findEntryByID(model, req.GetEntryId())
|
||||
if err != nil {
|
||||
return nil, status.Error(codes.NotFound, err.Error())
|
||||
}
|
||||
if _, err := s.authorizeEntryRequest(ctx, apitokens.OperationMutateEntry, entry); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
entry, index, err := findMutableEntryByID(&s.model, req.GetEntryId())
|
||||
if err != nil {
|
||||
return nil, status.Error(codes.NotFound, err.Error())
|
||||
@@ -522,18 +598,19 @@ func (s *Server) UploadAttachment(_ context.Context, req *keepassgov1.UploadAtta
|
||||
return &keepassgov1.UploadAttachmentResponse{}, nil
|
||||
}
|
||||
|
||||
func (s *Server) DownloadAttachment(_ context.Context, req *keepassgov1.DownloadAttachmentRequest) (*keepassgov1.DownloadAttachmentResponse, error) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
if s.locked {
|
||||
func (s *Server) DownloadAttachment(ctx context.Context, req *keepassgov1.DownloadAttachmentRequest) (*keepassgov1.DownloadAttachmentResponse, error) {
|
||||
model, locked := s.snapshotModel()
|
||||
if locked {
|
||||
return nil, status.Error(codes.FailedPrecondition, "vault is locked")
|
||||
}
|
||||
|
||||
entry, err := findEntryByID(s.model, req.GetEntryId())
|
||||
entry, err := findEntryByID(model, req.GetEntryId())
|
||||
if err != nil {
|
||||
return nil, status.Error(codes.NotFound, err.Error())
|
||||
}
|
||||
if _, err := s.authorizeEntryRequest(ctx, apitokens.OperationReadEntry, entry); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
content, ok := entry.Attachments[req.GetName()]
|
||||
if !ok {
|
||||
@@ -545,14 +622,21 @@ func (s *Server) DownloadAttachment(_ context.Context, req *keepassgov1.Download
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Server) DeleteAttachment(_ context.Context, req *keepassgov1.DeleteAttachmentRequest) (*keepassgov1.DeleteAttachmentResponse, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
if s.locked {
|
||||
func (s *Server) DeleteAttachment(ctx context.Context, req *keepassgov1.DeleteAttachmentRequest) (*keepassgov1.DeleteAttachmentResponse, error) {
|
||||
model, locked := s.snapshotModel()
|
||||
if locked {
|
||||
return nil, status.Error(codes.FailedPrecondition, "vault is locked")
|
||||
}
|
||||
entry, err := findEntryByID(model, req.GetEntryId())
|
||||
if err != nil {
|
||||
return nil, status.Error(codes.NotFound, err.Error())
|
||||
}
|
||||
if _, err := s.authorizeEntryRequest(ctx, apitokens.OperationMutateEntry, entry); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
entry, index, err := findMutableEntryByID(&s.model, req.GetEntryId())
|
||||
if err != nil {
|
||||
return nil, status.Error(codes.NotFound, err.Error())
|
||||
@@ -572,15 +656,18 @@ func (s *Server) DeleteAttachment(_ context.Context, req *keepassgov1.DeleteAtta
|
||||
return &keepassgov1.DeleteAttachmentResponse{}, nil
|
||||
}
|
||||
|
||||
func (s *Server) CopyEntryField(_ context.Context, req *keepassgov1.CopyEntryFieldRequest) (*keepassgov1.CopyEntryFieldResponse, error) {
|
||||
s.mu.RLock()
|
||||
model := s.model
|
||||
locked := s.locked
|
||||
s.mu.RUnlock()
|
||||
|
||||
func (s *Server) CopyEntryField(ctx context.Context, req *keepassgov1.CopyEntryFieldRequest) (*keepassgov1.CopyEntryFieldResponse, error) {
|
||||
model, locked := s.snapshotModel()
|
||||
if locked {
|
||||
return nil, status.Error(codes.FailedPrecondition, "vault is locked")
|
||||
}
|
||||
entry, err := findEntryByID(model, req.GetId())
|
||||
if err != nil {
|
||||
return nil, status.Error(codes.NotFound, err.Error())
|
||||
}
|
||||
if _, err := s.authorizeEntryRequest(ctx, copyOperation(req.GetTarget()), entry); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
service := clipboard.Service{Writer: s.clipboard}
|
||||
if err := service.Copy(model, req.GetId(), clipboard.Target(req.GetTarget())); err != nil {
|
||||
@@ -597,10 +684,14 @@ func (s *Server) CopyEntryField(_ context.Context, req *keepassgov1.CopyEntryFie
|
||||
return &keepassgov1.CopyEntryFieldResponse{}, nil
|
||||
}
|
||||
|
||||
func (s *Server) GeneratePassword(_ context.Context, req *keepassgov1.GeneratePasswordRequest) (*keepassgov1.GeneratePasswordResponse, error) {
|
||||
func (s *Server) GeneratePassword(ctx context.Context, req *keepassgov1.GeneratePasswordRequest) (*keepassgov1.GeneratePasswordResponse, error) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
if _, err := s.authenticateRequest(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if s.locked {
|
||||
return nil, status.Error(codes.FailedPrecondition, "vault is locked")
|
||||
}
|
||||
@@ -665,27 +756,224 @@ func findMutableEntryByID(model *vault.Model, id string) (vault.Entry, int, erro
|
||||
return vault.Entry{}, -1, vault.ErrEntryNotFound
|
||||
}
|
||||
|
||||
func BearerTokenInterceptor(expectedToken string) grpc.UnaryServerInterceptor {
|
||||
func visibleModel(model vault.Model) vault.Model {
|
||||
out := model
|
||||
out.Entries = nil
|
||||
for _, entry := range model.Entries {
|
||||
token, ok, err := apitokens.TokenFromEntry(entry)
|
||||
if err == nil && ok && token.ID != "" {
|
||||
continue
|
||||
}
|
||||
out.Entries = append(out.Entries, entry)
|
||||
}
|
||||
out.Groups = nil
|
||||
for _, path := range model.Groups {
|
||||
if len(path) >= 2 && path[0] == "Root" && path[1] == "API Tokens" {
|
||||
continue
|
||||
}
|
||||
out.Groups = append(out.Groups, path)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (s *Server) snapshotModel() (vault.Model, bool) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
return s.model, s.locked
|
||||
}
|
||||
|
||||
var timeNow = func() time.Time { return time.Now().UTC() }
|
||||
|
||||
func (s *Server) authenticateRequest(ctx context.Context) (apitokens.Token, error) {
|
||||
md, ok := metadata.FromIncomingContext(ctx)
|
||||
if !ok {
|
||||
return apitokens.Token{}, status.Error(codes.Unauthenticated, "missing metadata")
|
||||
}
|
||||
values := md.Get("authorization")
|
||||
if len(values) == 0 {
|
||||
s.audit.Record(apiaudit.Event{Type: apiaudit.EventAuthRejected, Message: "missing authorization"})
|
||||
return apitokens.Token{}, status.Error(codes.Unauthenticated, "missing authorization")
|
||||
}
|
||||
const prefix = "Bearer "
|
||||
if !strings.HasPrefix(values[0], prefix) {
|
||||
s.audit.Record(apiaudit.Event{Type: apiaudit.EventAuthRejected, Message: "invalid bearer token"})
|
||||
return apitokens.Token{}, status.Error(codes.Unauthenticated, "invalid bearer token")
|
||||
}
|
||||
s.mu.RLock()
|
||||
tokens, err := apitokens.Entries(s.model)
|
||||
s.mu.RUnlock()
|
||||
if err != nil {
|
||||
return apitokens.Token{}, status.Errorf(codes.Internal, "load api tokens: %v", err)
|
||||
}
|
||||
token, err := apitokens.Authenticate(tokens, strings.TrimSpace(strings.TrimPrefix(values[0], prefix)), timeNow())
|
||||
if err != nil {
|
||||
switch err {
|
||||
case apitokens.ErrInvalidToken, apitokens.ErrExpiredToken, apitokens.ErrDisabledToken:
|
||||
s.audit.Record(apiaudit.Event{Type: apiaudit.EventAuthRejected, Message: err.Error()})
|
||||
return apitokens.Token{}, status.Error(codes.Unauthenticated, err.Error())
|
||||
default:
|
||||
return apitokens.Token{}, status.Errorf(codes.Internal, "authenticate api token: %v", err)
|
||||
}
|
||||
}
|
||||
return token, nil
|
||||
}
|
||||
|
||||
func (s *Server) authorizePathRequest(ctx context.Context, op apitokens.Operation, path []string) (apitokens.Token, error) {
|
||||
token, err := s.authenticateRequest(ctx)
|
||||
if err != nil {
|
||||
return apitokens.Token{}, err
|
||||
}
|
||||
return s.authorizeResourceRequest(ctx, token, op, apitokens.Resource{Kind: apitokens.ResourceGroup, Path: path})
|
||||
}
|
||||
|
||||
func (s *Server) authorizeEntryRequest(ctx context.Context, op apitokens.Operation, entry vault.Entry) (apitokens.Token, error) {
|
||||
token, err := s.authenticateRequest(ctx)
|
||||
if err != nil {
|
||||
return apitokens.Token{}, err
|
||||
}
|
||||
return s.authorizeResourceRequest(ctx, token, op, apitokens.Resource{Kind: apitokens.ResourceEntry, EntryID: entry.ID, Path: entry.Path})
|
||||
}
|
||||
|
||||
func (s *Server) authorizeResourceRequest(ctx context.Context, token apitokens.Token, op apitokens.Operation, resource apitokens.Resource) (apitokens.Token, error) {
|
||||
switch apitokens.Evaluate(token, op, resource) {
|
||||
case apitokens.DecisionAllow:
|
||||
return token, nil
|
||||
case apitokens.DecisionDeny:
|
||||
return apitokens.Token{}, status.Error(codes.PermissionDenied, "access is not allowed for this token")
|
||||
case apitokens.DecisionPrompt:
|
||||
s.audit.Record(apiaudit.Event{
|
||||
Type: apiaudit.EventApprovalRequested,
|
||||
TokenID: token.ID,
|
||||
TokenName: token.Name,
|
||||
ClientName: token.ClientName,
|
||||
Operation: op,
|
||||
Resource: resource,
|
||||
})
|
||||
result, err := s.approvals.Request(ctx, token, op, resource)
|
||||
if result.Rule != nil {
|
||||
if persistErr := s.persistApprovalRule(token.ID, *result.Rule); persistErr != nil {
|
||||
return apitokens.Token{}, status.Errorf(codes.Internal, "persist approval decision: %v", persistErr)
|
||||
}
|
||||
}
|
||||
switch {
|
||||
case err == nil:
|
||||
s.audit.Record(apiaudit.Event{
|
||||
Type: apiaudit.EventApprovalAllowed,
|
||||
TokenID: token.ID,
|
||||
TokenName: token.Name,
|
||||
ClientName: token.ClientName,
|
||||
Operation: op,
|
||||
Resource: resource,
|
||||
})
|
||||
return token, nil
|
||||
case errors.Is(err, apiapproval.ErrRequestDenied):
|
||||
s.audit.Record(apiaudit.Event{
|
||||
Type: apiaudit.EventApprovalDenied,
|
||||
TokenID: token.ID,
|
||||
TokenName: token.Name,
|
||||
ClientName: token.ClientName,
|
||||
Operation: op,
|
||||
Resource: resource,
|
||||
})
|
||||
return apitokens.Token{}, status.Error(codes.PermissionDenied, "access denied by user approval")
|
||||
case errors.Is(err, apiapproval.ErrRequestCanceled):
|
||||
s.audit.Record(apiaudit.Event{
|
||||
Type: apiaudit.EventApprovalCanceled,
|
||||
TokenID: token.ID,
|
||||
TokenName: token.Name,
|
||||
ClientName: token.ClientName,
|
||||
Operation: op,
|
||||
Resource: resource,
|
||||
})
|
||||
return apitokens.Token{}, status.Error(codes.Unauthenticated, "authorization request canceled")
|
||||
case errors.Is(err, apiapproval.ErrRequestTimedOut):
|
||||
s.audit.Record(apiaudit.Event{
|
||||
Type: apiaudit.EventApprovalTimedOut,
|
||||
TokenID: token.ID,
|
||||
TokenName: token.Name,
|
||||
ClientName: token.ClientName,
|
||||
Operation: op,
|
||||
Resource: resource,
|
||||
})
|
||||
return apitokens.Token{}, status.Error(codes.DeadlineExceeded, "authorization request timed out")
|
||||
case errors.Is(err, context.Canceled):
|
||||
return apitokens.Token{}, status.Error(codes.Canceled, "authorization request canceled")
|
||||
case errors.Is(err, context.DeadlineExceeded):
|
||||
return apitokens.Token{}, status.Error(codes.DeadlineExceeded, "authorization request timed out")
|
||||
default:
|
||||
return apitokens.Token{}, status.Errorf(codes.Internal, "await authorization request: %v", err)
|
||||
}
|
||||
default:
|
||||
return apitokens.Token{}, status.Error(codes.PermissionDenied, "access is not allowed for this token")
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) persistApprovalRule(tokenID string, rule apitokens.PolicyRule) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
for i, entry := range s.model.Entries {
|
||||
token, ok, err := apitokens.TokenFromEntry(entry)
|
||||
if err != nil || !ok || token.ID != tokenID {
|
||||
continue
|
||||
}
|
||||
if !hasPolicyRule(token.Policies, rule) {
|
||||
token.Policies = append(token.Policies, rule)
|
||||
}
|
||||
s.model.Entries[i] = token.Entry(entry.Path)
|
||||
s.dirty = true
|
||||
if lifecycle, ok := s.lifecycle.(modelReplaceableLifecycle); ok {
|
||||
lifecycle.Replace(s.model)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
return status.Error(codes.NotFound, "api token entry not found")
|
||||
}
|
||||
|
||||
func hasPolicyRule(rules []apitokens.PolicyRule, target apitokens.PolicyRule) bool {
|
||||
for _, rule := range rules {
|
||||
if rule.Effect != target.Effect || rule.Operation != target.Operation {
|
||||
continue
|
||||
}
|
||||
if rule.Resource.Kind != target.Resource.Kind || rule.Resource.EntryID != target.Resource.EntryID {
|
||||
continue
|
||||
}
|
||||
if slices.Equal(rule.Resource.Path, target.Resource.Path) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func copyOperation(target string) apitokens.Operation {
|
||||
switch clipboard.Target(target) {
|
||||
case clipboard.TargetUsername:
|
||||
return apitokens.OperationCopyUsername
|
||||
case clipboard.TargetURL:
|
||||
return apitokens.OperationCopyURL
|
||||
default:
|
||||
return apitokens.OperationCopyPassword
|
||||
}
|
||||
}
|
||||
|
||||
func AuthInterceptor(server *Server) grpc.UnaryServerInterceptor {
|
||||
return func(
|
||||
ctx context.Context,
|
||||
req any,
|
||||
info *grpc.UnaryServerInfo,
|
||||
handler grpc.UnaryHandler,
|
||||
) (any, error) {
|
||||
md, ok := metadata.FromIncomingContext(ctx)
|
||||
if !ok {
|
||||
return nil, status.Error(codes.Unauthenticated, "missing metadata")
|
||||
switch info.FullMethod {
|
||||
case "/keepassgo.v1.VaultService/GetSessionStatus",
|
||||
"/keepassgo.v1.VaultService/OpenVault",
|
||||
"/keepassgo.v1.VaultService/OpenRemoteVault",
|
||||
"/keepassgo.v1.VaultService/SaveVault",
|
||||
"/keepassgo.v1.VaultService/LockVault",
|
||||
"/keepassgo.v1.VaultService/UnlockVault":
|
||||
if _, err := server.authenticateRequest(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
values := md.Get("authorization")
|
||||
if len(values) == 0 {
|
||||
return nil, status.Error(codes.Unauthenticated, "missing authorization")
|
||||
}
|
||||
|
||||
if values[0] != "Bearer "+expectedToken {
|
||||
return nil, status.Error(codes.Unauthenticated, "invalid bearer token")
|
||||
}
|
||||
|
||||
return handler(ctx, req)
|
||||
}
|
||||
}
|
||||
@@ -3,15 +3,21 @@ package api
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"net"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.julianfamily.org/keepassgo/passwords"
|
||||
"git.julianfamily.org/keepassgo/internal/apiapproval"
|
||||
"git.julianfamily.org/keepassgo/internal/apiaudit"
|
||||
"git.julianfamily.org/keepassgo/internal/apitokens"
|
||||
"git.julianfamily.org/keepassgo/internal/passwords"
|
||||
"git.julianfamily.org/keepassgo/internal/session"
|
||||
"git.julianfamily.org/keepassgo/internal/vault"
|
||||
"git.julianfamily.org/keepassgo/internal/webdav"
|
||||
keepassgov1 "git.julianfamily.org/keepassgo/proto/keepassgo/v1"
|
||||
"git.julianfamily.org/keepassgo/session"
|
||||
"git.julianfamily.org/keepassgo/vault"
|
||||
"git.julianfamily.org/keepassgo/webdav"
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/credentials/insecure"
|
||||
@@ -32,6 +38,243 @@ func TestVaultServiceRejectsRequestsWithoutBearerToken(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestVaultServiceRejectsExpiredAPITokens(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
expiredAt := time.Date(2026, 3, 29, 11, 59, 0, 0, time.UTC)
|
||||
token, _, err := apitokens.Issue("Expired", "grpc-test", &expiredAt, time.Date(2026, 3, 29, 10, 0, 0, 0, time.UTC))
|
||||
if err != nil {
|
||||
t.Fatalf("Issue() error = %v", err)
|
||||
}
|
||||
token.SecretHash = hashSecretForTest(defaultTestTokenSecret)
|
||||
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"},
|
||||
},
|
||||
token.Entry([]string{"Root", "API Tokens"}),
|
||||
},
|
||||
})
|
||||
defer cleanup()
|
||||
|
||||
oldNow := timeNow
|
||||
timeNow = func() time.Time { return time.Date(2026, 3, 29, 12, 0, 0, 0, time.UTC) }
|
||||
defer func() { timeNow = oldNow }()
|
||||
|
||||
_, err = client.ListEntries(tokenContext(defaultTestTokenSecret), &keepassgov1.ListEntriesRequest{Path: []string{"Root", "Internet"}})
|
||||
if status.Code(err) != codes.Unauthenticated {
|
||||
t.Fatalf("ListEntries() code = %v, want %v for expired token", status.Code(err), codes.Unauthenticated)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVaultServiceRejectsUnauthorizedEntryAccess(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", "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.CopyEntryField(tokenContext(defaultTestTokenSecret), &keepassgov1.CopyEntryFieldRequest{Id: "vault-console", Target: "password"})
|
||||
if status.Code(err) != codes.PermissionDenied {
|
||||
t.Fatalf("CopyEntryField() code = %v, want %v", status.Code(err), codes.PermissionDenied)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVaultServicePromptsAndResumesWhenApproved(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
model := 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),
|
||||
},
|
||||
}
|
||||
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{"Root", "Internet"}})
|
||||
respCh <- resp
|
||||
errCh <- err
|
||||
}()
|
||||
|
||||
pending := waitForServerPendingApproval(t, service, 1)[0]
|
||||
if pending.Operation != apitokens.OperationListEntries {
|
||||
t.Fatalf("pending.Operation = %q, want %q", pending.Operation, apitokens.OperationListEntries)
|
||||
}
|
||||
if _, _, err := service.ResolveApproval(pending.ID, apiapproval.OutcomeAllowOnce); err != nil {
|
||||
t.Fatalf("ResolveApproval(allow) error = %v", err)
|
||||
}
|
||||
|
||||
resp := <-respCh
|
||||
if err := <-errCh; err != nil {
|
||||
t.Fatalf("ListEntries() error = %v", err)
|
||||
}
|
||||
if len(resp.Entries) != 1 || resp.Entries[0].Id != "vault-console" {
|
||||
t.Fatalf("ListEntries().Entries = %#v, want vault-console", resp.Entries)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVaultServicePersistsPermanentDenyApproval(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
model := 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),
|
||||
},
|
||||
}
|
||||
client, _, service, cleanup := newTestHarnessForModel(t, model)
|
||||
defer cleanup()
|
||||
service.approvals = apiapproval.NewBroker(time.Minute)
|
||||
|
||||
errCh := make(chan error, 1)
|
||||
go func() {
|
||||
_, err := client.ListEntries(tokenContext(defaultTestTokenSecret), &keepassgov1.ListEntriesRequest{Path: []string{"Root", "Internet"}})
|
||||
errCh <- err
|
||||
}()
|
||||
|
||||
pending := waitForServerPendingApproval(t, service, 1)[0]
|
||||
if _, _, err := service.ResolveApproval(pending.ID, apiapproval.OutcomeDenyPermanent); err != nil {
|
||||
t.Fatalf("ResolveApproval(deny permanent) error = %v", err)
|
||||
}
|
||||
|
||||
if err := <-errCh; status.Code(err) != codes.PermissionDenied {
|
||||
t.Fatalf("ListEntries() code = %v, want %v", status.Code(err), codes.PermissionDenied)
|
||||
}
|
||||
|
||||
service.mu.RLock()
|
||||
tokens, err := apitokens.Entries(service.model)
|
||||
service.mu.RUnlock()
|
||||
if err != nil {
|
||||
t.Fatalf("Entries() error = %v", err)
|
||||
}
|
||||
decision := apitokens.Evaluate(tokens[0], apitokens.OperationListEntries, apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root", "Internet"}})
|
||||
if decision != apitokens.DecisionDeny {
|
||||
t.Fatalf("Evaluate() after permanent deny = %q, want %q", decision, apitokens.DecisionDeny)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVaultServiceReturnsCanceledForCanceledApproval(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
model := vault.Model{
|
||||
Entries: []vault.Entry{
|
||||
{ID: "vault-console", Title: "Vault Console", Path: []string{"Root", "Internet"}},
|
||||
testAPITokenEntry(t),
|
||||
},
|
||||
}
|
||||
client, _, service, cleanup := newTestHarnessForModel(t, model)
|
||||
defer cleanup()
|
||||
service.approvals = apiapproval.NewBroker(time.Minute)
|
||||
|
||||
errCh := make(chan error, 1)
|
||||
go func() {
|
||||
_, err := client.ListEntries(tokenContext(defaultTestTokenSecret), &keepassgov1.ListEntriesRequest{Path: []string{"Root", "Internet"}})
|
||||
errCh <- err
|
||||
}()
|
||||
|
||||
pending := waitForServerPendingApproval(t, service, 1)[0]
|
||||
if _, _, err := service.ResolveApproval(pending.ID, apiapproval.OutcomeCancel); err != nil {
|
||||
t.Fatalf("ResolveApproval(cancel) error = %v", err)
|
||||
}
|
||||
|
||||
if err := <-errCh; status.Code(err) != codes.Unauthenticated {
|
||||
t.Fatalf("ListEntries() code = %v, want %v", status.Code(err), codes.Unauthenticated)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVaultServiceTimesOutPendingApproval(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
model := vault.Model{
|
||||
Entries: []vault.Entry{
|
||||
{ID: "vault-console", Title: "Vault Console", Path: []string{"Root", "Internet"}},
|
||||
testAPITokenEntry(t),
|
||||
},
|
||||
}
|
||||
client, _, service, cleanup := newTestHarnessForModel(t, model)
|
||||
defer cleanup()
|
||||
service.approvals = apiapproval.NewBroker(20 * time.Millisecond)
|
||||
|
||||
_, err := client.ListEntries(tokenContext(defaultTestTokenSecret), &keepassgov1.ListEntriesRequest{Path: []string{"Root", "Internet"}})
|
||||
if status.Code(err) != codes.DeadlineExceeded {
|
||||
t.Fatalf("ListEntries() code = %v, want %v", status.Code(err), codes.DeadlineExceeded)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVaultServiceRecordsApprovalAuditEvents(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
model := vault.Model{
|
||||
Entries: []vault.Entry{
|
||||
{ID: "vault-console", Title: "Vault Console", Path: []string{"Root", "Internet"}},
|
||||
testAPITokenEntry(t),
|
||||
},
|
||||
}
|
||||
client, _, service, cleanup := newTestHarnessForModel(t, model)
|
||||
defer cleanup()
|
||||
service.approvals = apiapproval.NewBroker(time.Minute)
|
||||
|
||||
errCh := make(chan error, 1)
|
||||
go func() {
|
||||
_, err := client.ListEntries(tokenContext(defaultTestTokenSecret), &keepassgov1.ListEntriesRequest{Path: []string{"Root", "Internet"}})
|
||||
errCh <- err
|
||||
}()
|
||||
|
||||
pending := waitForServerPendingApproval(t, service, 1)[0]
|
||||
if _, _, err := service.ResolveApproval(pending.ID, apiapproval.OutcomeAllowPermanent); err != nil {
|
||||
t.Fatalf("ResolveApproval(allow permanent) error = %v", err)
|
||||
}
|
||||
if err := <-errCh; err != nil {
|
||||
t.Fatalf("ListEntries() error = %v", err)
|
||||
}
|
||||
|
||||
events := service.AuditLog().Events()
|
||||
if len(events) < 2 {
|
||||
t.Fatalf("len(AuditLog().Events()) = %d, want at least 2", len(events))
|
||||
}
|
||||
if events[0].Type != apiaudit.EventApprovalAllowed || events[1].Type != apiaudit.EventApprovalRequested {
|
||||
t.Fatalf("AuditLog().Events() = %#v, want allowed then requested", events[:2])
|
||||
}
|
||||
}
|
||||
|
||||
func TestVaultServiceReportsSessionStatusAndSupportsLockUnlock(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -39,11 +282,11 @@ func TestVaultServiceReportsSessionStatusAndSupportsLockUnlock(t *testing.T) {
|
||||
model: vault.Model{
|
||||
Entries: []vault.Entry{
|
||||
{
|
||||
ID: "git-server",
|
||||
Title: "Git Server",
|
||||
Username: "joejulian",
|
||||
ID: "vault-console",
|
||||
Title: "Vault Console",
|
||||
Username: "dannyocean",
|
||||
Password: "token-1",
|
||||
URL: "https://git.julianfamily.org",
|
||||
URL: "https://vault.crew.example.invalid",
|
||||
Path: []string{"Root", "Internet"},
|
||||
},
|
||||
},
|
||||
@@ -52,7 +295,7 @@ func TestVaultServiceReportsSessionStatusAndSupportsLockUnlock(t *testing.T) {
|
||||
client, _, cleanup := newTestClientWithLifecycle(t, lifecycle)
|
||||
defer cleanup()
|
||||
|
||||
ctx := metadata.AppendToOutgoingContext(context.Background(), "authorization", "Bearer test-token")
|
||||
ctx := tokenContext(defaultTestTokenSecret)
|
||||
statusResp, err := client.GetSessionStatus(ctx, &keepassgov1.GetSessionStatusRequest{})
|
||||
if err != nil {
|
||||
t.Fatalf("GetSessionStatus() error = %v", err)
|
||||
@@ -108,7 +351,7 @@ func TestVaultServiceLockAndUnlockUseLifecycleBackend(t *testing.T) {
|
||||
client, _, cleanup := newTestClientWithLifecycle(t, lifecycle)
|
||||
defer cleanup()
|
||||
|
||||
ctx := metadata.AppendToOutgoingContext(context.Background(), "authorization", "Bearer test-token")
|
||||
ctx := tokenContext(defaultTestTokenSecret)
|
||||
if _, err := client.OpenVault(ctx, &keepassgov1.OpenVaultRequest{
|
||||
Path: "/tmp/test.kdbx",
|
||||
Password: lifecycle.unlockPassword,
|
||||
@@ -182,7 +425,7 @@ func TestVaultServiceOpensAndSavesVaultThroughLifecycleBackend(t *testing.T) {
|
||||
client, _, cleanup := newTestClientWithLifecycle(t, lifecycle)
|
||||
defer cleanup()
|
||||
|
||||
ctx := metadata.AppendToOutgoingContext(context.Background(), "authorization", "Bearer test-token")
|
||||
ctx := tokenContext(defaultTestTokenSecret)
|
||||
if _, err := client.OpenVault(ctx, &keepassgov1.OpenVaultRequest{
|
||||
Path: "/tmp/test.kdbx",
|
||||
Password: "correct horse battery staple",
|
||||
@@ -211,7 +454,7 @@ func TestVaultServiceOpensAndSavesVaultThroughLifecycleBackend(t *testing.T) {
|
||||
if _, err := client.OpenRemoteVault(ctx, &keepassgov1.OpenRemoteVaultRequest{
|
||||
BaseUrl: "https://dav.example.com",
|
||||
Path: "vaults/main.kdbx",
|
||||
Username: "jjulian",
|
||||
Username: "rustyryan",
|
||||
Password: "dav-token",
|
||||
MasterPassword: "correct horse battery staple",
|
||||
}); err != nil {
|
||||
@@ -228,7 +471,7 @@ func TestVaultServiceLifecycleMethodsRequireLifecycleBackend(t *testing.T) {
|
||||
client, _, cleanup := newTestClient(t)
|
||||
defer cleanup()
|
||||
|
||||
ctx := metadata.AppendToOutgoingContext(context.Background(), "authorization", "Bearer test-token")
|
||||
ctx := tokenContext(defaultTestTokenSecret)
|
||||
|
||||
testCases := []struct {
|
||||
name string
|
||||
@@ -350,7 +593,7 @@ func TestVaultServiceLifecycleMethodsMapBackendErrors(t *testing.T) {
|
||||
client, _, cleanup := newTestClientWithLifecycle(t, lifecycle)
|
||||
defer cleanup()
|
||||
|
||||
ctx := metadata.AppendToOutgoingContext(context.Background(), "authorization", "Bearer test-token")
|
||||
ctx := tokenContext(defaultTestTokenSecret)
|
||||
err := tt.call(client, ctx)
|
||||
if status.Code(err) != tt.want {
|
||||
t.Fatalf("%s code = %v, want %v", tt.name, status.Code(err), tt.want)
|
||||
@@ -365,7 +608,7 @@ func TestVaultServiceListsEntriesForAuthorizedClients(t *testing.T) {
|
||||
client, _, cleanup := newTestClient(t)
|
||||
defer cleanup()
|
||||
|
||||
ctx := metadata.AppendToOutgoingContext(context.Background(), "authorization", "Bearer test-token")
|
||||
ctx := tokenContext(defaultTestTokenSecret)
|
||||
resp, err := client.ListEntries(ctx, &keepassgov1.ListEntriesRequest{Path: []string{"Root", "Internet"}})
|
||||
if err != nil {
|
||||
t.Fatalf("ListEntries() error = %v", err)
|
||||
@@ -375,8 +618,8 @@ func TestVaultServiceListsEntriesForAuthorizedClients(t *testing.T) {
|
||||
t.Fatalf("len(ListEntries().Entries) = %d, want 1", len(resp.Entries))
|
||||
}
|
||||
|
||||
if resp.Entries[0].Title != "Git Server" {
|
||||
t.Fatalf("ListEntries().Entries[0].Title = %q, want %q", resp.Entries[0].Title, "Git Server")
|
||||
if resp.Entries[0].Title != "Vault Console" {
|
||||
t.Fatalf("ListEntries().Entries[0].Title = %q, want %q", resp.Entries[0].Title, "Vault Console")
|
||||
}
|
||||
if got := resp.Entries[0].Fields["X-Role"]; got != "automation" {
|
||||
t.Fatalf("ListEntries().Entries[0].Fields[X-Role] = %q, want %q", got, "automation")
|
||||
@@ -389,7 +632,7 @@ func TestVaultServiceListsCreatesAndRenamesGroupsForAuthorizedClients(t *testing
|
||||
client, _, cleanup := newTestClient(t)
|
||||
defer cleanup()
|
||||
|
||||
ctx := metadata.AppendToOutgoingContext(context.Background(), "authorization", "Bearer test-token")
|
||||
ctx := tokenContext(defaultTestTokenSecret)
|
||||
|
||||
listed, err := client.ListGroups(ctx, &keepassgov1.ListGroupsRequest{Path: []string{"Root"}})
|
||||
if err != nil {
|
||||
@@ -436,7 +679,7 @@ func TestVaultServiceDeletesEmptyGroupsAndRejectsNonEmptyGroups(t *testing.T) {
|
||||
client, _, cleanup := newTestClient(t)
|
||||
defer cleanup()
|
||||
|
||||
ctx := metadata.AppendToOutgoingContext(context.Background(), "authorization", "Bearer test-token")
|
||||
ctx := tokenContext(defaultTestTokenSecret)
|
||||
if _, err := client.CreateGroup(ctx, &keepassgov1.CreateGroupRequest{
|
||||
ParentPath: []string{"Root"},
|
||||
Name: "Finance",
|
||||
@@ -472,7 +715,7 @@ func TestVaultServiceGeneratesPasswordsForAuthorizedClients(t *testing.T) {
|
||||
client, _, cleanup := newTestClient(t)
|
||||
defer cleanup()
|
||||
|
||||
ctx := metadata.AppendToOutgoingContext(context.Background(), "authorization", "Bearer test-token")
|
||||
ctx := tokenContext(defaultTestTokenSecret)
|
||||
resp, err := client.GeneratePassword(ctx, &keepassgov1.GeneratePasswordRequest{Profile: "strong"})
|
||||
if err != nil {
|
||||
t.Fatalf("GeneratePassword() error = %v", err)
|
||||
@@ -489,7 +732,7 @@ func TestVaultServiceGeneratePasswordRejectsUnknownProfiles(t *testing.T) {
|
||||
client, _, cleanup := newTestClient(t)
|
||||
defer cleanup()
|
||||
|
||||
ctx := metadata.AppendToOutgoingContext(context.Background(), "authorization", "Bearer test-token")
|
||||
ctx := tokenContext(defaultTestTokenSecret)
|
||||
_, err := client.GeneratePassword(ctx, &keepassgov1.GeneratePasswordRequest{Profile: "invalid"})
|
||||
if status.Code(err) != codes.InvalidArgument {
|
||||
t.Fatalf("GeneratePassword() code = %v, want %v", status.Code(err), codes.InvalidArgument)
|
||||
@@ -502,9 +745,9 @@ func TestVaultServiceCopiesEntryFieldsForAuthorizedClients(t *testing.T) {
|
||||
client, clipboardWriter, cleanup := newTestClient(t)
|
||||
defer cleanup()
|
||||
|
||||
ctx := metadata.AppendToOutgoingContext(context.Background(), "authorization", "Bearer test-token")
|
||||
ctx := tokenContext(defaultTestTokenSecret)
|
||||
if _, err := client.CopyEntryField(ctx, &keepassgov1.CopyEntryFieldRequest{
|
||||
Id: "git-server",
|
||||
Id: "vault-console",
|
||||
Target: "password",
|
||||
}); err != nil {
|
||||
t.Fatalf("CopyEntryField() error = %v", err)
|
||||
@@ -521,14 +764,14 @@ func TestVaultServiceUpsertsEntriesForAuthorizedClients(t *testing.T) {
|
||||
client, _, cleanup := newTestClient(t)
|
||||
defer cleanup()
|
||||
|
||||
ctx := metadata.AppendToOutgoingContext(context.Background(), "authorization", "Bearer test-token")
|
||||
ctx := tokenContext(defaultTestTokenSecret)
|
||||
upserted, err := client.UpsertEntry(ctx, &keepassgov1.UpsertEntryRequest{
|
||||
Entry: &keepassgov1.Entry{
|
||||
Id: "ha-codex",
|
||||
Title: "Home Assistant (Codex)",
|
||||
Id: "surveillance-console",
|
||||
Title: "Surveillance Console",
|
||||
Username: "codex",
|
||||
Password: "token-2",
|
||||
Url: "https://lights.julianfamily.org",
|
||||
Url: "https://surveillance.crew.example.invalid",
|
||||
Fields: map[string]string{
|
||||
"X-Role": "lights-admin",
|
||||
},
|
||||
@@ -539,8 +782,8 @@ func TestVaultServiceUpsertsEntriesForAuthorizedClients(t *testing.T) {
|
||||
t.Fatalf("UpsertEntry() error = %v", err)
|
||||
}
|
||||
|
||||
if upserted.Entry.Title != "Home Assistant (Codex)" {
|
||||
t.Fatalf("UpsertEntry().Entry.Title = %q, want %q", upserted.Entry.Title, "Home Assistant (Codex)")
|
||||
if upserted.Entry.Title != "Surveillance Console" {
|
||||
t.Fatalf("UpsertEntry().Entry.Title = %q, want %q", upserted.Entry.Title, "Surveillance Console")
|
||||
}
|
||||
if got := upserted.Entry.Fields["X-Role"]; got != "lights-admin" {
|
||||
t.Fatalf("UpsertEntry().Entry.Fields[X-Role] = %q, want %q", got, "lights-admin")
|
||||
@@ -565,8 +808,8 @@ func TestVaultServiceDeletesAndRestoresEntriesForAuthorizedClients(t *testing.T)
|
||||
client, _, cleanup := newTestClient(t)
|
||||
defer cleanup()
|
||||
|
||||
ctx := metadata.AppendToOutgoingContext(context.Background(), "authorization", "Bearer test-token")
|
||||
if _, err := client.DeleteEntry(ctx, &keepassgov1.DeleteEntryRequest{Id: "git-server"}); err != nil {
|
||||
ctx := tokenContext(defaultTestTokenSecret)
|
||||
if _, err := client.DeleteEntry(ctx, &keepassgov1.DeleteEntryRequest{Id: "vault-console"}); err != nil {
|
||||
t.Fatalf("DeleteEntry() error = %v", err)
|
||||
}
|
||||
|
||||
@@ -579,13 +822,13 @@ func TestVaultServiceDeletesAndRestoresEntriesForAuthorizedClients(t *testing.T)
|
||||
t.Fatalf("len(ListEntries().Entries) = %d, want 0 after delete", len(listed.Entries))
|
||||
}
|
||||
|
||||
restored, err := client.RestoreEntry(ctx, &keepassgov1.RestoreEntryRequest{Id: "git-server"})
|
||||
restored, err := client.RestoreEntry(ctx, &keepassgov1.RestoreEntryRequest{Id: "vault-console"})
|
||||
if err != nil {
|
||||
t.Fatalf("RestoreEntry() error = %v", err)
|
||||
}
|
||||
|
||||
if restored.Entry.Title != "Git Server" {
|
||||
t.Fatalf("RestoreEntry().Entry.Title = %q, want %q", restored.Entry.Title, "Git Server")
|
||||
if restored.Entry.Title != "Vault Console" {
|
||||
t.Fatalf("RestoreEntry().Entry.Title = %q, want %q", restored.Entry.Title, "Vault Console")
|
||||
}
|
||||
|
||||
listed, err = client.ListEntries(ctx, &keepassgov1.ListEntriesRequest{Path: []string{"Root", "Internet"}})
|
||||
@@ -593,8 +836,8 @@ func TestVaultServiceDeletesAndRestoresEntriesForAuthorizedClients(t *testing.T)
|
||||
t.Fatalf("ListEntries() error = %v", err)
|
||||
}
|
||||
|
||||
if len(listed.Entries) != 1 || listed.Entries[0].Title != "Git Server" {
|
||||
t.Fatalf("ListEntries().Entries = %#v, want restored Git Server entry", listed.Entries)
|
||||
if len(listed.Entries) != 1 || listed.Entries[0].Title != "Vault Console" {
|
||||
t.Fatalf("ListEntries().Entries = %#v, want restored Vault Console entry", listed.Entries)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -604,7 +847,7 @@ func TestVaultServiceListsAndInstantiatesTemplatesForAuthorizedClients(t *testin
|
||||
client, _, cleanup := newTestClient(t)
|
||||
defer cleanup()
|
||||
|
||||
ctx := metadata.AppendToOutgoingContext(context.Background(), "authorization", "Bearer test-token")
|
||||
ctx := tokenContext(defaultTestTokenSecret)
|
||||
templates, err := client.ListTemplates(ctx, &keepassgov1.ListTemplatesRequest{})
|
||||
if err != nil {
|
||||
t.Fatalf("ListTemplates() error = %v", err)
|
||||
@@ -620,11 +863,11 @@ func TestVaultServiceListsAndInstantiatesTemplatesForAuthorizedClients(t *testin
|
||||
instantiated, err := client.InstantiateTemplate(ctx, &keepassgov1.InstantiateTemplateRequest{
|
||||
TemplateId: "website-login",
|
||||
Overrides: &keepassgov1.Entry{
|
||||
Id: "dynadot",
|
||||
Title: "Dynadot",
|
||||
Username: "jjulian",
|
||||
Id: "bellagio",
|
||||
Title: "Bellagio",
|
||||
Username: "rustyryan",
|
||||
Password: "hunter2",
|
||||
Url: "https://www.dynadot.com",
|
||||
Url: "https://bellagio.example.invalid",
|
||||
Fields: map[string]string{
|
||||
"Environment": "staging",
|
||||
},
|
||||
@@ -636,8 +879,8 @@ func TestVaultServiceListsAndInstantiatesTemplatesForAuthorizedClients(t *testin
|
||||
t.Fatalf("InstantiateTemplate() error = %v", err)
|
||||
}
|
||||
|
||||
if instantiated.Entry.Title != "Dynadot" || instantiated.Entry.Notes != "Reusable template for website accounts." {
|
||||
t.Fatalf("InstantiateTemplate().Entry = %#v, want Dynadot entry with template notes", instantiated.Entry)
|
||||
if instantiated.Entry.Title != "Bellagio" || instantiated.Entry.Notes != "Reusable template for website accounts." {
|
||||
t.Fatalf("InstantiateTemplate().Entry = %#v, want Bellagio entry with template notes", instantiated.Entry)
|
||||
}
|
||||
if got := instantiated.Entry.Fields["Environment"]; got != "staging" {
|
||||
t.Fatalf("InstantiateTemplate().Entry.Fields[Environment] = %q, want %q", got, "staging")
|
||||
@@ -659,7 +902,7 @@ func TestVaultServiceUpsertsAndDeletesTemplatesForAuthorizedClients(t *testing.T
|
||||
client, _, cleanup := newTestClient(t)
|
||||
defer cleanup()
|
||||
|
||||
ctx := metadata.AppendToOutgoingContext(context.Background(), "authorization", "Bearer test-token")
|
||||
ctx := tokenContext(defaultTestTokenSecret)
|
||||
upserted, err := client.UpsertTemplate(ctx, &keepassgov1.UpsertTemplateRequest{
|
||||
Template: &keepassgov1.Entry{
|
||||
Id: "website-login",
|
||||
@@ -712,8 +955,8 @@ func TestVaultServiceListsAndRestoresEntryHistoryForAuthorizedClients(t *testing
|
||||
client, _, cleanup := newTestClient(t)
|
||||
defer cleanup()
|
||||
|
||||
ctx := metadata.AppendToOutgoingContext(context.Background(), "authorization", "Bearer test-token")
|
||||
history, err := client.ListEntryHistory(ctx, &keepassgov1.ListEntryHistoryRequest{Id: "git-server"})
|
||||
ctx := tokenContext(defaultTestTokenSecret)
|
||||
history, err := client.ListEntryHistory(ctx, &keepassgov1.ListEntryHistoryRequest{Id: "vault-console"})
|
||||
if err != nil {
|
||||
t.Fatalf("ListEntryHistory() error = %v", err)
|
||||
}
|
||||
@@ -722,7 +965,7 @@ func TestVaultServiceListsAndRestoresEntryHistoryForAuthorizedClients(t *testing
|
||||
}
|
||||
|
||||
restored, err := client.RestoreEntryHistory(ctx, &keepassgov1.RestoreEntryHistoryRequest{
|
||||
Id: "git-server",
|
||||
Id: "vault-console",
|
||||
HistoryIndex: 0,
|
||||
})
|
||||
if err != nil {
|
||||
@@ -739,18 +982,18 @@ func TestVaultServiceListsUploadsDownloadsAndDeletesAttachments(t *testing.T) {
|
||||
client, _, cleanup := newTestClient(t)
|
||||
defer cleanup()
|
||||
|
||||
ctx := metadata.AppendToOutgoingContext(context.Background(), "authorization", "Bearer test-token")
|
||||
ctx := tokenContext(defaultTestTokenSecret)
|
||||
uploaded := []byte("attachment-content")
|
||||
|
||||
if _, err := client.UploadAttachment(ctx, &keepassgov1.UploadAttachmentRequest{
|
||||
EntryId: "git-server",
|
||||
EntryId: "vault-console",
|
||||
Name: "token.txt",
|
||||
Content: uploaded,
|
||||
}); err != nil {
|
||||
t.Fatalf("UploadAttachment() error = %v", err)
|
||||
}
|
||||
|
||||
listed, err := client.ListAttachments(ctx, &keepassgov1.ListAttachmentsRequest{EntryId: "git-server"})
|
||||
listed, err := client.ListAttachments(ctx, &keepassgov1.ListAttachmentsRequest{EntryId: "vault-console"})
|
||||
if err != nil {
|
||||
t.Fatalf("ListAttachments() error = %v", err)
|
||||
}
|
||||
@@ -759,7 +1002,7 @@ func TestVaultServiceListsUploadsDownloadsAndDeletesAttachments(t *testing.T) {
|
||||
}
|
||||
|
||||
downloaded, err := client.DownloadAttachment(ctx, &keepassgov1.DownloadAttachmentRequest{
|
||||
EntryId: "git-server",
|
||||
EntryId: "vault-console",
|
||||
Name: "token.txt",
|
||||
})
|
||||
if err != nil {
|
||||
@@ -770,13 +1013,13 @@ func TestVaultServiceListsUploadsDownloadsAndDeletesAttachments(t *testing.T) {
|
||||
}
|
||||
|
||||
if _, err := client.DeleteAttachment(ctx, &keepassgov1.DeleteAttachmentRequest{
|
||||
EntryId: "git-server",
|
||||
EntryId: "vault-console",
|
||||
Name: "token.txt",
|
||||
}); err != nil {
|
||||
t.Fatalf("DeleteAttachment() error = %v", err)
|
||||
}
|
||||
|
||||
listed, err = client.ListAttachments(ctx, &keepassgov1.ListAttachmentsRequest{EntryId: "git-server"})
|
||||
listed, err = client.ListAttachments(ctx, &keepassgov1.ListAttachmentsRequest{EntryId: "vault-console"})
|
||||
if err != nil {
|
||||
t.Fatalf("ListAttachments() error = %v", err)
|
||||
}
|
||||
@@ -785,64 +1028,105 @@ func TestVaultServiceListsUploadsDownloadsAndDeletesAttachments(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
const defaultTestTokenSecret = "test-token"
|
||||
|
||||
func tokenContext(secret string) context.Context {
|
||||
return metadata.AppendToOutgoingContext(context.Background(), "authorization", "Bearer "+secret)
|
||||
}
|
||||
|
||||
func hashSecretForTest(secret string) string {
|
||||
sum := sha256.Sum256([]byte(secret))
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
func testAPITokenEntry(t *testing.T, rules ...apitokens.PolicyRule) vault.Entry {
|
||||
t.Helper()
|
||||
token, _, err := apitokens.Issue("Test Client", "grpc-test", nil, time.Date(2026, 3, 29, 12, 0, 0, 0, time.UTC))
|
||||
if err != nil {
|
||||
t.Fatalf("Issue() error = %v", err)
|
||||
}
|
||||
token.SecretHash = hashSecretForTest(defaultTestTokenSecret)
|
||||
token.Policies = append([]apitokens.PolicyRule(nil), rules...)
|
||||
return token.Entry([]string{"Root", "API Tokens"})
|
||||
}
|
||||
|
||||
func newTestClient(t *testing.T) (keepassgov1.VaultServiceClient, *memoryClipboardWriter, func()) {
|
||||
t.Helper()
|
||||
model := vault.Model{
|
||||
Entries: []vault.Entry{
|
||||
{
|
||||
ID: "vault-console",
|
||||
Title: "Vault Console",
|
||||
Username: "dannyocean",
|
||||
Password: "token-1",
|
||||
URL: "https://vault.crew.example.invalid",
|
||||
Fields: map[string]string{
|
||||
"X-Role": "automation",
|
||||
},
|
||||
History: []vault.Entry{
|
||||
{
|
||||
ID: "vault-console-h1",
|
||||
Title: "Vault Console",
|
||||
Username: "dannyocean",
|
||||
Password: "token-0",
|
||||
URL: "https://vault.crew.example.invalid",
|
||||
Path: []string{"Root", "Internet"},
|
||||
},
|
||||
},
|
||||
Path: []string{"Root", "Internet"},
|
||||
},
|
||||
{
|
||||
ID: "surveillance-console",
|
||||
Title: "Surveillance Console",
|
||||
Username: "codex",
|
||||
Password: "token-2",
|
||||
URL: "https://surveillance.crew.example.invalid",
|
||||
Path: []string{"Root", "Home Assistant"},
|
||||
},
|
||||
testAPITokenEntry(t,
|
||||
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationManageVault, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root"}}},
|
||||
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationListEntries, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root"}}},
|
||||
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationListGroups, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root"}}},
|
||||
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationMutateGroup, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root"}}},
|
||||
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationMutateEntry, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root"}}},
|
||||
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationReadEntry, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root"}}},
|
||||
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationCopyPassword, Resource: apitokens.Resource{Kind: apitokens.ResourceEntry, EntryID: "vault-console", Path: []string{"Root", "Internet"}}},
|
||||
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationCopyUsername, Resource: apitokens.Resource{Kind: apitokens.ResourceEntry, EntryID: "vault-console", Path: []string{"Root", "Internet"}}},
|
||||
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationCopyURL, Resource: apitokens.Resource{Kind: apitokens.ResourceEntry, EntryID: "vault-console", Path: []string{"Root", "Internet"}}},
|
||||
),
|
||||
},
|
||||
Templates: []vault.Entry{
|
||||
{
|
||||
ID: "website-login",
|
||||
Title: "Website Login",
|
||||
Username: "template-user",
|
||||
Password: "template-password",
|
||||
URL: "https://example.com",
|
||||
Notes: "Reusable template for website accounts.",
|
||||
Fields: map[string]string{
|
||||
"Environment": "prod",
|
||||
},
|
||||
Tags: []string{"template", "web"},
|
||||
Path: []string{"Templates"},
|
||||
},
|
||||
},
|
||||
}
|
||||
return newTestClientForModel(t, model)
|
||||
}
|
||||
|
||||
func newTestClientForModel(t *testing.T, model vault.Model) (keepassgov1.VaultServiceClient, *memoryClipboardWriter, func()) {
|
||||
client, clipboardWriter, _, cleanup := newTestHarnessForModel(t, model)
|
||||
return client, clipboardWriter, cleanup
|
||||
}
|
||||
|
||||
func newTestHarnessForModel(t *testing.T, model vault.Model) (keepassgov1.VaultServiceClient, *memoryClipboardWriter, *Server, func()) {
|
||||
t.Helper()
|
||||
|
||||
listener := bufconn.Listen(1024 * 1024)
|
||||
server := grpc.NewServer(grpc.UnaryInterceptor(BearerTokenInterceptor("test-token")))
|
||||
clipboardWriter := &memoryClipboardWriter{}
|
||||
keepassgov1.RegisterVaultServiceServer(server, NewServer(
|
||||
vault.Model{
|
||||
Entries: []vault.Entry{
|
||||
{
|
||||
ID: "git-server",
|
||||
Title: "Git Server",
|
||||
Username: "joejulian",
|
||||
Password: "token-1",
|
||||
URL: "https://git.julianfamily.org",
|
||||
Fields: map[string]string{
|
||||
"X-Role": "automation",
|
||||
},
|
||||
History: []vault.Entry{
|
||||
{
|
||||
ID: "git-server-h1",
|
||||
Title: "Git Server",
|
||||
Username: "joejulian",
|
||||
Password: "token-0",
|
||||
URL: "https://git.julianfamily.org",
|
||||
Path: []string{"Root", "Internet"},
|
||||
},
|
||||
},
|
||||
Path: []string{"Root", "Internet"},
|
||||
},
|
||||
{
|
||||
ID: "ha-codex",
|
||||
Title: "Home Assistant (Codex)",
|
||||
Username: "codex",
|
||||
Password: "token-2",
|
||||
URL: "https://lights.julianfamily.org",
|
||||
Path: []string{"Root", "Home Assistant"},
|
||||
},
|
||||
},
|
||||
Templates: []vault.Entry{
|
||||
{
|
||||
ID: "website-login",
|
||||
Title: "Website Login",
|
||||
Username: "template-user",
|
||||
Password: "template-password",
|
||||
URL: "https://example.com",
|
||||
Notes: "Reusable template for website accounts.",
|
||||
Fields: map[string]string{
|
||||
"Environment": "prod",
|
||||
},
|
||||
Tags: []string{"template", "web"},
|
||||
Path: []string{"Templates"},
|
||||
},
|
||||
},
|
||||
},
|
||||
passwords.DefaultProfiles(),
|
||||
clipboardWriter,
|
||||
))
|
||||
service := NewServer(model, passwords.DefaultProfiles(), clipboardWriter)
|
||||
server := grpc.NewServer(grpc.UnaryInterceptor(AuthInterceptor(service)))
|
||||
keepassgov1.RegisterVaultServiceServer(server, service)
|
||||
|
||||
go func() {
|
||||
_ = server.Serve(listener)
|
||||
@@ -863,21 +1147,29 @@ func newTestClient(t *testing.T) (keepassgov1.VaultServiceClient, *memoryClipboa
|
||||
server.Stop()
|
||||
}
|
||||
|
||||
return keepassgov1.NewVaultServiceClient(conn), clipboardWriter, cleanup
|
||||
return keepassgov1.NewVaultServiceClient(conn), clipboardWriter, service, cleanup
|
||||
}
|
||||
|
||||
func newTestClientWithLifecycle(t *testing.T, lifecycle *stubLifecycle) (keepassgov1.VaultServiceClient, *memoryClipboardWriter, func()) {
|
||||
client, clipboardWriter, _, cleanup := newTestHarnessWithLifecycle(t, lifecycle)
|
||||
return client, clipboardWriter, cleanup
|
||||
}
|
||||
|
||||
func newTestHarnessWithLifecycle(t *testing.T, lifecycle *stubLifecycle) (keepassgov1.VaultServiceClient, *memoryClipboardWriter, *Server, func()) {
|
||||
t.Helper()
|
||||
|
||||
listener := bufconn.Listen(1024 * 1024)
|
||||
server := grpc.NewServer(grpc.UnaryInterceptor(BearerTokenInterceptor("test-token")))
|
||||
clipboardWriter := &memoryClipboardWriter{}
|
||||
keepassgov1.RegisterVaultServiceServer(server, NewServerWithLifecycle(
|
||||
lifecycle.model,
|
||||
passwords.DefaultProfiles(),
|
||||
clipboardWriter,
|
||||
lifecycle,
|
||||
model := lifecycle.model
|
||||
model.Entries = append(model.Entries, testAPITokenEntry(t,
|
||||
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationManageVault, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root"}}},
|
||||
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationListEntries, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root"}}},
|
||||
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationReadEntry, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root"}}},
|
||||
))
|
||||
lifecycle.model = model
|
||||
service := NewServerWithLifecycle(model, passwords.DefaultProfiles(), clipboardWriter, lifecycle)
|
||||
server := grpc.NewServer(grpc.UnaryInterceptor(AuthInterceptor(service)))
|
||||
keepassgov1.RegisterVaultServiceServer(server, service)
|
||||
|
||||
go func() {
|
||||
_ = server.Serve(listener)
|
||||
@@ -898,7 +1190,7 @@ func newTestClientWithLifecycle(t *testing.T, lifecycle *stubLifecycle) (keepass
|
||||
server.Stop()
|
||||
}
|
||||
|
||||
return keepassgov1.NewVaultServiceClient(conn), clipboardWriter, cleanup
|
||||
return keepassgov1.NewVaultServiceClient(conn), clipboardWriter, service, cleanup
|
||||
}
|
||||
|
||||
type memoryClipboardWriter struct {
|
||||
@@ -910,6 +1202,21 @@ func (w *memoryClipboardWriter) WriteText(text string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func waitForServerPendingApproval(t *testing.T, server *Server, want int) []apiapproval.Request {
|
||||
t.Helper()
|
||||
|
||||
deadline := time.Now().Add(time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
pending := server.ApprovalBroker().Pending()
|
||||
if len(pending) == want {
|
||||
return pending
|
||||
}
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
}
|
||||
t.Fatalf("server pending approvals never reached len %d", want)
|
||||
return nil
|
||||
}
|
||||
|
||||
type stubLifecycle struct {
|
||||
model vault.Model
|
||||
openPath string
|
||||
@@ -984,3 +1291,7 @@ func (s *stubLifecycle) Unlock(key vault.MasterKey) error {
|
||||
s.locked = false
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *stubLifecycle) Replace(model vault.Model) {
|
||||
s.model = model
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
package apiapproval
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"slices"
|
||||
"strconv"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"git.julianfamily.org/keepassgo/internal/apitokens"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrRequestDenied = errors.New("authorization request denied")
|
||||
ErrRequestCanceled = errors.New("authorization request canceled")
|
||||
ErrRequestTimedOut = errors.New("authorization request timed out")
|
||||
ErrRequestNotFound = errors.New("authorization request not found")
|
||||
)
|
||||
|
||||
type Outcome string
|
||||
|
||||
const (
|
||||
OutcomeAllowOnce Outcome = "allow-once"
|
||||
OutcomeDenyOnce Outcome = "deny-once"
|
||||
OutcomeAllowPermanent Outcome = "allow-permanent"
|
||||
OutcomeDenyPermanent Outcome = "deny-permanent"
|
||||
OutcomeCancel Outcome = "cancel"
|
||||
)
|
||||
|
||||
type Request struct {
|
||||
ID string
|
||||
TokenID string
|
||||
TokenName string
|
||||
ClientName string
|
||||
Operation apitokens.Operation
|
||||
Resource apitokens.Resource
|
||||
RequestedAt time.Time
|
||||
}
|
||||
|
||||
type Result struct {
|
||||
Outcome Outcome
|
||||
Rule *apitokens.PolicyRule
|
||||
}
|
||||
|
||||
type Broker struct {
|
||||
mu sync.Mutex
|
||||
pending map[string]*pendingRequest
|
||||
timeout time.Duration
|
||||
now func() time.Time
|
||||
nextID func() string
|
||||
}
|
||||
|
||||
type pendingRequest struct {
|
||||
request Request
|
||||
done chan Outcome
|
||||
}
|
||||
|
||||
type idGenerator struct {
|
||||
mu sync.Mutex
|
||||
counter int
|
||||
}
|
||||
|
||||
func NewBroker(timeout time.Duration) *Broker {
|
||||
gen := &idGenerator{}
|
||||
return &Broker{
|
||||
pending: map[string]*pendingRequest{},
|
||||
timeout: timeout,
|
||||
now: func() time.Time {
|
||||
return time.Now().UTC()
|
||||
},
|
||||
nextID: func() string {
|
||||
return gen.Next()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (g *idGenerator) Next() string {
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
g.counter++
|
||||
return "approval-" + strconv.Itoa(g.counter)
|
||||
}
|
||||
|
||||
func (b *Broker) Pending() []Request {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
|
||||
requests := make([]Request, 0, len(b.pending))
|
||||
for _, pending := range b.pending {
|
||||
requests = append(requests, pending.request)
|
||||
}
|
||||
slices.SortFunc(requests, func(a, c Request) int {
|
||||
switch {
|
||||
case a.RequestedAt.Before(c.RequestedAt):
|
||||
return -1
|
||||
case a.RequestedAt.After(c.RequestedAt):
|
||||
return 1
|
||||
case a.ID < c.ID:
|
||||
return -1
|
||||
case a.ID > c.ID:
|
||||
return 1
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
})
|
||||
return requests
|
||||
}
|
||||
|
||||
func (b *Broker) Request(ctx context.Context, token apitokens.Token, op apitokens.Operation, resource apitokens.Resource) (Result, error) {
|
||||
if b == nil {
|
||||
return Result{}, ErrRequestTimedOut
|
||||
}
|
||||
|
||||
pending := &pendingRequest{
|
||||
request: Request{
|
||||
ID: b.nextID(),
|
||||
TokenID: token.ID,
|
||||
TokenName: token.Name,
|
||||
ClientName: token.ClientName,
|
||||
Operation: op,
|
||||
Resource: resource,
|
||||
RequestedAt: b.now(),
|
||||
},
|
||||
done: make(chan Outcome, 1),
|
||||
}
|
||||
|
||||
b.mu.Lock()
|
||||
b.pending[pending.request.ID] = pending
|
||||
b.mu.Unlock()
|
||||
|
||||
defer func() {
|
||||
b.mu.Lock()
|
||||
delete(b.pending, pending.request.ID)
|
||||
b.mu.Unlock()
|
||||
}()
|
||||
|
||||
timer := time.NewTimer(b.timeout)
|
||||
defer timer.Stop()
|
||||
|
||||
select {
|
||||
case outcome := <-pending.done:
|
||||
result, err := resultForOutcome(pending.request, outcome)
|
||||
if err != nil {
|
||||
return result, err
|
||||
}
|
||||
return result, nil
|
||||
case <-timer.C:
|
||||
return Result{}, ErrRequestTimedOut
|
||||
case <-ctx.Done():
|
||||
return Result{}, ctx.Err()
|
||||
}
|
||||
}
|
||||
|
||||
func (b *Broker) Resolve(id string, outcome Outcome) (Request, *apitokens.PolicyRule, error) {
|
||||
b.mu.Lock()
|
||||
pending, ok := b.pending[id]
|
||||
b.mu.Unlock()
|
||||
if !ok {
|
||||
return Request{}, nil, ErrRequestNotFound
|
||||
}
|
||||
|
||||
rule, err := RuleFromDecision(pending.request, outcome)
|
||||
if err != nil {
|
||||
return Request{}, nil, err
|
||||
}
|
||||
|
||||
select {
|
||||
case pending.done <- outcome:
|
||||
default:
|
||||
}
|
||||
return pending.request, rule, nil
|
||||
}
|
||||
|
||||
func resultForOutcome(request Request, outcome Outcome) (Result, error) {
|
||||
rule, err := RuleFromDecision(request, outcome)
|
||||
if err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
result := Result{Outcome: outcome, Rule: rule}
|
||||
switch outcome {
|
||||
case OutcomeAllowOnce, OutcomeAllowPermanent:
|
||||
return result, nil
|
||||
case OutcomeDenyOnce, OutcomeDenyPermanent:
|
||||
return result, ErrRequestDenied
|
||||
case OutcomeCancel:
|
||||
return result, ErrRequestCanceled
|
||||
default:
|
||||
return Result{}, fmt.Errorf("unsupported approval outcome %q", outcome)
|
||||
}
|
||||
}
|
||||
|
||||
func RuleFromDecision(request Request, outcome Outcome) (*apitokens.PolicyRule, error) {
|
||||
switch outcome {
|
||||
case OutcomeAllowPermanent:
|
||||
rule := apitokens.PolicyRule{
|
||||
Effect: apitokens.EffectAllow,
|
||||
Operation: request.Operation,
|
||||
Resource: request.Resource,
|
||||
}
|
||||
return &rule, nil
|
||||
case OutcomeDenyPermanent:
|
||||
rule := apitokens.PolicyRule{
|
||||
Effect: apitokens.EffectDeny,
|
||||
Operation: request.Operation,
|
||||
Resource: request.Resource,
|
||||
}
|
||||
return &rule, nil
|
||||
case OutcomeAllowOnce, OutcomeDenyOnce, OutcomeCancel:
|
||||
return nil, nil
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported approval outcome %q", outcome)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
package apiapproval
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.julianfamily.org/keepassgo/internal/apitokens"
|
||||
)
|
||||
|
||||
func TestBrokerCreatesPendingRequestAndAllowsOnce(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
broker := NewBroker(time.Minute)
|
||||
broker.now = func() time.Time { return time.Date(2026, 3, 29, 12, 0, 0, 0, time.UTC) }
|
||||
|
||||
resultCh := make(chan Result, 1)
|
||||
errCh := make(chan error, 1)
|
||||
go func() {
|
||||
result, err := broker.Request(context.Background(), apitokens.Token{ID: "token-1", Name: "CLI", ClientName: "grpc-cli"}, apitokens.OperationListEntries, apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root", "Internet"}})
|
||||
resultCh <- result
|
||||
errCh <- err
|
||||
}()
|
||||
|
||||
waitForPending(t, broker, 1)
|
||||
pending := broker.Pending()
|
||||
if len(pending) != 1 {
|
||||
t.Fatalf("Pending() len = %d, want 1", len(pending))
|
||||
}
|
||||
if pending[0].TokenID != "token-1" {
|
||||
t.Fatalf("Pending()[0].TokenID = %q, want token-1", pending[0].TokenID)
|
||||
}
|
||||
|
||||
if _, _, err := broker.Resolve(pending[0].ID, OutcomeAllowOnce); err != nil {
|
||||
t.Fatalf("Resolve(allow once) error = %v", err)
|
||||
}
|
||||
|
||||
result := <-resultCh
|
||||
if err := <-errCh; err != nil {
|
||||
t.Fatalf("Request() error = %v, want nil", err)
|
||||
}
|
||||
if result.Outcome != OutcomeAllowOnce {
|
||||
t.Fatalf("Request() outcome = %q, want %q", result.Outcome, OutcomeAllowOnce)
|
||||
}
|
||||
if result.Rule != nil {
|
||||
t.Fatalf("Request() rule = %#v, want nil for allow-once", result.Rule)
|
||||
}
|
||||
if got := broker.Pending(); len(got) != 0 {
|
||||
t.Fatalf("Pending() after allow len = %d, want 0", len(got))
|
||||
}
|
||||
}
|
||||
|
||||
func TestBrokerReturnsPermanentRuleForDeny(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
broker := NewBroker(time.Minute)
|
||||
reqDone := make(chan struct{})
|
||||
var result Result
|
||||
var err error
|
||||
go func() {
|
||||
result, err = broker.Request(context.Background(), apitokens.Token{ID: "token-1", Name: "CLI"}, apitokens.OperationReadEntry, apitokens.Resource{Kind: apitokens.ResourceEntry, EntryID: "entry-1", Path: []string{"Root", "Internet"}})
|
||||
close(reqDone)
|
||||
}()
|
||||
|
||||
waitForPending(t, broker, 1)
|
||||
pending := broker.Pending()[0]
|
||||
request, rule, resolveErr := broker.Resolve(pending.ID, OutcomeDenyPermanent)
|
||||
if resolveErr != nil {
|
||||
t.Fatalf("Resolve(deny permanent) error = %v", resolveErr)
|
||||
}
|
||||
if request.ID != pending.ID {
|
||||
t.Fatalf("Resolve().ID = %q, want %q", request.ID, pending.ID)
|
||||
}
|
||||
if rule == nil || rule.Effect != apitokens.EffectDeny || rule.Operation != apitokens.OperationReadEntry {
|
||||
t.Fatalf("Resolve() rule = %#v, want deny read-entry rule", rule)
|
||||
}
|
||||
|
||||
<-reqDone
|
||||
if !errors.Is(err, ErrRequestDenied) {
|
||||
t.Fatalf("Request() error = %v, want ErrRequestDenied", err)
|
||||
}
|
||||
if result.Rule == nil || result.Rule.Effect != apitokens.EffectDeny {
|
||||
t.Fatalf("Request() rule = %#v, want deny rule", result.Rule)
|
||||
}
|
||||
if result.Outcome != OutcomeDenyPermanent {
|
||||
t.Fatalf("Request() outcome = %q, want %q", result.Outcome, OutcomeDenyPermanent)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBrokerSupportsCancellation(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
broker := NewBroker(time.Minute)
|
||||
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, OutcomeCancel); err != nil {
|
||||
t.Fatalf("Resolve(cancel) error = %v", err)
|
||||
}
|
||||
if err := <-errCh; !errors.Is(err, ErrRequestCanceled) {
|
||||
t.Fatalf("Request() error = %v, want ErrRequestCanceled", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBrokerTimesOutPendingRequests(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
broker := NewBroker(10 * time.Millisecond)
|
||||
_, 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, ErrRequestTimedOut) {
|
||||
t.Fatalf("Request() error = %v, want ErrRequestTimedOut", err)
|
||||
}
|
||||
if got := broker.Pending(); len(got) != 0 {
|
||||
t.Fatalf("Pending() len after timeout = %d, want 0", len(got))
|
||||
}
|
||||
}
|
||||
|
||||
func waitForPending(t *testing.T, broker *Broker, want int) {
|
||||
t.Helper()
|
||||
|
||||
deadline := time.Now().Add(time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
if got := len(broker.Pending()); got == want {
|
||||
return
|
||||
}
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
}
|
||||
t.Fatalf("Pending() never reached len %d", want)
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
package apiaudit
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"git.julianfamily.org/keepassgo/internal/apitokens"
|
||||
)
|
||||
|
||||
type EventType string
|
||||
|
||||
const (
|
||||
EventApprovalRequested EventType = "approval_requested"
|
||||
EventApprovalAllowed EventType = "approval_allowed"
|
||||
EventApprovalDenied EventType = "approval_denied"
|
||||
EventApprovalCanceled EventType = "approval_canceled"
|
||||
EventApprovalTimedOut EventType = "approval_timed_out"
|
||||
EventAutofillFound EventType = "autofill_found"
|
||||
EventAutofillAmbiguous EventType = "autofill_ambiguous"
|
||||
EventAutofillBlocked EventType = "autofill_blocked"
|
||||
EventAuthRejected EventType = "auth_rejected"
|
||||
)
|
||||
|
||||
type Event struct {
|
||||
Type EventType
|
||||
At time.Time
|
||||
TokenID string
|
||||
TokenName string
|
||||
ClientName string
|
||||
Operation apitokens.Operation
|
||||
Resource apitokens.Resource
|
||||
Message string
|
||||
}
|
||||
|
||||
type Log struct {
|
||||
mu sync.Mutex
|
||||
max int
|
||||
now func() time.Time
|
||||
events []Event
|
||||
}
|
||||
|
||||
func New(max int) *Log {
|
||||
if max < 1 {
|
||||
max = 1
|
||||
}
|
||||
return &Log{
|
||||
max: max,
|
||||
now: func() time.Time {
|
||||
return time.Now().UTC()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (l *Log) Record(event Event) {
|
||||
if l == nil {
|
||||
return
|
||||
}
|
||||
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
|
||||
if event.At.IsZero() {
|
||||
event.At = l.now()
|
||||
}
|
||||
l.events = append([]Event{event}, l.events...)
|
||||
if len(l.events) > l.max {
|
||||
l.events = l.events[:l.max]
|
||||
}
|
||||
}
|
||||
|
||||
func (l *Log) Events() []Event {
|
||||
if l == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
return slices.Clone(l.events)
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package apiaudit
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.julianfamily.org/keepassgo/internal/apitokens"
|
||||
)
|
||||
|
||||
func TestLogKeepsNewestEventsWithinBound(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
log := New(2)
|
||||
log.now = func() time.Time { return time.Date(2026, 3, 29, 12, 0, 0, 0, time.UTC) }
|
||||
log.Record(Event{Type: EventApprovalRequested, TokenID: "token-1"})
|
||||
log.Record(Event{Type: EventApprovalAllowed, TokenID: "token-2"})
|
||||
log.Record(Event{Type: EventApprovalDenied, TokenID: "token-3"})
|
||||
|
||||
events := log.Events()
|
||||
if len(events) != 2 {
|
||||
t.Fatalf("len(Events()) = %d, want 2", len(events))
|
||||
}
|
||||
if events[0].TokenID != "token-3" || events[1].TokenID != "token-2" {
|
||||
t.Fatalf("Events() = %#v, want newest-first bounded list", events)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLogPreservesRecordedMetadata(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
log := New(5)
|
||||
log.Record(Event{
|
||||
Type: EventApprovalRequested,
|
||||
TokenID: "token-1",
|
||||
TokenName: "CLI",
|
||||
ClientName: "grpc-cli",
|
||||
Operation: apitokens.OperationListEntries,
|
||||
Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root", "Internet"}},
|
||||
Message: "prompted for access",
|
||||
})
|
||||
|
||||
events := log.Events()
|
||||
if len(events) != 1 {
|
||||
t.Fatalf("len(Events()) = %d, want 1", len(events))
|
||||
}
|
||||
if events[0].Operation != apitokens.OperationListEntries || events[0].Message != "prompted for access" {
|
||||
t.Fatalf("Events()[0] = %#v, want preserved metadata", events[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestLogStoresAutofillEventTypes(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
log := New(5)
|
||||
log.Record(Event{
|
||||
Type: EventAutofillAmbiguous,
|
||||
TokenName: "Browser Extension",
|
||||
Message: "multiple matches for example.com",
|
||||
})
|
||||
|
||||
events := log.Events()
|
||||
if len(events) != 1 {
|
||||
t.Fatalf("len(Events()) = %d, want 1", len(events))
|
||||
}
|
||||
if events[0].Type != EventAutofillAmbiguous {
|
||||
t.Fatalf("Events()[0].Type = %q, want %q", events[0].Type, EventAutofillAmbiguous)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,335 @@
|
||||
package apitokens
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.julianfamily.org/keepassgo/internal/vault"
|
||||
)
|
||||
|
||||
const (
|
||||
EntryTypeAPIToken = "api-token"
|
||||
|
||||
FieldType = "KeePassGO-Type"
|
||||
FieldTokenID = "KeePassGO-API-Token-ID"
|
||||
FieldClientName = "KeePassGO-API-Client-Name"
|
||||
FieldCreatedAt = "KeePassGO-API-Created-At"
|
||||
FieldExpiresAt = "KeePassGO-API-Expires-At"
|
||||
FieldDisabled = "KeePassGO-API-Disabled"
|
||||
FieldRevokedAt = "KeePassGO-API-Revoked-At"
|
||||
FieldSecretHash = "KeePassGO-API-Secret-Hash"
|
||||
FieldPolicies = "KeePassGO-API-Policies"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrNotAToken = errors.New("entry is not an api token")
|
||||
ErrInvalidToken = errors.New("invalid api token")
|
||||
ErrExpiredToken = errors.New("expired api token")
|
||||
ErrDisabledToken = errors.New("disabled api token")
|
||||
ErrTokenNotFound = errors.New("api token not found")
|
||||
)
|
||||
|
||||
var EntryPath = []string{"Root", "API Tokens"}
|
||||
|
||||
type Effect string
|
||||
type Operation string
|
||||
type ResourceKind string
|
||||
type Decision string
|
||||
|
||||
const (
|
||||
EffectAllow Effect = "allow"
|
||||
EffectDeny Effect = "deny"
|
||||
|
||||
ResourceGroup ResourceKind = "group"
|
||||
ResourceEntry ResourceKind = "entry"
|
||||
|
||||
DecisionAllow Decision = "allow"
|
||||
DecisionDeny Decision = "deny"
|
||||
DecisionPrompt Decision = "prompt"
|
||||
|
||||
OperationListEntries Operation = "list_entries"
|
||||
OperationListGroups Operation = "list_groups"
|
||||
OperationReadEntry Operation = "read_entry"
|
||||
OperationCopyPassword Operation = "copy_password"
|
||||
OperationCopyUsername Operation = "copy_username"
|
||||
OperationCopyURL Operation = "copy_url"
|
||||
OperationMutateEntry Operation = "mutate_entry"
|
||||
OperationMutateGroup Operation = "mutate_group"
|
||||
OperationManageVault Operation = "manage_vault"
|
||||
)
|
||||
|
||||
type Resource struct {
|
||||
Kind ResourceKind `json:"kind"`
|
||||
Path []string `json:"path,omitempty"`
|
||||
EntryID string `json:"entry_id,omitempty"`
|
||||
}
|
||||
|
||||
type PolicyRule struct {
|
||||
Effect Effect `json:"effect"`
|
||||
Operation Operation `json:"operation"`
|
||||
Resource Resource `json:"resource"`
|
||||
}
|
||||
|
||||
type Token struct {
|
||||
ID string
|
||||
Name string
|
||||
ClientName string
|
||||
SecretHash string
|
||||
CreatedAt time.Time
|
||||
ExpiresAt *time.Time
|
||||
RevokedAt *time.Time
|
||||
Disabled bool
|
||||
Policies []PolicyRule
|
||||
}
|
||||
|
||||
func Issue(name, clientName string, expiresAt *time.Time, now time.Time) (Token, string, error) {
|
||||
clear, hashed, err := newSecret()
|
||||
if err != nil {
|
||||
return Token{}, "", err
|
||||
}
|
||||
id, _, err := newSecret()
|
||||
if err != nil {
|
||||
return Token{}, "", err
|
||||
}
|
||||
return Token{
|
||||
ID: id,
|
||||
Name: strings.TrimSpace(name),
|
||||
ClientName: strings.TrimSpace(clientName),
|
||||
SecretHash: hashed,
|
||||
CreatedAt: now.UTC(),
|
||||
ExpiresAt: cloneTime(expiresAt),
|
||||
}, clear, nil
|
||||
}
|
||||
|
||||
func Rotate(token Token, now time.Time) (Token, string, error) {
|
||||
clear, hashed, err := newSecret()
|
||||
if err != nil {
|
||||
return Token{}, "", err
|
||||
}
|
||||
token.SecretHash = hashed
|
||||
token.Disabled = false
|
||||
token.RevokedAt = nil
|
||||
if token.CreatedAt.IsZero() {
|
||||
token.CreatedAt = now.UTC()
|
||||
}
|
||||
return token, clear, nil
|
||||
}
|
||||
|
||||
func Disable(token Token) Token {
|
||||
token.Disabled = true
|
||||
return token
|
||||
}
|
||||
|
||||
func Revoke(token Token, when time.Time) Token {
|
||||
token.Disabled = true
|
||||
t := when.UTC()
|
||||
token.RevokedAt = &t
|
||||
return token
|
||||
}
|
||||
|
||||
func Authenticate(tokens []Token, presentedSecret string, now time.Time) (Token, error) {
|
||||
hashed := hashSecret(presentedSecret)
|
||||
for _, token := range tokens {
|
||||
if token.SecretHash != hashed {
|
||||
continue
|
||||
}
|
||||
if token.Disabled || token.RevokedAt != nil {
|
||||
return Token{}, ErrDisabledToken
|
||||
}
|
||||
if token.ExpiresAt != nil && !token.ExpiresAt.After(now.UTC()) {
|
||||
return Token{}, ErrExpiredToken
|
||||
}
|
||||
return token, nil
|
||||
}
|
||||
return Token{}, ErrInvalidToken
|
||||
}
|
||||
|
||||
func Evaluate(token Token, operation Operation, resource Resource) Decision {
|
||||
decision := DecisionPrompt
|
||||
for _, rule := range token.Policies {
|
||||
if rule.Operation != operation {
|
||||
continue
|
||||
}
|
||||
if !matches(rule.Resource, resource) {
|
||||
continue
|
||||
}
|
||||
if rule.Effect == EffectDeny {
|
||||
return DecisionDeny
|
||||
}
|
||||
if rule.Effect == EffectAllow {
|
||||
decision = DecisionAllow
|
||||
}
|
||||
}
|
||||
return decision
|
||||
}
|
||||
|
||||
func Entries(model vault.Model) ([]Token, error) {
|
||||
var out []Token
|
||||
for _, entry := range model.Entries {
|
||||
token, ok, err := TokenFromEntry(entry)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if ok {
|
||||
out = append(out, token)
|
||||
}
|
||||
}
|
||||
slices.SortFunc(out, func(a, b Token) int {
|
||||
switch {
|
||||
case a.Name < b.Name:
|
||||
return -1
|
||||
case a.Name > b.Name:
|
||||
return 1
|
||||
default:
|
||||
return strings.Compare(a.ID, b.ID)
|
||||
}
|
||||
})
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func Find(model vault.Model, id string) (Token, error) {
|
||||
tokens, err := Entries(model)
|
||||
if err != nil {
|
||||
return Token{}, err
|
||||
}
|
||||
for _, token := range tokens {
|
||||
if token.ID == id {
|
||||
return token, nil
|
||||
}
|
||||
}
|
||||
return Token{}, ErrTokenNotFound
|
||||
}
|
||||
|
||||
func Upsert(model *vault.Model, token Token) {
|
||||
model.UpsertEntry(token.Entry(EntryPath))
|
||||
model.CreateGroup([]string{"Root"}, "API Tokens")
|
||||
}
|
||||
|
||||
func Delete(model *vault.Model, id string) error {
|
||||
for i, entry := range model.Entries {
|
||||
token, ok, err := TokenFromEntry(entry)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if ok && token.ID == id {
|
||||
model.Entries = append(model.Entries[:i], model.Entries[i+1:]...)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return ErrTokenNotFound
|
||||
}
|
||||
|
||||
func TokenFromEntry(entry vault.Entry) (Token, bool, error) {
|
||||
if entry.Fields[FieldType] != EntryTypeAPIToken {
|
||||
return Token{}, false, nil
|
||||
}
|
||||
createdAt, err := time.Parse(time.RFC3339, entry.Fields[FieldCreatedAt])
|
||||
if err != nil {
|
||||
return Token{}, true, fmt.Errorf("parse created at: %w", err)
|
||||
}
|
||||
var expiresAt *time.Time
|
||||
if raw := strings.TrimSpace(entry.Fields[FieldExpiresAt]); raw != "" {
|
||||
t, err := time.Parse(time.RFC3339, raw)
|
||||
if err != nil {
|
||||
return Token{}, true, fmt.Errorf("parse expires at: %w", err)
|
||||
}
|
||||
expiresAt = &t
|
||||
}
|
||||
var revokedAt *time.Time
|
||||
if raw := strings.TrimSpace(entry.Fields[FieldRevokedAt]); raw != "" {
|
||||
t, err := time.Parse(time.RFC3339, raw)
|
||||
if err != nil {
|
||||
return Token{}, true, fmt.Errorf("parse revoked at: %w", err)
|
||||
}
|
||||
revokedAt = &t
|
||||
}
|
||||
policies := []PolicyRule{}
|
||||
if raw := strings.TrimSpace(entry.Fields[FieldPolicies]); raw != "" {
|
||||
if err := json.Unmarshal([]byte(raw), &policies); err != nil {
|
||||
return Token{}, true, fmt.Errorf("parse policies: %w", err)
|
||||
}
|
||||
}
|
||||
return Token{
|
||||
ID: entry.Fields[FieldTokenID],
|
||||
Name: entry.Title,
|
||||
ClientName: entry.Fields[FieldClientName],
|
||||
SecretHash: entry.Fields[FieldSecretHash],
|
||||
CreatedAt: createdAt,
|
||||
ExpiresAt: expiresAt,
|
||||
RevokedAt: revokedAt,
|
||||
Disabled: strings.EqualFold(entry.Fields[FieldDisabled], "true"),
|
||||
Policies: policies,
|
||||
}, true, nil
|
||||
}
|
||||
|
||||
func (t Token) Entry(path []string) vault.Entry {
|
||||
fields := map[string]string{
|
||||
FieldType: EntryTypeAPIToken,
|
||||
FieldTokenID: t.ID,
|
||||
FieldClientName: t.ClientName,
|
||||
FieldCreatedAt: t.CreatedAt.UTC().Format(time.RFC3339),
|
||||
FieldDisabled: fmt.Sprintf("%t", t.Disabled),
|
||||
FieldSecretHash: t.SecretHash,
|
||||
}
|
||||
if t.ExpiresAt != nil {
|
||||
fields[FieldExpiresAt] = t.ExpiresAt.UTC().Format(time.RFC3339)
|
||||
}
|
||||
if t.RevokedAt != nil {
|
||||
fields[FieldRevokedAt] = t.RevokedAt.UTC().Format(time.RFC3339)
|
||||
}
|
||||
if len(t.Policies) > 0 {
|
||||
data, _ := json.Marshal(t.Policies)
|
||||
fields[FieldPolicies] = string(data)
|
||||
}
|
||||
return vault.Entry{
|
||||
ID: t.ID,
|
||||
Title: t.Name,
|
||||
Username: t.ClientName,
|
||||
Path: slices.Clone(path),
|
||||
Fields: fields,
|
||||
}
|
||||
}
|
||||
|
||||
func hashSecret(secret string) string {
|
||||
sum := sha256.Sum256([]byte(secret))
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
func newSecret() (string, string, error) {
|
||||
buf := make([]byte, 24)
|
||||
if _, err := rand.Read(buf); err != nil {
|
||||
return "", "", fmt.Errorf("generate secret: %w", err)
|
||||
}
|
||||
clear := base64.RawURLEncoding.EncodeToString(buf)
|
||||
return clear, hashSecret(clear), nil
|
||||
}
|
||||
|
||||
func cloneTime(in *time.Time) *time.Time {
|
||||
if in == nil {
|
||||
return nil
|
||||
}
|
||||
t := in.UTC()
|
||||
return &t
|
||||
}
|
||||
|
||||
func matches(rule, resource Resource) bool {
|
||||
switch rule.Kind {
|
||||
case ResourceEntry:
|
||||
return rule.EntryID != "" && rule.EntryID == resource.EntryID
|
||||
case ResourceGroup:
|
||||
if len(rule.Path) > len(resource.Path) {
|
||||
return false
|
||||
}
|
||||
return slices.Equal(rule.Path, resource.Path[:len(rule.Path)])
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,282 @@
|
||||
package apitokens
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.julianfamily.org/keepassgo/internal/vault"
|
||||
)
|
||||
|
||||
func TestTokenEntryRoundTripsThroughVaultEntry(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
expiresAt := time.Date(2026, 4, 1, 12, 0, 0, 0, time.UTC)
|
||||
token := Token{
|
||||
ID: "token-1",
|
||||
Name: "Browser Connector",
|
||||
ClientName: "browser-extension",
|
||||
SecretHash: "deadbeef",
|
||||
CreatedAt: time.Date(2026, 3, 29, 12, 0, 0, 0, time.UTC),
|
||||
ExpiresAt: &expiresAt,
|
||||
Disabled: true,
|
||||
Policies: []PolicyRule{
|
||||
{Effect: EffectAllow, Operation: OperationListEntries, Resource: Resource{Kind: ResourceGroup, Path: []string{"Root", "Internet"}}},
|
||||
{Effect: EffectDeny, Operation: OperationCopyPassword, Resource: Resource{Kind: ResourceEntry, EntryID: "bank-token"}},
|
||||
},
|
||||
}
|
||||
|
||||
entry := token.Entry([]string{"Root", "API Tokens"})
|
||||
if entry.Fields[FieldType] != EntryTypeAPIToken {
|
||||
t.Fatalf("FieldType = %q, want %q", entry.Fields[FieldType], EntryTypeAPIToken)
|
||||
}
|
||||
|
||||
got, ok, err := TokenFromEntry(entry)
|
||||
if err != nil {
|
||||
t.Fatalf("TokenFromEntry() error = %v", err)
|
||||
}
|
||||
if !ok {
|
||||
t.Fatal("TokenFromEntry() ok = false, want true")
|
||||
}
|
||||
if got.ID != token.ID || got.Name != token.Name || got.ClientName != token.ClientName || got.SecretHash != token.SecretHash || !got.Disabled {
|
||||
t.Fatalf("TokenFromEntry() = %#v, want %#v", got, token)
|
||||
}
|
||||
if got.ExpiresAt == nil || !got.ExpiresAt.Equal(expiresAt) {
|
||||
t.Fatalf("ExpiresAt = %#v, want %v", got.ExpiresAt, expiresAt)
|
||||
}
|
||||
if len(got.Policies) != 2 {
|
||||
t.Fatalf("len(Policies) = %d, want 2", len(got.Policies))
|
||||
}
|
||||
}
|
||||
|
||||
func TestEntriesFiltersOnlyAPITokens(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
model := vault.Model{
|
||||
Entries: []vault.Entry{
|
||||
{ID: "entry-1", Title: "Ordinary Entry"},
|
||||
Token{ID: "token-1", Name: "CLI", SecretHash: "hash-1", CreatedAt: time.Date(2026, 3, 29, 12, 0, 0, 0, time.UTC)}.Entry([]string{"Root", "API Tokens"}),
|
||||
Token{ID: "token-2", Name: "Browser", SecretHash: "hash-2", CreatedAt: time.Date(2026, 3, 29, 12, 0, 0, 0, time.UTC)}.Entry([]string{"Root", "API Tokens"}),
|
||||
},
|
||||
}
|
||||
|
||||
got, err := Entries(model)
|
||||
if err != nil {
|
||||
t.Fatalf("Entries() error = %v", err)
|
||||
}
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("len(Entries()) = %d, want 2", len(got))
|
||||
}
|
||||
if got[0].Name != "Browser" || got[1].Name != "CLI" {
|
||||
t.Fatalf("Entries() = %#v, want Browser then CLI by name sort", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpsertCreatesAndUpdatesVaultTokenEntry(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
model := vault.Model{}
|
||||
token := Token{
|
||||
ID: "token-1",
|
||||
Name: "CLI",
|
||||
ClientName: "grpc-cli",
|
||||
SecretHash: "hash-1",
|
||||
CreatedAt: time.Date(2026, 3, 29, 12, 0, 0, 0, time.UTC),
|
||||
}
|
||||
|
||||
Upsert(&model, token)
|
||||
if len(model.Entries) != 1 {
|
||||
t.Fatalf("len(model.Entries) = %d, want 1", len(model.Entries))
|
||||
}
|
||||
if got := model.ChildGroups([]string{"Root"}); !slices.Equal(got, []string{"API Tokens"}) {
|
||||
t.Fatalf("ChildGroups(Root) = %v, want [API Tokens]", got)
|
||||
}
|
||||
|
||||
token.ClientName = "rotated-client"
|
||||
token.Disabled = true
|
||||
Upsert(&model, token)
|
||||
|
||||
got, err := Find(model, "token-1")
|
||||
if err != nil {
|
||||
t.Fatalf("Find() error = %v", err)
|
||||
}
|
||||
if got.ClientName != "rotated-client" || !got.Disabled {
|
||||
t.Fatalf("Find() = %#v, want updated client name and disabled state", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteRemovesTokenEntryByTokenID(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
model := vault.Model{
|
||||
Entries: []vault.Entry{
|
||||
Token{ID: "token-1", Name: "CLI", SecretHash: "hash-1", CreatedAt: time.Date(2026, 3, 29, 12, 0, 0, 0, time.UTC)}.Entry(EntryPath),
|
||||
{ID: "entry-1", Title: "Regular Entry"},
|
||||
},
|
||||
}
|
||||
|
||||
if err := Delete(&model, "token-1"); err != nil {
|
||||
t.Fatalf("Delete() error = %v", err)
|
||||
}
|
||||
if len(model.Entries) != 1 || model.Entries[0].ID != "entry-1" {
|
||||
t.Fatalf("model.Entries after Delete = %#v, want only regular entry", model.Entries)
|
||||
}
|
||||
if err := Delete(&model, "missing"); err != ErrTokenNotFound {
|
||||
t.Fatalf("Delete(missing) error = %v, want %v", err, ErrTokenNotFound)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIssueRotateDisableAndRevokeToken(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
now := time.Date(2026, 3, 29, 12, 0, 0, 0, time.UTC)
|
||||
token, secret, err := Issue("Browser Connector", "browser-extension", nil, now)
|
||||
if err != nil {
|
||||
t.Fatalf("Issue() error = %v", err)
|
||||
}
|
||||
if token.ID == "" || secret == "" {
|
||||
t.Fatalf("Issue() returned empty token or secret: %#v %q", token, secret)
|
||||
}
|
||||
if token.SecretHash == secret {
|
||||
t.Fatal("SecretHash should not equal cleartext secret")
|
||||
}
|
||||
if token.Disabled {
|
||||
t.Fatal("Disabled = true, want false after Issue")
|
||||
}
|
||||
|
||||
rotated, newSecret, err := Rotate(token, now.Add(time.Hour))
|
||||
if err != nil {
|
||||
t.Fatalf("Rotate() error = %v", err)
|
||||
}
|
||||
if rotated.ID != token.ID {
|
||||
t.Fatalf("Rotate() changed ID from %q to %q", token.ID, rotated.ID)
|
||||
}
|
||||
if newSecret == secret {
|
||||
t.Fatal("Rotate() returned the same cleartext secret, want a new one")
|
||||
}
|
||||
if rotated.SecretHash == token.SecretHash {
|
||||
t.Fatal("Rotate() left SecretHash unchanged")
|
||||
}
|
||||
|
||||
disabled := Disable(rotated)
|
||||
if !disabled.Disabled {
|
||||
t.Fatal("Disable() did not set Disabled")
|
||||
}
|
||||
revoked := Revoke(disabled, now.Add(2*time.Hour))
|
||||
if !revoked.Disabled {
|
||||
t.Fatal("Revoke() should leave token disabled")
|
||||
}
|
||||
if revoked.RevokedAt == nil || !revoked.RevokedAt.Equal(now.Add(2*time.Hour)) {
|
||||
t.Fatalf("RevokedAt = %#v, want %v", revoked.RevokedAt, now.Add(2*time.Hour))
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthenticateRejectsDisabledExpiredAndWrongSecret(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
now := time.Date(2026, 3, 29, 12, 0, 0, 0, time.UTC)
|
||||
valid, secret, err := Issue("CLI", "cli-tool", nil, now)
|
||||
if err != nil {
|
||||
t.Fatalf("Issue() error = %v", err)
|
||||
}
|
||||
expired, expiredSecret, err := Issue("Expired", "cli-tool", nil, now)
|
||||
if err != nil {
|
||||
t.Fatalf("Issue(expired) error = %v", err)
|
||||
}
|
||||
expiredAt := now.Add(-time.Minute)
|
||||
expired.ExpiresAt = &expiredAt
|
||||
disabled, disabledSecret, err := Issue("Disabled", "cli-tool", nil, now)
|
||||
if err != nil {
|
||||
t.Fatalf("Issue(disabled) error = %v", err)
|
||||
}
|
||||
disabled = Disable(disabled)
|
||||
|
||||
tokens := []Token{expired, disabled, valid}
|
||||
if _, err := Authenticate(tokens, "wrong-secret", now); err != ErrInvalidToken {
|
||||
t.Fatalf("Authenticate(wrong-secret) error = %v, want %v", err, ErrInvalidToken)
|
||||
}
|
||||
if _, err := Authenticate([]Token{expired}, expiredSecret, now); err != ErrExpiredToken {
|
||||
t.Fatalf("Authenticate(expired) error = %v, want %v", err, ErrExpiredToken)
|
||||
}
|
||||
if _, err := Authenticate([]Token{disabled}, disabledSecret, now); err != ErrDisabledToken {
|
||||
t.Fatalf("Authenticate(disabled) error = %v, want %v", err, ErrDisabledToken)
|
||||
}
|
||||
got, err := Authenticate(tokens, secret, now)
|
||||
if err != nil {
|
||||
t.Fatalf("Authenticate(valid) error = %v", err)
|
||||
}
|
||||
if got.ID != valid.ID {
|
||||
t.Fatalf("Authenticate(valid).ID = %q, want %q", got.ID, valid.ID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvaluatePolicyDistinguishesAllowDenyAndPrompt(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
token := Token{
|
||||
ID: "token-1",
|
||||
Policies: []PolicyRule{
|
||||
{Effect: EffectAllow, Operation: OperationListEntries, Resource: Resource{Kind: ResourceGroup, Path: []string{"Root", "Internet"}}},
|
||||
{Effect: EffectDeny, Operation: OperationCopyPassword, Resource: Resource{Kind: ResourceEntry, EntryID: "amazon"}},
|
||||
},
|
||||
}
|
||||
|
||||
decision := Evaluate(token, OperationListEntries, Resource{Kind: ResourceGroup, Path: []string{"Root", "Internet"}})
|
||||
if decision != DecisionAllow {
|
||||
t.Fatalf("Evaluate(allow) = %q, want %q", decision, DecisionAllow)
|
||||
}
|
||||
|
||||
decision = Evaluate(token, OperationCopyPassword, Resource{Kind: ResourceEntry, EntryID: "amazon", Path: []string{"Root", "Internet"}})
|
||||
if decision != DecisionDeny {
|
||||
t.Fatalf("Evaluate(deny) = %q, want %q", decision, DecisionDeny)
|
||||
}
|
||||
|
||||
decision = Evaluate(token, OperationCopyPassword, Resource{Kind: ResourceEntry, EntryID: "github", Path: []string{"Root", "Internet"}})
|
||||
if decision != DecisionPrompt {
|
||||
t.Fatalf("Evaluate(prompt) = %q, want %q", decision, DecisionPrompt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEntryScopedDenyOverridesGroupAllow(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
token := Token{
|
||||
ID: "token-1",
|
||||
Policies: []PolicyRule{
|
||||
{Effect: EffectAllow, Operation: OperationCopyPassword, Resource: Resource{Kind: ResourceGroup, Path: []string{"Root", "Internet"}}},
|
||||
{Effect: EffectDeny, Operation: OperationCopyPassword, Resource: Resource{Kind: ResourceEntry, EntryID: "bank", Path: []string{"Root", "Internet"}}},
|
||||
},
|
||||
}
|
||||
|
||||
allowed := Evaluate(token, OperationCopyPassword, Resource{Kind: ResourceEntry, EntryID: "forum", Path: []string{"Root", "Internet"}})
|
||||
denied := Evaluate(token, OperationCopyPassword, Resource{Kind: ResourceEntry, EntryID: "bank", Path: []string{"Root", "Internet"}})
|
||||
if allowed != DecisionAllow || denied != DecisionDeny {
|
||||
t.Fatalf("Evaluate() allow/deny = %q/%q, want %q/%q", allowed, denied, DecisionAllow, DecisionDeny)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTokenEntryKeepsPoliciesStableAcrossRoundTripOrdering(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
token := Token{
|
||||
ID: "token-1",
|
||||
Name: "CLI",
|
||||
ClientName: "cli",
|
||||
SecretHash: "hash",
|
||||
CreatedAt: time.Date(2026, 3, 29, 12, 0, 0, 0, time.UTC),
|
||||
Policies: []PolicyRule{
|
||||
{Effect: EffectDeny, Operation: OperationCopyPassword, Resource: Resource{Kind: ResourceEntry, EntryID: "bank"}},
|
||||
{Effect: EffectAllow, Operation: OperationListEntries, Resource: Resource{Kind: ResourceGroup, Path: []string{"Root", "Internet"}}},
|
||||
},
|
||||
}
|
||||
|
||||
got, ok, err := TokenFromEntry(token.Entry([]string{"Root", "API Tokens"}))
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("TokenFromEntry() error = %v, ok=%v", err, ok)
|
||||
}
|
||||
if !slices.EqualFunc(got.Policies, token.Policies, func(a, b PolicyRule) bool {
|
||||
return a.Effect == b.Effect && a.Operation == b.Operation && a.Resource.Kind == b.Resource.Kind && a.Resource.EntryID == b.Resource.EntryID && slices.Equal(a.Resource.Path, b.Resource.Path)
|
||||
}) {
|
||||
t.Fatalf("Policies after round trip = %#v, want %#v", got.Policies, token.Policies)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
package appstate
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"git.julianfamily.org/keepassgo/internal/vault"
|
||||
)
|
||||
|
||||
type SyncMode string
|
||||
|
||||
const (
|
||||
SyncModeManual SyncMode = "manual"
|
||||
SyncModeAutomaticOnOpenSave SyncMode = "automatic_on_open_save"
|
||||
)
|
||||
|
||||
type RemoteBinding struct {
|
||||
LocalVaultPath string `json:"localVaultPath"`
|
||||
RemoteProfileID string `json:"remoteProfileId"`
|
||||
CredentialEntryID string `json:"credentialEntryId"`
|
||||
SyncMode SyncMode `json:"syncMode,omitempty"`
|
||||
}
|
||||
|
||||
type ResolvedRemoteBinding struct {
|
||||
Profile vault.RemoteProfile
|
||||
Credentials vault.Entry
|
||||
}
|
||||
|
||||
type RemoteBindingInput struct {
|
||||
LocalVaultPath string
|
||||
RemoteProfileID string
|
||||
RemoteProfileName string
|
||||
BaseURL string
|
||||
RemotePath string
|
||||
CredentialEntryID string
|
||||
CredentialTitle string
|
||||
Username string
|
||||
Password string
|
||||
CredentialPath []string
|
||||
SyncMode SyncMode
|
||||
}
|
||||
|
||||
func (b RemoteBinding) Resolve(model vault.Model) (ResolvedRemoteBinding, error) {
|
||||
profile, err := model.RemoteProfileByID(b.RemoteProfileID)
|
||||
if err != nil {
|
||||
return ResolvedRemoteBinding{}, fmt.Errorf("resolve remote profile: %w", err)
|
||||
}
|
||||
credentials, err := model.EntryByID(b.CredentialEntryID)
|
||||
if err != nil {
|
||||
return ResolvedRemoteBinding{}, fmt.Errorf("resolve remote credentials: %w", err)
|
||||
}
|
||||
return ResolvedRemoteBinding{
|
||||
Profile: profile,
|
||||
Credentials: credentials,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func ConfigureRemoteBinding(model *vault.Model, input RemoteBindingInput) (RemoteBinding, error) {
|
||||
if model == nil {
|
||||
return RemoteBinding{}, fmt.Errorf("model is required")
|
||||
}
|
||||
|
||||
input.LocalVaultPath = strings.TrimSpace(input.LocalVaultPath)
|
||||
input.RemoteProfileID = strings.TrimSpace(input.RemoteProfileID)
|
||||
input.RemoteProfileName = strings.TrimSpace(input.RemoteProfileName)
|
||||
input.BaseURL = strings.TrimSpace(input.BaseURL)
|
||||
input.RemotePath = strings.TrimSpace(input.RemotePath)
|
||||
input.CredentialEntryID = strings.TrimSpace(input.CredentialEntryID)
|
||||
input.CredentialTitle = strings.TrimSpace(input.CredentialTitle)
|
||||
input.Username = strings.TrimSpace(input.Username)
|
||||
|
||||
switch {
|
||||
case input.LocalVaultPath == "":
|
||||
return RemoteBinding{}, fmt.Errorf("local vault path is required")
|
||||
case input.RemoteProfileID == "":
|
||||
return RemoteBinding{}, fmt.Errorf("remote profile id is required")
|
||||
case input.BaseURL == "":
|
||||
return RemoteBinding{}, fmt.Errorf("remote base URL is required")
|
||||
case input.RemotePath == "":
|
||||
return RemoteBinding{}, fmt.Errorf("remote path is required")
|
||||
case input.CredentialEntryID == "":
|
||||
return RemoteBinding{}, fmt.Errorf("credential entry id is required")
|
||||
case input.Password == "":
|
||||
return RemoteBinding{}, fmt.Errorf("credential password is required")
|
||||
}
|
||||
|
||||
if input.RemoteProfileName == "" {
|
||||
input.RemoteProfileName = input.RemoteProfileID
|
||||
}
|
||||
if input.CredentialTitle == "" {
|
||||
input.CredentialTitle = "Remote Sign-In"
|
||||
}
|
||||
|
||||
model.UpsertRemoteProfile(vault.RemoteProfile{
|
||||
ID: input.RemoteProfileID,
|
||||
Name: input.RemoteProfileName,
|
||||
Backend: vault.RemoteBackendWebDAV,
|
||||
BaseURL: input.BaseURL,
|
||||
Path: input.RemotePath,
|
||||
})
|
||||
model.UpsertEntry(vault.Entry{
|
||||
ID: input.CredentialEntryID,
|
||||
Title: input.CredentialTitle,
|
||||
Username: input.Username,
|
||||
Password: input.Password,
|
||||
URL: input.BaseURL,
|
||||
Path: append([]string(nil), input.CredentialPath...),
|
||||
})
|
||||
|
||||
return RemoteBinding{
|
||||
LocalVaultPath: input.LocalVaultPath,
|
||||
RemoteProfileID: input.RemoteProfileID,
|
||||
CredentialEntryID: input.CredentialEntryID,
|
||||
SyncMode: normalizeSyncMode(input.SyncMode),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func RemoveRemoteBinding(model *vault.Model, binding RemoteBinding) error {
|
||||
if model == nil {
|
||||
return fmt.Errorf("model is required")
|
||||
}
|
||||
if strings.TrimSpace(binding.RemoteProfileID) == "" {
|
||||
return fmt.Errorf("remote profile id is required")
|
||||
}
|
||||
if strings.TrimSpace(binding.CredentialEntryID) == "" {
|
||||
return fmt.Errorf("credential entry id is required")
|
||||
}
|
||||
|
||||
model.RemoveRemoteProfileByID(binding.RemoteProfileID)
|
||||
model.RemoveEntryByID(binding.CredentialEntryID)
|
||||
return nil
|
||||
}
|
||||
|
||||
func normalizeSyncMode(mode SyncMode) SyncMode {
|
||||
switch mode {
|
||||
case SyncModeAutomaticOnOpenSave:
|
||||
return SyncModeAutomaticOnOpenSave
|
||||
default:
|
||||
return SyncModeManual
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
package appstate
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.julianfamily.org/keepassgo/internal/vault"
|
||||
)
|
||||
|
||||
func TestRemoteBindingResolveUsesVaultProfileAndCredentialEntry(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
model := vault.Model{
|
||||
Entries: []vault.Entry{
|
||||
{
|
||||
ID: "linuscaldwell-webdav",
|
||||
Title: "Bellagio WebDAV Sign-In",
|
||||
Username: "linuscaldwell",
|
||||
Password: "bellagio-pass-1",
|
||||
Path: []string{"Crew", "Internet"},
|
||||
},
|
||||
},
|
||||
RemoteProfiles: []vault.RemoteProfile{
|
||||
{
|
||||
ID: "bellagio-webdav",
|
||||
Name: "Bellagio Vault",
|
||||
Backend: vault.RemoteBackendWebDAV,
|
||||
BaseURL: "https://dav.example.invalid/remote.php/dav",
|
||||
Path: "files/bellagio/keepass.kdbx",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
binding := RemoteBinding{
|
||||
LocalVaultPath: "/tmp/bellagio.kdbx",
|
||||
RemoteProfileID: "bellagio-webdav",
|
||||
CredentialEntryID: "linuscaldwell-webdav",
|
||||
SyncMode: SyncModeAutomaticOnOpenSave,
|
||||
}
|
||||
|
||||
resolved, err := binding.Resolve(model)
|
||||
if err != nil {
|
||||
t.Fatalf("Resolve() error = %v", err)
|
||||
}
|
||||
if got := resolved.Profile.BaseURL; got != "https://dav.example.invalid/remote.php/dav" {
|
||||
t.Fatalf("resolved profile base URL = %q, want remote.php/dav URL", got)
|
||||
}
|
||||
if got := resolved.Profile.Path; got != "files/bellagio/keepass.kdbx" {
|
||||
t.Fatalf("resolved profile path = %q, want files/bellagio/keepass.kdbx", got)
|
||||
}
|
||||
if got := resolved.Credentials.Username; got != "linuscaldwell" {
|
||||
t.Fatalf("resolved credentials username = %q, want linuscaldwell", got)
|
||||
}
|
||||
if got := resolved.Credentials.Password; got != "bellagio-pass-1" {
|
||||
t.Fatalf("resolved credentials password = %q, want bellagio-pass-1", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemoteBindingResolveFailsWhenVaultReferenceIsMissing(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
model := vault.Model{
|
||||
Entries: []vault.Entry{
|
||||
{ID: "linuscaldwell-webdav", Title: "Bellagio WebDAV Sign-In"},
|
||||
},
|
||||
}
|
||||
|
||||
_, err := (RemoteBinding{
|
||||
LocalVaultPath: "/tmp/bellagio.kdbx",
|
||||
RemoteProfileID: "bellagio-webdav",
|
||||
CredentialEntryID: "missing-creds",
|
||||
}).Resolve(model)
|
||||
if !errors.Is(err, vault.ErrRemoteProfileNotFound) {
|
||||
t.Fatalf("Resolve() error = %v, want ErrRemoteProfileNotFound first", err)
|
||||
}
|
||||
|
||||
model.RemoteProfiles = []vault.RemoteProfile{{
|
||||
ID: "bellagio-webdav",
|
||||
Name: "Bellagio Vault",
|
||||
Backend: vault.RemoteBackendWebDAV,
|
||||
BaseURL: "https://dav.example.invalid/remote.php/dav",
|
||||
Path: "files/bellagio/keepass.kdbx",
|
||||
}}
|
||||
|
||||
_, err = (RemoteBinding{
|
||||
LocalVaultPath: "/tmp/bellagio.kdbx",
|
||||
RemoteProfileID: "bellagio-webdav",
|
||||
CredentialEntryID: "missing-creds",
|
||||
}).Resolve(model)
|
||||
if !errors.Is(err, vault.ErrEntryNotFound) {
|
||||
t.Fatalf("Resolve() error = %v, want ErrEntryNotFound", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemoteBindingJSONStoresOnlyNonSecretReferences(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
content, err := json.Marshal(RemoteBinding{
|
||||
LocalVaultPath: "/tmp/bellagio.kdbx",
|
||||
RemoteProfileID: "bellagio-webdav",
|
||||
CredentialEntryID: "remote-creds-1",
|
||||
SyncMode: SyncModeAutomaticOnOpenSave,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("json.Marshal(RemoteBinding) error = %v", err)
|
||||
}
|
||||
|
||||
text := string(content)
|
||||
for _, disallowed := range []string{"bellagio-pass-1", "password", "username", "baseUrl"} {
|
||||
if strings.Contains(text, disallowed) {
|
||||
t.Fatalf("binding JSON %q unexpectedly contains %q", text, disallowed)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigureRemoteBindingStoresProfileAndCredentialsInVault(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var model vault.Model
|
||||
|
||||
binding, err := ConfigureRemoteBinding(&model, RemoteBindingInput{
|
||||
LocalVaultPath: "/tmp/bellagio.kdbx",
|
||||
RemoteProfileID: "bellagio-webdav",
|
||||
RemoteProfileName: "Bellagio Vault",
|
||||
BaseURL: "https://dav.example.invalid/remote.php/dav",
|
||||
RemotePath: "files/bellagio/keepass.kdbx",
|
||||
CredentialEntryID: "remote-creds-1",
|
||||
CredentialTitle: "Bellagio WebDAV Sign-In",
|
||||
Username: "linuscaldwell",
|
||||
Password: "bellagio-pass-1",
|
||||
CredentialPath: []string{"Crew", "Internet"},
|
||||
SyncMode: SyncModeAutomaticOnOpenSave,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("ConfigureRemoteBinding() error = %v", err)
|
||||
}
|
||||
|
||||
if len(model.RemoteProfiles) != 1 {
|
||||
t.Fatalf("len(RemoteProfiles) = %d, want 1", len(model.RemoteProfiles))
|
||||
}
|
||||
if got := model.RemoteProfiles[0].BaseURL; got != "https://dav.example.invalid/remote.php/dav" {
|
||||
t.Fatalf("stored remote profile base URL = %q, want remote.php/dav URL", got)
|
||||
}
|
||||
|
||||
credentials, err := model.EntryByID("remote-creds-1")
|
||||
if err != nil {
|
||||
t.Fatalf("EntryByID(remote-creds-1) error = %v", err)
|
||||
}
|
||||
if credentials.Username != "linuscaldwell" || credentials.Password != "bellagio-pass-1" {
|
||||
t.Fatalf("stored credential entry = %#v, want linuscaldwell/bellagio-pass-1", credentials)
|
||||
}
|
||||
if credentials.URL != "https://dav.example.invalid/remote.php/dav" {
|
||||
t.Fatalf("stored credential entry URL = %q, want remote.php/dav URL", credentials.URL)
|
||||
}
|
||||
|
||||
if binding.LocalVaultPath != "/tmp/bellagio.kdbx" {
|
||||
t.Fatalf("binding LocalVaultPath = %q, want /tmp/bellagio.kdbx", binding.LocalVaultPath)
|
||||
}
|
||||
if binding.RemoteProfileID != "bellagio-webdav" || binding.CredentialEntryID != "remote-creds-1" {
|
||||
t.Fatalf("binding = %#v, want only vault references", binding)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigureRemoteBindingRejectsIncompleteInput(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
input RemoteBindingInput
|
||||
}{
|
||||
{
|
||||
name: "missing_local_vault_path",
|
||||
input: RemoteBindingInput{
|
||||
RemoteProfileID: "bellagio-webdav",
|
||||
BaseURL: "https://dav.example.invalid/remote.php/dav",
|
||||
RemotePath: "files/bellagio/keepass.kdbx",
|
||||
CredentialEntryID: "remote-creds-1",
|
||||
Password: "bellagio-pass-1",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "missing_remote_base_url",
|
||||
input: RemoteBindingInput{
|
||||
LocalVaultPath: "/tmp/bellagio.kdbx",
|
||||
RemoteProfileID: "bellagio-webdav",
|
||||
RemotePath: "files/bellagio/keepass.kdbx",
|
||||
CredentialEntryID: "remote-creds-1",
|
||||
Password: "bellagio-pass-1",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "missing_credential_entry_id",
|
||||
input: RemoteBindingInput{
|
||||
LocalVaultPath: "/tmp/bellagio.kdbx",
|
||||
RemoteProfileID: "bellagio-webdav",
|
||||
BaseURL: "https://dav.example.invalid/remote.php/dav",
|
||||
RemotePath: "files/bellagio/keepass.kdbx",
|
||||
Password: "bellagio-pass-1",
|
||||
},
|
||||
},
|
||||
} {
|
||||
tc := tc
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var model vault.Model
|
||||
if _, err := ConfigureRemoteBinding(&model, tc.input); err == nil {
|
||||
t.Fatalf("ConfigureRemoteBinding(%#v) error = nil, want validation error", tc.input)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemoveRemoteBindingRemovesProfileAndCredentialsFromVault(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
model := vault.Model{
|
||||
Entries: []vault.Entry{{
|
||||
ID: "remote-creds-1",
|
||||
Title: "Bellagio WebDAV Sign-In",
|
||||
Username: "linuscaldwell",
|
||||
Password: "bellagio-pass-1",
|
||||
}},
|
||||
RemoteProfiles: []vault.RemoteProfile{{
|
||||
ID: "bellagio-webdav",
|
||||
Name: "Bellagio Vault",
|
||||
Backend: vault.RemoteBackendWebDAV,
|
||||
BaseURL: "https://dav.example.invalid/remote.php/dav",
|
||||
Path: "files/bellagio/keepass.kdbx",
|
||||
}},
|
||||
}
|
||||
|
||||
err := RemoveRemoteBinding(&model, RemoteBinding{
|
||||
LocalVaultPath: "/tmp/bellagio.kdbx",
|
||||
RemoteProfileID: "bellagio-webdav",
|
||||
CredentialEntryID: "remote-creds-1",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("RemoveRemoteBinding() error = %v", err)
|
||||
}
|
||||
|
||||
if got := len(model.RemoteProfiles); got != 0 {
|
||||
t.Fatalf("len(RemoteProfiles) = %d, want 0", got)
|
||||
}
|
||||
if _, err := model.EntryByID("remote-creds-1"); !errors.Is(err, vault.ErrEntryNotFound) {
|
||||
t.Fatalf("EntryByID(remote-creds-1) error = %v, want ErrEntryNotFound", err)
|
||||
}
|
||||
}
|
||||
@@ -5,9 +5,12 @@ import (
|
||||
"fmt"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.julianfamily.org/keepassgo/vault"
|
||||
"git.julianfamily.org/keepassgo/webdav"
|
||||
"git.julianfamily.org/keepassgo/internal/apiapproval"
|
||||
"git.julianfamily.org/keepassgo/internal/apitokens"
|
||||
"git.julianfamily.org/keepassgo/internal/vault"
|
||||
"git.julianfamily.org/keepassgo/internal/webdav"
|
||||
)
|
||||
|
||||
type Section string
|
||||
@@ -21,6 +24,9 @@ const (
|
||||
SectionEntries Section = ""
|
||||
SectionTemplates Section = "templates"
|
||||
SectionRecycleBin Section = "recycle-bin"
|
||||
SectionAPITokens Section = "api-tokens"
|
||||
SectionAPIAudit Section = "api-audit"
|
||||
SectionAbout Section = "about"
|
||||
)
|
||||
|
||||
type CurrentSession interface {
|
||||
@@ -56,6 +62,7 @@ type SynchronizableSession interface {
|
||||
type AdvancedSynchronizableSession interface {
|
||||
CurrentSession
|
||||
SynchronizeFromLocal(string) error
|
||||
SynchronizeFromLocalBytes(string, []byte) error
|
||||
SynchronizeToLocal(string) error
|
||||
SynchronizeFromRemote(webdav.Client, string) error
|
||||
SynchronizeToRemote(webdav.Client, string) error
|
||||
@@ -81,8 +88,19 @@ type RemoteOpenableSession interface {
|
||||
OpenRemote(webdav.Client, string, vault.MasterKey) error
|
||||
}
|
||||
|
||||
type SecurityConfigurableSession interface {
|
||||
ConfigureSecurity(vault.SecuritySettings) error
|
||||
SecuritySettings() vault.SecuritySettings
|
||||
}
|
||||
|
||||
type ApprovalManager interface {
|
||||
Pending() []apiapproval.Request
|
||||
Resolve(string, apiapproval.Outcome) (apiapproval.Request, *apitokens.PolicyRule, error)
|
||||
}
|
||||
|
||||
type State struct {
|
||||
Session CurrentSession
|
||||
Approvals ApprovalManager
|
||||
Section Section
|
||||
CurrentPath []string
|
||||
SearchQuery string
|
||||
@@ -92,6 +110,207 @@ type State struct {
|
||||
ErrorMessage string
|
||||
}
|
||||
|
||||
func (s *State) PendingApprovals() []apiapproval.Request {
|
||||
if s.Approvals == nil {
|
||||
return nil
|
||||
}
|
||||
return s.Approvals.Pending()
|
||||
}
|
||||
|
||||
func (s *State) ResolveApproval(id string, outcome apiapproval.Outcome) error {
|
||||
if s.Approvals == nil {
|
||||
return fmt.Errorf("approval manager is not configured")
|
||||
}
|
||||
_, _, err := s.Approvals.Resolve(id, outcome)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *State) APITokens() ([]apitokens.Token, error) {
|
||||
model, err := s.currentModel()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return apitokens.Entries(model)
|
||||
}
|
||||
|
||||
func (s *State) RemoteProfiles() ([]vault.RemoteProfile, error) {
|
||||
model, err := s.currentModel()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
profiles := slices.Clone(model.RemoteProfiles)
|
||||
slices.SortFunc(profiles, func(a, b vault.RemoteProfile) int {
|
||||
switch {
|
||||
case a.Name < b.Name:
|
||||
return -1
|
||||
case a.Name > b.Name:
|
||||
return 1
|
||||
case a.ID < b.ID:
|
||||
return -1
|
||||
case a.ID > b.ID:
|
||||
return 1
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
})
|
||||
return profiles, nil
|
||||
}
|
||||
|
||||
func (s *State) RemoteCredentialEntries() ([]vault.Entry, error) {
|
||||
model, err := s.currentModel()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
entries := slices.Clone(model.Entries)
|
||||
slices.SortFunc(entries, func(a, b vault.Entry) int {
|
||||
switch {
|
||||
case a.Title < b.Title:
|
||||
return -1
|
||||
case a.Title > b.Title:
|
||||
return 1
|
||||
case a.ID < b.ID:
|
||||
return -1
|
||||
case a.ID > b.ID:
|
||||
return 1
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
})
|
||||
return entries, nil
|
||||
}
|
||||
|
||||
func (s *State) IssueAPIToken(name, clientName string, expiresAt *time.Time, now time.Time) (apitokens.Token, string, error) {
|
||||
session, ok := s.Session.(MutableSession)
|
||||
if !ok {
|
||||
return apitokens.Token{}, "", fmt.Errorf("session is not mutable")
|
||||
}
|
||||
model, err := session.Current()
|
||||
if err != nil {
|
||||
return apitokens.Token{}, "", err
|
||||
}
|
||||
token, secret, err := apitokens.Issue(name, clientName, expiresAt, now)
|
||||
if err != nil {
|
||||
return apitokens.Token{}, "", err
|
||||
}
|
||||
apitokens.Upsert(&model, token)
|
||||
session.Replace(model)
|
||||
s.Dirty = true
|
||||
return token, secret, nil
|
||||
}
|
||||
|
||||
func (s *State) RotateAPIToken(id string, now time.Time) (apitokens.Token, string, error) {
|
||||
session, ok := s.Session.(MutableSession)
|
||||
if !ok {
|
||||
return apitokens.Token{}, "", fmt.Errorf("session is not mutable")
|
||||
}
|
||||
model, err := session.Current()
|
||||
if err != nil {
|
||||
return apitokens.Token{}, "", err
|
||||
}
|
||||
token, err := apitokens.Find(model, id)
|
||||
if err != nil {
|
||||
return apitokens.Token{}, "", err
|
||||
}
|
||||
token, secret, err := apitokens.Rotate(token, now)
|
||||
if err != nil {
|
||||
return apitokens.Token{}, "", err
|
||||
}
|
||||
apitokens.Upsert(&model, token)
|
||||
session.Replace(model)
|
||||
s.Dirty = true
|
||||
return token, secret, nil
|
||||
}
|
||||
|
||||
func (s *State) UpsertAPIToken(token apitokens.Token) error {
|
||||
session, ok := s.Session.(MutableSession)
|
||||
if !ok {
|
||||
return fmt.Errorf("session is not mutable")
|
||||
}
|
||||
model, err := session.Current()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
apitokens.Upsert(&model, token)
|
||||
session.Replace(model)
|
||||
s.Dirty = true
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *State) DisableAPIToken(id string) error {
|
||||
session, ok := s.Session.(MutableSession)
|
||||
if !ok {
|
||||
return fmt.Errorf("session is not mutable")
|
||||
}
|
||||
model, err := session.Current()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
token, err := apitokens.Find(model, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
apitokens.Upsert(&model, apitokens.Disable(token))
|
||||
session.Replace(model)
|
||||
s.Dirty = true
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *State) RevokeAPIToken(id string, when time.Time) error {
|
||||
session, ok := s.Session.(MutableSession)
|
||||
if !ok {
|
||||
return fmt.Errorf("session is not mutable")
|
||||
}
|
||||
model, err := session.Current()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
token, err := apitokens.Find(model, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
apitokens.Upsert(&model, apitokens.Revoke(token, when))
|
||||
session.Replace(model)
|
||||
s.Dirty = true
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *State) DeleteAPIToken(id string) error {
|
||||
session, ok := s.Session.(MutableSession)
|
||||
if !ok {
|
||||
return fmt.Errorf("session is not mutable")
|
||||
}
|
||||
model, err := session.Current()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := apitokens.Delete(&model, id); err != nil {
|
||||
return err
|
||||
}
|
||||
session.Replace(model)
|
||||
s.Dirty = true
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *State) SecuritySettings() (vault.SecuritySettings, error) {
|
||||
security, ok := s.Session.(SecurityConfigurableSession)
|
||||
if !ok {
|
||||
return vault.SecuritySettings{}, fmt.Errorf("session does not expose security settings")
|
||||
}
|
||||
return security.SecuritySettings(), nil
|
||||
}
|
||||
|
||||
func (s *State) ConfigureSecurity(settings vault.SecuritySettings) error {
|
||||
security, ok := s.Session.(SecurityConfigurableSession)
|
||||
if !ok {
|
||||
return fmt.Errorf("session does not expose security settings")
|
||||
}
|
||||
if err := security.ConfigureSecurity(settings); err != nil {
|
||||
return err
|
||||
}
|
||||
s.Dirty = true
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *State) ShowSection(section Section) {
|
||||
s.Section = section
|
||||
s.CurrentPath = nil
|
||||
@@ -131,7 +350,7 @@ func (s *State) VisibleEntries() ([]vault.Entry, error) {
|
||||
}
|
||||
|
||||
if s.Section == SectionEntries {
|
||||
return model.EntriesInPath(s.CurrentPath), nil
|
||||
return entriesInPath(model.Entries, s.CurrentPath), nil
|
||||
}
|
||||
if s.Section == SectionRecycleBin || len(s.CurrentPath) == 0 {
|
||||
return entries, nil
|
||||
@@ -204,6 +423,8 @@ func (s *State) entriesForSection(model vault.Model) []vault.Entry {
|
||||
return slices.Clone(model.Templates)
|
||||
case SectionRecycleBin:
|
||||
return slices.Clone(model.RecycleBin)
|
||||
case SectionAPITokens, SectionAPIAudit, SectionAbout:
|
||||
return nil
|
||||
default:
|
||||
return slices.Clone(model.Entries)
|
||||
}
|
||||
@@ -549,6 +770,18 @@ func (s *State) SynchronizeFromLocal(path string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *State) SynchronizeFromLocalBytes(name string, content []byte) error {
|
||||
session, ok := s.Session.(AdvancedSynchronizableSession)
|
||||
if !ok {
|
||||
return fmt.Errorf("session is not advanced-synchronizable")
|
||||
}
|
||||
if err := session.SynchronizeFromLocalBytes(name, content); err != nil {
|
||||
return err
|
||||
}
|
||||
s.Dirty = false
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *State) SynchronizeToLocal(path string) error {
|
||||
session, ok := s.Session.(AdvancedSynchronizableSession)
|
||||
if !ok {
|
||||
@@ -647,6 +880,66 @@ func (s *State) OpenRemoteVault(client webdav.Client, path string, key vault.Mas
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *State) OpenBoundRemoteVault(binding RemoteBinding, key vault.MasterKey) error {
|
||||
model, err := s.currentModel()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
resolved, err := binding.Resolve(model)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
client := webdav.Client{
|
||||
BaseURL: resolved.Profile.BaseURL,
|
||||
Username: resolved.Credentials.Username,
|
||||
Password: resolved.Credentials.Password,
|
||||
}
|
||||
return s.OpenRemoteVault(client, resolved.Profile.Path, key)
|
||||
}
|
||||
|
||||
func (s *State) ConfigureRemoteBinding(input RemoteBindingInput) (RemoteBinding, error) {
|
||||
session, ok := s.Session.(MutableSession)
|
||||
if !ok {
|
||||
return RemoteBinding{}, fmt.Errorf("session is not mutable")
|
||||
}
|
||||
|
||||
model, err := session.Current()
|
||||
if err != nil {
|
||||
return RemoteBinding{}, err
|
||||
}
|
||||
|
||||
binding, err := ConfigureRemoteBinding(&model, input)
|
||||
if err != nil {
|
||||
return RemoteBinding{}, err
|
||||
}
|
||||
|
||||
session.Replace(model)
|
||||
s.Dirty = true
|
||||
return binding, nil
|
||||
}
|
||||
|
||||
func (s *State) RemoveRemoteBinding(binding RemoteBinding) error {
|
||||
session, ok := s.Session.(MutableSession)
|
||||
if !ok {
|
||||
return fmt.Errorf("session is not mutable")
|
||||
}
|
||||
|
||||
model, err := session.Current()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := RemoveRemoteBinding(&model, binding); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
session.Replace(model)
|
||||
s.Dirty = true
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *State) CreateGroup(name string) error {
|
||||
session, ok := s.Session.(MutableSession)
|
||||
if !ok {
|
||||
@@ -664,6 +957,27 @@ func (s *State) CreateGroup(name string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *State) MoveCurrentGroup(parent []string) error {
|
||||
session, ok := s.Session.(MutableSession)
|
||||
if !ok {
|
||||
return fmt.Errorf("session is not mutable")
|
||||
}
|
||||
model, err := session.Current()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
current := append([]string(nil), s.CurrentPath...)
|
||||
if err := model.MoveGroup(current, parent); err != nil {
|
||||
return err
|
||||
}
|
||||
session.Replace(model)
|
||||
if len(current) > 0 {
|
||||
s.CurrentPath = append(append([]string(nil), parent...), current[len(current)-1])
|
||||
}
|
||||
s.Dirty = true
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *State) RenameCurrentGroup(newName string) error {
|
||||
session, ok := s.Session.(MutableSession)
|
||||
if !ok {
|
||||
@@ -0,0 +1,90 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"git.julianfamily.org/keepassgo/internal/apiaudit"
|
||||
"git.julianfamily.org/keepassgo/internal/apitokens"
|
||||
)
|
||||
|
||||
type AuditQuickFilter struct {
|
||||
Label string
|
||||
Query string
|
||||
}
|
||||
|
||||
func Operations() []apitokens.Operation {
|
||||
return []apitokens.Operation{
|
||||
apitokens.OperationListEntries,
|
||||
apitokens.OperationListGroups,
|
||||
apitokens.OperationReadEntry,
|
||||
apitokens.OperationCopyPassword,
|
||||
apitokens.OperationCopyUsername,
|
||||
apitokens.OperationCopyURL,
|
||||
apitokens.OperationMutateEntry,
|
||||
apitokens.OperationMutateGroup,
|
||||
apitokens.OperationManageVault,
|
||||
}
|
||||
}
|
||||
|
||||
func AuditDecisionLabel(eventType apiaudit.EventType) string {
|
||||
switch eventType {
|
||||
case apiaudit.EventApprovalRequested:
|
||||
return "Requested"
|
||||
case apiaudit.EventApprovalAllowed:
|
||||
return "Allowed"
|
||||
case apiaudit.EventApprovalDenied:
|
||||
return "Denied"
|
||||
case apiaudit.EventApprovalCanceled:
|
||||
return "Canceled"
|
||||
case apiaudit.EventApprovalTimedOut:
|
||||
return "Timed Out"
|
||||
case apiaudit.EventAuthRejected:
|
||||
return "Auth Rejected"
|
||||
default:
|
||||
return strings.ReplaceAll(string(eventType), "_", " ")
|
||||
}
|
||||
}
|
||||
|
||||
func AuditOperationLabel(operation apitokens.Operation) string {
|
||||
if strings.TrimSpace(string(operation)) == "" {
|
||||
return "Other"
|
||||
}
|
||||
return strings.ReplaceAll(string(operation), "_", " ")
|
||||
}
|
||||
|
||||
func CompactAuditFilterLabel(label string) string {
|
||||
label = strings.TrimSpace(label)
|
||||
if len(label) <= 22 {
|
||||
return label
|
||||
}
|
||||
return label[:19] + "..."
|
||||
}
|
||||
|
||||
func AuditEventSearchTerms(event apiaudit.Event) string {
|
||||
parts := []string{
|
||||
string(event.Type),
|
||||
AuditDecisionLabel(event.Type),
|
||||
event.TokenName,
|
||||
event.ClientName,
|
||||
string(event.Operation),
|
||||
AuditOperationLabel(event.Operation),
|
||||
strings.Join(event.Resource.Path, " / "),
|
||||
event.Resource.EntryID,
|
||||
event.Message,
|
||||
}
|
||||
switch event.Type {
|
||||
case apiaudit.EventApprovalAllowed:
|
||||
parts = append(parts, "allow approved")
|
||||
case apiaudit.EventApprovalDenied:
|
||||
parts = append(parts, "deny denied")
|
||||
case apiaudit.EventApprovalRequested:
|
||||
parts = append(parts, "prompt requested")
|
||||
case apiaudit.EventApprovalCanceled:
|
||||
parts = append(parts, "cancel canceled")
|
||||
case apiaudit.EventApprovalTimedOut:
|
||||
parts = append(parts, "timeout timed out")
|
||||
case apiaudit.EventAuthRejected:
|
||||
parts = append(parts, "rejected unauthorized")
|
||||
}
|
||||
return strings.ToLower(strings.Join(parts, " "))
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package layout
|
||||
|
||||
type Mode string
|
||||
|
||||
const (
|
||||
ModeLocked Mode = "locked"
|
||||
ModeStatic Mode = "static"
|
||||
ModeEmpty Mode = "empty"
|
||||
ModeEditor Mode = "editor"
|
||||
ModeView Mode = "view"
|
||||
)
|
||||
|
||||
func Resolve(isLocked bool, hasStaticPanel bool, hasSelectedEntry bool, editing bool) Mode {
|
||||
switch {
|
||||
case isLocked:
|
||||
return ModeLocked
|
||||
case hasStaticPanel:
|
||||
return ModeStatic
|
||||
case !hasSelectedEntry && !editing:
|
||||
return ModeEmpty
|
||||
case editing:
|
||||
return ModeEditor
|
||||
default:
|
||||
return ModeView
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package detail
|
||||
|
||||
type EmptyState struct {
|
||||
Title string
|
||||
Body string
|
||||
}
|
||||
|
||||
type VaultSummary struct {
|
||||
Title string
|
||||
Detail string
|
||||
Context string
|
||||
}
|
||||
|
||||
type AttachmentItem struct {
|
||||
Name string
|
||||
Size int
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package editor
|
||||
|
||||
import "strings"
|
||||
|
||||
type Field string
|
||||
|
||||
const (
|
||||
FieldID Field = "id"
|
||||
FieldTitle Field = "title"
|
||||
FieldUsername Field = "username"
|
||||
FieldPassword Field = "password"
|
||||
FieldURL Field = "url"
|
||||
FieldPath Field = "path"
|
||||
FieldTags Field = "tags"
|
||||
FieldPasswordProfile Field = "password-profile"
|
||||
FieldNotes Field = "notes"
|
||||
FieldFields Field = "fields"
|
||||
FieldHistoryIndex Field = "history-index"
|
||||
)
|
||||
|
||||
func Label(field Field) string {
|
||||
switch field {
|
||||
case FieldID:
|
||||
return "ID"
|
||||
case FieldTitle:
|
||||
return "Title"
|
||||
case FieldUsername:
|
||||
return "Username"
|
||||
case FieldPassword:
|
||||
return "Password"
|
||||
case FieldURL:
|
||||
return "URL"
|
||||
case FieldPath:
|
||||
return "Path"
|
||||
case FieldTags:
|
||||
return "Tags"
|
||||
case FieldPasswordProfile:
|
||||
return "Password Profile"
|
||||
case FieldNotes:
|
||||
return "Notes"
|
||||
case FieldFields:
|
||||
return "Custom Fields"
|
||||
case FieldHistoryIndex:
|
||||
return "History Index"
|
||||
default:
|
||||
return strings.ReplaceAll(string(field), "-", " ")
|
||||
}
|
||||
}
|
||||
|
||||
func FocusOrder() []Field {
|
||||
return []Field{
|
||||
FieldID,
|
||||
FieldTitle,
|
||||
FieldUsername,
|
||||
FieldPassword,
|
||||
FieldURL,
|
||||
FieldPath,
|
||||
FieldTags,
|
||||
FieldPasswordProfile,
|
||||
FieldNotes,
|
||||
FieldFields,
|
||||
FieldHistoryIndex,
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package main
|
||||
package appui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
@@ -8,9 +8,9 @@ import (
|
||||
"strings"
|
||||
|
||||
"gioui.org/widget"
|
||||
"git.julianfamily.org/keepassgo/clipboard"
|
||||
"git.julianfamily.org/keepassgo/passwords"
|
||||
"git.julianfamily.org/keepassgo/vault"
|
||||
"git.julianfamily.org/keepassgo/internal/clipboard"
|
||||
"git.julianfamily.org/keepassgo/internal/passwords"
|
||||
"git.julianfamily.org/keepassgo/internal/vault"
|
||||
)
|
||||
|
||||
func (u *ui) attachmentInput() (string, []byte, error) {
|
||||
@@ -41,6 +41,7 @@ func (u *ui) attachmentInput() (string, []byte, error) {
|
||||
|
||||
func (u *ui) loadSelectedEntryIntoEditor() {
|
||||
u.resetPasswordPeek()
|
||||
u.clearGeneratedPasswordDraft()
|
||||
u.selectedHistoryIndex = -1
|
||||
u.historyIndex.SetText("")
|
||||
|
||||
@@ -175,6 +176,7 @@ func (u *ui) saveEntryAction() error {
|
||||
return err
|
||||
}
|
||||
u.editingEntry = false
|
||||
u.clearGeneratedPasswordDraft()
|
||||
u.filter()
|
||||
return nil
|
||||
}
|
||||
@@ -229,6 +231,7 @@ func (u *ui) saveTemplateAction() error {
|
||||
return err
|
||||
}
|
||||
u.editingEntry = false
|
||||
u.clearGeneratedPasswordDraft()
|
||||
u.filter()
|
||||
return nil
|
||||
}
|
||||
@@ -251,6 +254,14 @@ func (u *ui) createGroupAction() error {
|
||||
return u.state.CreateGroup(strings.TrimSpace(u.groupName.Text()))
|
||||
}
|
||||
|
||||
func (u *ui) moveCurrentGroupAction() error {
|
||||
u.clearDeleteGroupConfirmation()
|
||||
if len(u.displayPath()) == 0 {
|
||||
return fmt.Errorf("no current group selected")
|
||||
}
|
||||
return u.state.MoveCurrentGroup(parsePath(u.groupParentPath.Text()))
|
||||
}
|
||||
|
||||
func (u *ui) renameGroupAction() error {
|
||||
u.clearDeleteGroupConfirmation()
|
||||
return u.state.RenameCurrentGroup(strings.TrimSpace(u.groupName.Text()))
|
||||
@@ -400,6 +411,7 @@ func (u *ui) generatePasswordAction() error {
|
||||
return err
|
||||
}
|
||||
u.entryPassword.SetText(password)
|
||||
u.generatedPasswordDraft = true
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -481,3 +493,27 @@ func marshalFields(fields map[string]string) string {
|
||||
}
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
func (u *ui) clearGeneratedPasswordDraft() {
|
||||
u.generatedPasswordDraft = false
|
||||
}
|
||||
|
||||
func (u *ui) attachmentActionSummary() string {
|
||||
items := u.selectedAttachmentItems()
|
||||
if len(items) == 0 {
|
||||
return "No attachments yet. Add one below to store a file with this entry."
|
||||
}
|
||||
|
||||
name := strings.TrimSpace(u.attachmentName.Text())
|
||||
if name == "" {
|
||||
return "Select an attachment above, then replace it, export it, or remove it below."
|
||||
}
|
||||
|
||||
for _, item := range items {
|
||||
if item.Name == name {
|
||||
return fmt.Sprintf("Selected attachment %q. Replace it, export it, or remove it below.", name)
|
||||
}
|
||||
}
|
||||
|
||||
return fmt.Sprintf("Attachment %q is not on this entry yet. Add it as new, or select an existing attachment above.", name)
|
||||
}
|
||||
@@ -0,0 +1,335 @@
|
||||
package appui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"image"
|
||||
|
||||
"gioui.org/layout"
|
||||
"gioui.org/op"
|
||||
"gioui.org/op/paint"
|
||||
"gioui.org/unit"
|
||||
"gioui.org/widget"
|
||||
"gioui.org/widget/material"
|
||||
headerview "git.julianfamily.org/keepassgo/internal/appui/header"
|
||||
headerlayout "git.julianfamily.org/keepassgo/internal/appui/header/layout"
|
||||
)
|
||||
|
||||
func (u *ui) header(gtx layout.Context) layout.Dimensions {
|
||||
if u.usesCompactViewport() {
|
||||
if u.shouldShowLifecycleSetup() || u.isVaultLocked() {
|
||||
return layout.Dimensions{}
|
||||
}
|
||||
gtx.Constraints.Min.X = gtx.Constraints.Max.X
|
||||
return u.headerActions(gtx)
|
||||
}
|
||||
if u.shouldShowDesktopWorkingHeader() {
|
||||
return layout.Dimensions{}
|
||||
}
|
||||
return card(gtx, func(gtx layout.Context) layout.Dimensions {
|
||||
return layout.Flex{Alignment: layout.Middle}.Layout(gtx,
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
return u.brandMark(gtx, 196, 56)
|
||||
}),
|
||||
layout.Flexed(1, u.headerActions),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
func (u *ui) headerActions(gtx layout.Context) layout.Dimensions {
|
||||
if u.shouldShowLifecycleSetup() || u.isVaultLocked() {
|
||||
return layout.Dimensions{}
|
||||
}
|
||||
cluster := u.newHeaderActionCluster(gtx)
|
||||
rowDims := cluster.layout(gtx, u)
|
||||
if u.usesCompactViewport() {
|
||||
u.maybeLogHeaderBounds(newHeaderButtonBounds(image.Pt(u.frameInsetPx, u.frameInsetPx), cluster.Metrics.Bounds()))
|
||||
}
|
||||
|
||||
if u.usesCompactViewport() {
|
||||
cluster.prepareCompactMenus(gtx, u)
|
||||
return layout.Dimensions{Size: image.Pt(gtx.Constraints.Max.X, rowDims.Size.Y)}
|
||||
}
|
||||
return rowDims
|
||||
}
|
||||
|
||||
type headerActionCluster struct {
|
||||
Metrics headerlayout.ActionMetrics
|
||||
SyncMenu layout.Widget
|
||||
MainMenu layout.Widget
|
||||
}
|
||||
|
||||
func (c headerActionCluster) ShowSyncMenu() bool { return c.SyncMenu != nil }
|
||||
|
||||
func (c headerActionCluster) ShowMainMenu() bool { return c.MainMenu != nil }
|
||||
|
||||
func (u *ui) newHeaderActionCluster(gtx layout.Context) headerActionCluster {
|
||||
cluster := headerActionCluster{
|
||||
SyncMenu: u.syncMenuWidget(),
|
||||
MainMenu: u.mainMenuWidget(),
|
||||
}
|
||||
spacing := gtx.Dp(unit.Dp(8))
|
||||
cluster.Metrics = headerlayout.ActionMetrics{Spacing: spacing, SyncInnerSpacing: gtx.Dp(unit.Dp(3))}
|
||||
if !u.usesCompactViewport() {
|
||||
cluster.Metrics.SyncInnerSpacing = gtx.Dp(unit.Dp(4))
|
||||
}
|
||||
return cluster
|
||||
}
|
||||
|
||||
func (c *headerActionCluster) layout(gtx layout.Context, u *ui) layout.Dimensions {
|
||||
rowOps := op.Record(gtx.Ops)
|
||||
c.Metrics.RowDims = c.layoutRow(gtx, u)
|
||||
rowCall := rowOps.Stop()
|
||||
c.Metrics.RowOriginX = max(0, gtx.Constraints.Max.X-c.Metrics.RowDims.Size.X)
|
||||
return layout.E.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
|
||||
rowCall.Add(gtx.Ops)
|
||||
return c.Metrics.RowDims
|
||||
})
|
||||
}
|
||||
|
||||
func (c headerActionCluster) activeMenu() layout.Widget {
|
||||
switch {
|
||||
case c.ShowSyncMenu():
|
||||
return c.SyncMenu
|
||||
case c.ShowMainMenu():
|
||||
return c.MainMenu
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func (c *headerActionCluster) layoutRow(gtx layout.Context, u *ui) layout.Dimensions {
|
||||
return layout.Flex{Spacing: layout.SpaceStart}.Layout(gtx,
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
c.Metrics.SyncDims, c.Metrics.SyncPrimaryDims, c.Metrics.SyncToggleDims = u.syncButtonGroupWithMetrics(gtx)
|
||||
return c.Metrics.SyncDims
|
||||
}),
|
||||
layout.Rigid(layout.Spacer{Width: unit.Dp(8)}.Layout),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
btn := material.Button(u.theme, &u.lockVault, "Lock")
|
||||
c.Metrics.LockDims = btn.Layout(gtx)
|
||||
return c.Metrics.LockDims
|
||||
}),
|
||||
layout.Rigid(layout.Spacer{Width: unit.Dp(8)}.Layout),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
c.Metrics.MainDims = u.mainMenuButtonGroup(gtx)
|
||||
return c.Metrics.MainDims
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
func (c *headerActionCluster) prepareCompactMenus(gtx layout.Context, u *ui) {
|
||||
compactSurface := headerlayout.DropdownSurface{
|
||||
ContainerWidth: gtx.Constraints.Max.X,
|
||||
LeftInset: u.frameInsetPx,
|
||||
TopInset: u.frameInsetPx,
|
||||
}
|
||||
if c.ShowSyncMenu() {
|
||||
u.phoneSyncMenuVisible = true
|
||||
u.maybeLogHeaderMenuToggle("sync-visible", true)
|
||||
placement, menuCall := compactSurface.Place(gtx, c.Metrics.SyncAnchor(), c.SyncMenu)
|
||||
u.phoneSyncMenuOrigin = placement.Origin
|
||||
u.phoneSyncMenuSize = placement.Size
|
||||
u.phoneSyncMenuCall = menuCall
|
||||
u.maybeLogHeaderMenuPlacement("sync-phone", compactSurface, placement)
|
||||
}
|
||||
if c.ShowMainMenu() {
|
||||
u.phoneMainMenuVisible = true
|
||||
u.maybeLogHeaderMenuToggle("main-visible", true)
|
||||
placement, menuCall := compactSurface.Place(gtx, c.Metrics.MainAnchor(), c.MainMenu)
|
||||
u.phoneMainMenuOrigin = placement.Origin
|
||||
u.phoneMainMenuSize = placement.Size
|
||||
u.phoneMainMenuCall = menuCall
|
||||
u.maybeLogHeaderMenuPlacement("main-phone", compactSurface, placement)
|
||||
}
|
||||
}
|
||||
|
||||
func (c headerActionCluster) layoutMenuRow(gtx layout.Context) layout.Dimensions {
|
||||
menu := c.activeMenu()
|
||||
if menu == nil {
|
||||
return layout.Dimensions{}
|
||||
}
|
||||
fullWidthGTX := gtx
|
||||
fullWidthGTX.Constraints.Min = image.Point{}
|
||||
fullWidthGTX.Constraints.Min.X = fullWidthGTX.Constraints.Max.X
|
||||
dims := layout.E.Layout(fullWidthGTX, menu)
|
||||
return layout.Dimensions{Size: image.Pt(fullWidthGTX.Constraints.Max.X, dims.Size.Y)}
|
||||
}
|
||||
|
||||
type headerButtonBounds struct {
|
||||
SyncPrimary image.Rectangle
|
||||
SyncToggle image.Rectangle
|
||||
Lock image.Rectangle
|
||||
MainMenu image.Rectangle
|
||||
}
|
||||
|
||||
func newHeaderButtonBounds(origin image.Point, bounds headerlayout.ActionBounds) headerButtonBounds {
|
||||
return headerButtonBounds{
|
||||
SyncPrimary: bounds.SyncPrimary.Add(origin),
|
||||
SyncToggle: bounds.SyncToggle.Add(origin),
|
||||
Lock: bounds.Lock.Add(origin),
|
||||
MainMenu: bounds.MainMenu.Add(origin),
|
||||
}
|
||||
}
|
||||
|
||||
func (b headerButtonBounds) logLine(mode string) string {
|
||||
return fmt.Sprintf(
|
||||
"keepassgo header-bounds mode=%s sync=%s sync_toggle=%s lock=%s menu=%s",
|
||||
mode,
|
||||
formatHeaderRect(b.SyncPrimary),
|
||||
formatHeaderRect(b.SyncToggle),
|
||||
formatHeaderRect(b.Lock),
|
||||
formatHeaderRect(b.MainMenu),
|
||||
)
|
||||
}
|
||||
|
||||
func formatHeaderRect(rect image.Rectangle) string {
|
||||
return fmt.Sprintf("%d,%d-%d,%d", rect.Min.X, rect.Min.Y, rect.Max.X, rect.Max.Y)
|
||||
}
|
||||
|
||||
func (u *ui) topRightActionOrder() []string {
|
||||
if u.isVaultLocked() {
|
||||
return nil
|
||||
}
|
||||
return []string{"Sync", "Lock", "Menu"}
|
||||
}
|
||||
|
||||
func (u *ui) phoneHeaderMenus(gtx layout.Context) layout.Dimensions {
|
||||
if !u.usesCompactViewport() || (!u.syncMenuVisibleOnPhone() && !u.mainMenuVisibleOnPhone()) {
|
||||
return layout.Dimensions{}
|
||||
}
|
||||
|
||||
cluster := u.newHeaderActionCluster(gtx)
|
||||
if u.syncMenuVisibleOnPhone() {
|
||||
return layout.UniformInset(unit.Dp(16)).Layout(gtx, func(gtx layout.Context) layout.Dimensions {
|
||||
stack := op.Offset(image.Pt(0, max(0, u.phoneSyncMenuOrigin.Y-u.frameInsetPx))).Push(gtx.Ops)
|
||||
defer stack.Pop()
|
||||
dims := cluster.layoutMenuRow(gtx)
|
||||
return layout.Dimensions{Size: image.Pt(gtx.Constraints.Max.X, max(dims.Size.Y, u.phoneSyncMenuOrigin.Y))}
|
||||
})
|
||||
}
|
||||
if u.mainMenuVisibleOnPhone() {
|
||||
return layout.UniformInset(unit.Dp(16)).Layout(gtx, func(gtx layout.Context) layout.Dimensions {
|
||||
stack := op.Offset(image.Pt(0, max(0, u.phoneMainMenuOrigin.Y-u.frameInsetPx))).Push(gtx.Ops)
|
||||
defer stack.Pop()
|
||||
dims := cluster.layoutMenuRow(gtx)
|
||||
return layout.Dimensions{Size: image.Pt(gtx.Constraints.Max.X, max(dims.Size.Y, u.phoneMainMenuOrigin.Y))}
|
||||
})
|
||||
}
|
||||
return layout.Dimensions{}
|
||||
}
|
||||
|
||||
func (u *ui) desktopHeaderMenus(gtx layout.Context) layout.Dimensions {
|
||||
if u.usesCompactViewport() || (!u.syncMenuOpen && !u.mainMenuOpen) {
|
||||
return layout.Dimensions{}
|
||||
}
|
||||
cluster := u.newHeaderActionCluster(gtx)
|
||||
dims := cluster.layoutMenuRow(gtx)
|
||||
if dims.Size.Y == 0 {
|
||||
return dims
|
||||
}
|
||||
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
|
||||
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions { return dims }),
|
||||
)
|
||||
}
|
||||
|
||||
func (u *ui) syncMenuVisibleOnPhone() bool {
|
||||
return u.usesCompactViewport() && u.phoneSyncMenuVisible && u.syncMenuOpen
|
||||
}
|
||||
|
||||
func (u *ui) mainMenuVisibleOnPhone() bool {
|
||||
return u.usesCompactViewport() && u.phoneMainMenuVisible && u.mainMenuOpen
|
||||
}
|
||||
|
||||
func (u *ui) syncMenuDropsBelowTrigger() bool { return true }
|
||||
|
||||
func (u *ui) syncMenuRightAlignsToTrigger() bool { return true }
|
||||
|
||||
func (u *ui) headerMenusUseOverlayModel() bool { return true }
|
||||
|
||||
func (u *ui) mainMenuDropsBelowTrigger() bool { return true }
|
||||
|
||||
func (u *ui) mainMenuRightAlignsToTrigger() bool { return true }
|
||||
|
||||
func (u *ui) lifecycleBranding(gtx layout.Context) layout.Dimensions {
|
||||
if !u.usesCompactViewport() {
|
||||
return layout.Dimensions{}
|
||||
}
|
||||
return layout.Dimensions{}
|
||||
}
|
||||
|
||||
func (u *ui) brandMark(gtx layout.Context, widthDP, heightDP float32) layout.Dimensions {
|
||||
if u.usesCompactViewport() {
|
||||
return u.brandImage(gtx, u.splashSquare, widthDP, heightDP)
|
||||
}
|
||||
return u.brandImage(gtx, u.logoHorizontal, widthDP, heightDP)
|
||||
}
|
||||
|
||||
func (u *ui) brandImage(gtx layout.Context, src paint.ImageOp, widthDP, heightDP float32) layout.Dimensions {
|
||||
width := gtx.Dp(unit.Dp(widthDP))
|
||||
height := gtx.Dp(unit.Dp(heightDP))
|
||||
if width > gtx.Constraints.Max.X {
|
||||
width = gtx.Constraints.Max.X
|
||||
}
|
||||
if height > gtx.Constraints.Max.Y && gtx.Constraints.Max.Y > 0 {
|
||||
height = gtx.Constraints.Max.Y
|
||||
}
|
||||
img := widget.Image{
|
||||
Src: src,
|
||||
Fit: widget.Contain,
|
||||
Position: layout.W,
|
||||
Scale: 1.0 / gtx.Metric.PxPerDp,
|
||||
}
|
||||
gtx.Constraints.Min = image.Point{}
|
||||
gtx.Constraints.Max = image.Pt(width, height)
|
||||
return img.Layout(gtx)
|
||||
}
|
||||
|
||||
func (u *ui) mainMenuWidget() layout.Widget {
|
||||
if !u.mainMenuOpen {
|
||||
return nil
|
||||
}
|
||||
return u.mainMenu
|
||||
}
|
||||
|
||||
func (u *ui) mainMenu(gtx layout.Context) layout.Dimensions {
|
||||
rows := []layout.Widget{
|
||||
func(gtx layout.Context) layout.Dimensions {
|
||||
return tonedButton(gtx, u.theme, &u.showEntries, "Entries")
|
||||
},
|
||||
func(gtx layout.Context) layout.Dimensions {
|
||||
return tonedButton(gtx, u.theme, &u.showRecycle, "Recycle Bin")
|
||||
},
|
||||
func(gtx layout.Context) layout.Dimensions {
|
||||
return tonedButton(gtx, u.theme, &u.showAPITokens, "API Tokens")
|
||||
},
|
||||
func(gtx layout.Context) layout.Dimensions {
|
||||
return tonedButton(gtx, u.theme, &u.showAPIAudit, "API Audit")
|
||||
},
|
||||
func(gtx layout.Context) layout.Dimensions { return tonedButton(gtx, u.theme, &u.showAbout, "About") },
|
||||
func(gtx layout.Context) layout.Dimensions {
|
||||
return tonedButton(gtx, u.theme, &u.openSecuritySettings, "Settings")
|
||||
},
|
||||
}
|
||||
return headerview.MainMenu(gtx, u.theme, rows, compactCard, nil)
|
||||
}
|
||||
|
||||
func (u *ui) mainMenuButtonGroup(gtx layout.Context) layout.Dimensions {
|
||||
icon := u.menuIcon
|
||||
if icon == nil {
|
||||
icon = u.settingsIcon
|
||||
}
|
||||
return headerview.MainMenuButtonGroup(gtx, u.theme, &u.toggleMainMenu, icon, u.mainMenuOpen, selectedColor, accentColor)
|
||||
}
|
||||
|
||||
func intrinsicCompactCard(gtx layout.Context, w layout.Widget) layout.Dimensions {
|
||||
return headerlayout.IntrinsicCompactCard(gtx, w, compactCard, nil)
|
||||
}
|
||||
|
||||
func menuActionWidth(gtx layout.Context, rows []layout.Widget) int {
|
||||
return headerlayout.MenuActionWidth(gtx, rows)
|
||||
}
|
||||
|
||||
func rightAlignedMenuAction(gtx layout.Context, width int, child layout.Widget) layout.Dimensions {
|
||||
return headerlayout.RightAlignedAction(gtx, width, child)
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
package layout
|
||||
|
||||
import (
|
||||
"image"
|
||||
|
||||
"gioui.org/layout"
|
||||
"gioui.org/op"
|
||||
"gioui.org/unit"
|
||||
)
|
||||
|
||||
func AnchoredMenuX(triggerWidth, menuWidth int) int {
|
||||
return triggerWidth - menuWidth
|
||||
}
|
||||
|
||||
func AnchoredMenuOriginX(containerWidth, rowOriginX, triggerRightX, menuWidth int) int {
|
||||
x := rowOriginX + triggerRightX - menuWidth
|
||||
if x < 0 {
|
||||
return 0
|
||||
}
|
||||
if x+menuWidth > containerWidth {
|
||||
return max(0, containerWidth-menuWidth)
|
||||
}
|
||||
return x
|
||||
}
|
||||
|
||||
type DropdownAnchor struct {
|
||||
TriggerRightX int
|
||||
TriggerBottomY int
|
||||
}
|
||||
|
||||
func (a DropdownAnchor) Point() image.Point {
|
||||
return image.Pt(a.TriggerRightX, a.TriggerBottomY)
|
||||
}
|
||||
|
||||
type DropdownSurface struct {
|
||||
ContainerWidth int
|
||||
LeftInset int
|
||||
TopInset int
|
||||
}
|
||||
|
||||
type DropdownPlacement struct {
|
||||
Anchor DropdownAnchor
|
||||
Origin image.Point
|
||||
Size image.Point
|
||||
}
|
||||
|
||||
func (s DropdownSurface) MenuConstraints(gtx layout.Context) layout.Context {
|
||||
menuGTX := gtx
|
||||
menuGTX.Constraints.Min = image.Point{}
|
||||
menuGTX.Constraints.Max.X = max(0, s.ContainerWidth)
|
||||
return menuGTX
|
||||
}
|
||||
|
||||
func (s DropdownSurface) Origin(anchor DropdownAnchor, menuWidth int) image.Point {
|
||||
x := s.LeftInset + AnchoredMenuOriginX(s.ContainerWidth, 0, anchor.TriggerRightX, menuWidth)
|
||||
y := s.TopInset + anchor.TriggerBottomY
|
||||
return image.Pt(x, y)
|
||||
}
|
||||
|
||||
func (s DropdownSurface) Place(gtx layout.Context, anchor DropdownAnchor, menu layout.Widget) (DropdownPlacement, op.CallOp) {
|
||||
menuGTX := s.MenuConstraints(gtx)
|
||||
menuOps := op.Record(gtx.Ops)
|
||||
menuDims := layout.Inset{Top: unit.Dp(6)}.Layout(menuGTX, menu)
|
||||
menuCall := menuOps.Stop()
|
||||
menuOrigin := s.Origin(anchor, menuDims.Size.X)
|
||||
return DropdownPlacement{
|
||||
Anchor: anchor,
|
||||
Origin: menuOrigin,
|
||||
Size: menuDims.Size,
|
||||
}, menuCall
|
||||
}
|
||||
|
||||
func (s DropdownSurface) Draw(gtx layout.Context, anchor DropdownAnchor, menu layout.Widget) layout.Dimensions {
|
||||
placement, menuCall := s.Place(gtx, anchor, menu)
|
||||
stack := op.Offset(placement.Origin).Push(gtx.Ops)
|
||||
menuCall.Add(gtx.Ops)
|
||||
stack.Pop()
|
||||
return layout.Dimensions{Size: gtx.Constraints.Max}
|
||||
}
|
||||
|
||||
type ActionMetrics struct {
|
||||
RowOriginX int
|
||||
Spacing int
|
||||
SyncInnerSpacing int
|
||||
RowDims layout.Dimensions
|
||||
SyncDims layout.Dimensions
|
||||
SyncPrimaryDims layout.Dimensions
|
||||
SyncToggleDims layout.Dimensions
|
||||
LockDims layout.Dimensions
|
||||
MainDims layout.Dimensions
|
||||
}
|
||||
|
||||
func (m ActionMetrics) SyncAnchor() DropdownAnchor {
|
||||
return DropdownAnchor{
|
||||
TriggerRightX: m.RowOriginX + m.SyncDims.Size.X,
|
||||
TriggerBottomY: m.RowDims.Size.Y,
|
||||
}
|
||||
}
|
||||
|
||||
func (m ActionMetrics) MainAnchor() DropdownAnchor {
|
||||
triggerRightX := m.SyncDims.Size.X + m.Spacing + m.LockDims.Size.X + m.Spacing + m.MainDims.Size.X
|
||||
return DropdownAnchor{
|
||||
TriggerRightX: m.RowOriginX + triggerRightX,
|
||||
TriggerBottomY: m.RowDims.Size.Y,
|
||||
}
|
||||
}
|
||||
|
||||
type ActionBounds struct {
|
||||
SyncPrimary image.Rectangle
|
||||
SyncToggle image.Rectangle
|
||||
Lock image.Rectangle
|
||||
MainMenu image.Rectangle
|
||||
}
|
||||
|
||||
func (m ActionMetrics) Bounds() ActionBounds {
|
||||
top := 0
|
||||
syncLeft := m.RowOriginX
|
||||
syncPrimary := image.Rect(syncLeft, top, syncLeft+m.SyncPrimaryDims.Size.X, top+m.SyncPrimaryDims.Size.Y)
|
||||
syncToggleLeft := syncPrimary.Max.X + m.SyncInnerSpacing
|
||||
syncToggle := image.Rect(syncToggleLeft, top, syncToggleLeft+m.SyncToggleDims.Size.X, top+m.SyncToggleDims.Size.Y)
|
||||
lockLeft := syncLeft + m.SyncDims.Size.X + m.Spacing
|
||||
lock := image.Rect(lockLeft, top, lockLeft+m.LockDims.Size.X, top+m.LockDims.Size.Y)
|
||||
mainLeft := lock.Max.X + m.Spacing
|
||||
mainMenu := image.Rect(mainLeft, top, mainLeft+m.MainDims.Size.X, top+m.MainDims.Size.Y)
|
||||
return ActionBounds{
|
||||
SyncPrimary: syncPrimary,
|
||||
SyncToggle: syncToggle,
|
||||
Lock: lock,
|
||||
MainMenu: mainMenu,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
package layout
|
||||
|
||||
import (
|
||||
"image"
|
||||
|
||||
"gioui.org/layout"
|
||||
"gioui.org/op"
|
||||
"gioui.org/unit"
|
||||
)
|
||||
|
||||
func IntrinsicCompactCard(gtx layout.Context, w layout.Widget, card func(layout.Context, layout.Widget) layout.Dimensions, logger func(name string, constraints layout.Constraints, dims layout.Dimensions)) layout.Dimensions {
|
||||
measureGTX := gtx
|
||||
measureGTX.Constraints.Min = image.Point{}
|
||||
measureGTX.Constraints.Max.X = gtx.Constraints.Max.X
|
||||
macro := op.Record(gtx.Ops)
|
||||
contentDims := w(measureGTX)
|
||||
_ = macro.Stop()
|
||||
if logger != nil {
|
||||
logger("intrinsic-measure", measureGTX.Constraints, contentDims)
|
||||
}
|
||||
width := contentDims.Size.X + gtx.Dp(unit.Dp(20))
|
||||
maxWidth := gtx.Constraints.Max.X
|
||||
if maxWidth > 0 && width > maxWidth {
|
||||
width = maxWidth
|
||||
}
|
||||
if width > 0 {
|
||||
gtx.Constraints.Min.X = width
|
||||
gtx.Constraints.Max.X = width
|
||||
}
|
||||
dims := card(gtx, w)
|
||||
if logger != nil {
|
||||
logger("intrinsic-card", gtx.Constraints, dims)
|
||||
}
|
||||
return dims
|
||||
}
|
||||
|
||||
func MenuActionWidth(gtx layout.Context, rows []layout.Widget) int {
|
||||
width := 0
|
||||
for _, row := range rows {
|
||||
measureGTX := gtx
|
||||
measureGTX.Constraints.Min = image.Point{}
|
||||
macro := op.Record(gtx.Ops)
|
||||
dims := row(measureGTX)
|
||||
_ = macro.Stop()
|
||||
if dims.Size.X > width {
|
||||
width = dims.Size.X
|
||||
}
|
||||
}
|
||||
return width
|
||||
}
|
||||
|
||||
func RightAlignedAction(gtx layout.Context, width int, child layout.Widget) layout.Dimensions {
|
||||
if width <= 0 {
|
||||
return child(gtx)
|
||||
}
|
||||
gtx.Constraints.Min.X = width
|
||||
gtx.Constraints.Max.X = width
|
||||
return layout.E.Layout(gtx, child)
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package header
|
||||
|
||||
import (
|
||||
"image/color"
|
||||
"image"
|
||||
|
||||
"gioui.org/layout"
|
||||
"gioui.org/unit"
|
||||
"gioui.org/widget"
|
||||
"gioui.org/widget/material"
|
||||
headerlayout "git.julianfamily.org/keepassgo/internal/appui/header/layout"
|
||||
)
|
||||
|
||||
func MainMenu(gtx layout.Context, theme *material.Theme, rows []layout.Widget, card func(layout.Context, layout.Widget) layout.Dimensions, logger func(name string, constraints layout.Constraints, dims layout.Dimensions)) layout.Dimensions {
|
||||
rowWidth := headerlayout.MenuActionWidth(gtx, rows)
|
||||
if logger != nil {
|
||||
logger("row-width", gtx.Constraints, layout.Dimensions{Size: image.Pt(rowWidth, 0)})
|
||||
}
|
||||
dims := headerlayout.IntrinsicCompactCard(gtx, func(gtx layout.Context) layout.Dimensions {
|
||||
children := make([]layout.FlexChild, 0, (len(rows)*2)-1)
|
||||
for i, row := range rows {
|
||||
if i > 0 {
|
||||
children = append(children, layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout))
|
||||
}
|
||||
current := row
|
||||
children = append(children, layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
return headerlayout.RightAlignedAction(gtx, rowWidth, current)
|
||||
}))
|
||||
}
|
||||
dims := layout.Flex{Axis: layout.Vertical}.Layout(gtx, children...)
|
||||
if logger != nil {
|
||||
logger("rows", gtx.Constraints, dims)
|
||||
}
|
||||
return dims
|
||||
}, card, logger)
|
||||
if logger != nil {
|
||||
logger("card", gtx.Constraints, dims)
|
||||
}
|
||||
return dims
|
||||
}
|
||||
|
||||
func MainMenuButtonGroup(gtx layout.Context, theme *material.Theme, click *widget.Clickable, icon *widget.Icon, open bool, selectedColor, accentColor color.NRGBA) layout.Dimensions {
|
||||
btn := material.IconButton(theme, click, icon, "Menu")
|
||||
if open {
|
||||
btn.Background = accentColor
|
||||
btn.Color = color.NRGBA{R: 255, G: 252, B: 247, A: 255}
|
||||
} else {
|
||||
btn.Background = selectedColor
|
||||
btn.Color = accentColor
|
||||
}
|
||||
btn.Size = unit.Dp(18)
|
||||
btn.Inset = layout.UniformInset(unit.Dp(8))
|
||||
return btn.Layout(gtx)
|
||||
}
|
||||
@@ -0,0 +1,362 @@
|
||||
package appui
|
||||
|
||||
import (
|
||||
"image"
|
||||
"image/color"
|
||||
"runtime"
|
||||
"strings"
|
||||
|
||||
"gioui.org/layout"
|
||||
"gioui.org/op"
|
||||
"gioui.org/unit"
|
||||
"gioui.org/widget"
|
||||
"gioui.org/widget/material"
|
||||
"git.julianfamily.org/keepassgo/internal/appstate"
|
||||
syncmodel "git.julianfamily.org/keepassgo/internal/appui/sync"
|
||||
"git.julianfamily.org/keepassgo/internal/vault"
|
||||
)
|
||||
|
||||
func (u *ui) syncButtonGroupWithMetrics(gtx layout.Context) (layout.Dimensions, layout.Dimensions, layout.Dimensions) {
|
||||
spacing := unit.Dp(4)
|
||||
if u.usesCompactViewport() {
|
||||
spacing = unit.Dp(3)
|
||||
}
|
||||
var primaryDims layout.Dimensions
|
||||
var toggleDims layout.Dimensions
|
||||
groupDims := layout.Flex{Alignment: layout.Middle}.Layout(gtx,
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
primaryDims = syncPrimaryButton(gtx, u.theme, &u.synchronizeVault, "Sync", u.usesCompactViewport())
|
||||
return primaryDims
|
||||
}),
|
||||
layout.Rigid(layout.Spacer{Width: spacing}.Layout),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
toggleDims = u.syncMenuToggle(gtx)
|
||||
return toggleDims
|
||||
}),
|
||||
)
|
||||
return groupDims, primaryDims, toggleDims
|
||||
}
|
||||
|
||||
func (u *ui) syncMenuToggle(gtx layout.Context) layout.Dimensions {
|
||||
btn := material.IconButton(u.theme, &u.toggleSyncMenu, u.chevronDownIcon, "More synchronize actions")
|
||||
if u.syncMenuOpen {
|
||||
btn.Background = accentColor
|
||||
btn.Color = color.NRGBA{R: 255, G: 252, B: 247, A: 255}
|
||||
} else {
|
||||
btn.Background = color.NRGBA{R: 231, G: 236, B: 232, A: 255}
|
||||
btn.Color = accentColor
|
||||
}
|
||||
btn.Size = unit.Dp(18)
|
||||
btn.Inset = layout.UniformInset(unit.Dp(8))
|
||||
if u.usesCompactViewport() {
|
||||
btn.Size = unit.Dp(16)
|
||||
btn.Inset = layout.UniformInset(unit.Dp(7))
|
||||
}
|
||||
return btn.Layout(gtx)
|
||||
}
|
||||
|
||||
func (u *ui) syncMenuWidget() layout.Widget {
|
||||
if !u.syncMenuOpen {
|
||||
return nil
|
||||
}
|
||||
return u.syncMenu
|
||||
}
|
||||
|
||||
func (u *ui) syncMenu(gtx layout.Context) layout.Dimensions {
|
||||
model := u.buildSyncMenuModel()
|
||||
profiles := u.availableRemoteProfiles()
|
||||
credentials := u.availableRemoteCredentialEntries()
|
||||
if len(u.vaultRemoteProfileClicks) < len(profiles) {
|
||||
u.vaultRemoteProfileClicks = make([]widget.Clickable, len(profiles))
|
||||
}
|
||||
if len(u.vaultRemoteCredentialClicks) < len(credentials) {
|
||||
u.vaultRemoteCredentialClicks = make([]widget.Clickable, len(credentials))
|
||||
}
|
||||
actionRows := u.syncMenuActionRows(model)
|
||||
actionWidth := menuActionWidth(gtx, actionRows)
|
||||
menu := func(gtx layout.Context) layout.Dimensions {
|
||||
return intrinsicCompactCard(gtx, func(gtx layout.Context) layout.Dimensions {
|
||||
return layout.Flex{Axis: layout.Vertical}.Layout(gtx, u.syncMenuRows(model, profiles, credentials, actionWidth)...)
|
||||
})
|
||||
}
|
||||
reserveWidth := u.syncMenuTrailingReserveWidth(gtx)
|
||||
if reserveWidth <= 0 {
|
||||
return menu(gtx)
|
||||
}
|
||||
return layout.Flex{}.Layout(gtx,
|
||||
layout.Rigid(menu),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
return layout.Dimensions{Size: image.Pt(reserveWidth, 0)}
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
func (u *ui) syncMenuTrailingReserveWidth(gtx layout.Context) int {
|
||||
spacing := gtx.Dp(unit.Dp(8))
|
||||
if u.usesCompactViewport() {
|
||||
spacing = gtx.Dp(unit.Dp(8))
|
||||
}
|
||||
|
||||
measureGTX := gtx
|
||||
measureGTX.Constraints.Min = image.Point{}
|
||||
|
||||
lockOps := op.Record(gtx.Ops)
|
||||
lockDims := func(gtx layout.Context) layout.Dimensions {
|
||||
btn := material.Button(u.theme, &u.lockVault, "Lock")
|
||||
return btn.Layout(gtx)
|
||||
}(measureGTX)
|
||||
_ = lockOps.Stop()
|
||||
|
||||
menuOps := op.Record(gtx.Ops)
|
||||
menuDims := u.mainMenuButtonGroup(measureGTX)
|
||||
_ = menuOps.Stop()
|
||||
|
||||
return spacing + lockDims.Size.X + spacing + menuDims.Size.X
|
||||
}
|
||||
|
||||
func (u *ui) syncMenuActionRows(model syncmodel.MenuModel) []layout.Widget {
|
||||
rows := []layout.Widget{
|
||||
func(gtx layout.Context) layout.Dimensions {
|
||||
return tonedButton(gtx, u.theme, &u.openAdvancedSync, "Open Advanced Sync")
|
||||
},
|
||||
}
|
||||
if model.ShowShare {
|
||||
rows = append(rows, func(gtx layout.Context) layout.Dimensions {
|
||||
return tonedButton(gtx, u.theme, &u.shareCurrentVault, "Share Vault")
|
||||
})
|
||||
}
|
||||
if model.ShowRemoteSyncSetupShortcut() {
|
||||
rows = append(rows, func(gtx layout.Context) layout.Dimensions {
|
||||
return tonedButton(gtx, u.theme, &u.useSavedAdvancedSyncRemote, model.RemoteSyncSetupShortcutLabel())
|
||||
})
|
||||
}
|
||||
if model.ShowDirectRemoteSyncShortcut() {
|
||||
rows = append(rows, func(gtx layout.Context) layout.Dimensions {
|
||||
return tonedButton(gtx, u.theme, &u.openSelectedVaultRemote, model.DirectRemoteSyncShortcutLabel())
|
||||
})
|
||||
}
|
||||
if model.ShowRemoteSyncSettingsShortcut() {
|
||||
rows = append(rows, func(gtx layout.Context) layout.Dimensions {
|
||||
return tonedButton(gtx, u.theme, &u.useSavedAdvancedSyncRemote, model.RemoteSyncSettingsShortcutLabel())
|
||||
})
|
||||
}
|
||||
if model.ShowRemoveRemoteSyncShortcut() {
|
||||
rows = append(rows, func(gtx layout.Context) layout.Dimensions {
|
||||
return tonedButton(gtx, u.theme, &u.removeSelectedRemoteBinding, model.RemoveRemoteSyncShortcutLabel())
|
||||
})
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
func (u *ui) syncMenuRows(model syncmodel.MenuModel, profiles []vault.RemoteProfile, credentials []vault.Entry, actionWidth int) []layout.FlexChild {
|
||||
rows := u.syncMenuPrimaryRows(model, actionWidth)
|
||||
rows = append(rows, u.syncMenuSavedBindingRows(model, profiles, credentials)...)
|
||||
if model.ShowSaveCurrentBinding {
|
||||
rows = append(rows, u.syncMenuSaveBindingRows(model)...)
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
func (u *ui) syncMenuPrimaryRows(model syncmodel.MenuModel, actionWidth int) []layout.FlexChild {
|
||||
rows := []layout.FlexChild{}
|
||||
if model.ShowShare {
|
||||
rows = append(rows, layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
return rightAlignedMenuAction(gtx, actionWidth, func(gtx layout.Context) layout.Dimensions {
|
||||
return tonedButton(gtx, u.theme, &u.shareCurrentVault, "Share Vault")
|
||||
})
|
||||
}),
|
||||
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
|
||||
)
|
||||
}))
|
||||
}
|
||||
rows = append(rows, u.syncMenuActionRow(actionWidth, &u.openAdvancedSync, "Open Advanced Sync"))
|
||||
if model.ShowRemoteSyncSetupShortcut() {
|
||||
rows = append(rows, layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout))
|
||||
rows = append(rows, u.syncMenuActionRow(actionWidth, &u.useSavedAdvancedSyncRemote, model.RemoteSyncSetupShortcutLabel()))
|
||||
}
|
||||
if model.ShowDirectRemoteSyncShortcut() {
|
||||
rows = append(rows, layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout))
|
||||
rows = append(rows, u.syncMenuActionRow(actionWidth, &u.openSelectedVaultRemote, model.DirectRemoteSyncShortcutLabel()))
|
||||
}
|
||||
if model.ShowRemoteSyncSettingsShortcut() {
|
||||
rows = append(rows, layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout))
|
||||
rows = append(rows, u.syncMenuActionRow(actionWidth, &u.useSavedAdvancedSyncRemote, model.RemoteSyncSettingsShortcutLabel()))
|
||||
}
|
||||
if model.ShowRemoveRemoteSyncShortcut() {
|
||||
rows = append(rows, layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout))
|
||||
rows = append(rows, u.syncMenuActionRow(actionWidth, &u.removeSelectedRemoteBinding, model.RemoveRemoteSyncShortcutLabel()))
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
func (u *ui) syncMenuActionRow(actionWidth int, click *widget.Clickable, label string) layout.FlexChild {
|
||||
return layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
return rightAlignedMenuAction(gtx, actionWidth, func(gtx layout.Context) layout.Dimensions {
|
||||
return tonedButton(gtx, u.theme, click, label)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func (u *ui) syncMenuSavedBindingRows(model syncmodel.MenuModel, profiles []vault.RemoteProfile, credentials []vault.Entry) []layout.FlexChild {
|
||||
if !u.hasOpenVault() || len(profiles) == 0 || len(credentials) == 0 {
|
||||
return nil
|
||||
}
|
||||
rows := []layout.FlexChild{
|
||||
layout.Rigid(layout.Spacer{Height: unit.Dp(10)}.Layout),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
lbl := material.Label(u.theme, unit.Sp(11), model.SavedBindingHeading())
|
||||
lbl.Color = mutedColor
|
||||
return lbl.Layout(gtx)
|
||||
}),
|
||||
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
|
||||
}
|
||||
if !model.ShowSelectors {
|
||||
rows = append(rows, layout.Rigid(u.syncMenuSavedBindingSummary(model)))
|
||||
} else {
|
||||
rows = append(rows, u.syncMenuSelectorRows(model, profiles, credentials)...)
|
||||
}
|
||||
if _, ok := u.selectedVaultRemoteProfile(); ok {
|
||||
if _, ok := u.selectedVaultRemoteCredentialEntry(); ok {
|
||||
rows = append(rows,
|
||||
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
return tonedButton(gtx, u.theme, &u.openSelectedVaultRemote, u.openSelectedVaultRemoteButtonLabel())
|
||||
}),
|
||||
)
|
||||
}
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
func (u *ui) syncMenuSavedBindingSummary(model syncmodel.MenuModel) layout.Widget {
|
||||
return func(gtx layout.Context) layout.Dimensions {
|
||||
summary := model.SavedBindingSummary
|
||||
if !summary.OK {
|
||||
return layout.Dimensions{}
|
||||
}
|
||||
return layout.Background{}.Layout(gtx, fill(color.NRGBA{R: 242, G: 245, B: 240, A: 255}), func(gtx layout.Context) layout.Dimensions {
|
||||
return layout.UniformInset(unit.Dp(10)).Layout(gtx, func(gtx layout.Context) layout.Dimensions {
|
||||
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
lbl := material.Label(u.theme, unit.Sp(13), summary.ProfileLabel)
|
||||
lbl.Color = accentColor
|
||||
return lbl.Layout(gtx)
|
||||
}),
|
||||
layout.Rigid(layout.Spacer{Height: unit.Dp(2)}.Layout),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
lbl := material.Label(u.theme, unit.Sp(12), "Credential: "+summary.CredentialLabel)
|
||||
lbl.Color = mutedColor
|
||||
return lbl.Layout(gtx)
|
||||
}),
|
||||
layout.Rigid(layout.Spacer{Height: unit.Dp(2)}.Layout),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
lbl := material.Label(u.theme, unit.Sp(12), summary.SyncLabel)
|
||||
lbl.Color = mutedColor
|
||||
return lbl.Layout(gtx)
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func (u *ui) syncMenuSaveBindingRows(model syncmodel.MenuModel) []layout.FlexChild {
|
||||
return []layout.FlexChild{
|
||||
layout.Rigid(layout.Spacer{Height: unit.Dp(10)}.Layout),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
lbl := material.Label(u.theme, unit.Sp(11), model.SaveCurrentRemoteBindingHeading())
|
||||
lbl.Color = mutedColor
|
||||
return lbl.Layout(gtx)
|
||||
}),
|
||||
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
return tonedButton(gtx, u.theme, &u.saveCurrentRemoteBinding, model.SaveCurrentRemoteBindingButtonLabel())
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
func (u *ui) syncMenuSelectorRows(_ syncmodel.MenuModel, profiles []vault.RemoteProfile, credentials []vault.Entry) []layout.FlexChild {
|
||||
rows := make([]layout.FlexChild, 0, len(profiles)+len(credentials)+4)
|
||||
for i, profile := range profiles {
|
||||
i := i
|
||||
profile := profile
|
||||
rows = append(rows, layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
selected := u.selectedVaultRemoteProfileID == profile.ID
|
||||
return layout.Inset{Bottom: unit.Dp(4)}.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
|
||||
return recentSelectionCard(gtx, selected, func(gtx layout.Context) layout.Dimensions {
|
||||
return u.vaultRemoteProfileClicks[i].Layout(gtx, func(gtx layout.Context) layout.Dimensions {
|
||||
lbl := material.Label(u.theme, unit.Sp(13), profile.Name)
|
||||
lbl.Color = accentColor
|
||||
return lbl.Layout(gtx)
|
||||
})
|
||||
})
|
||||
})
|
||||
}))
|
||||
}
|
||||
rows = append(rows, layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout))
|
||||
for i, entry := range credentials {
|
||||
i := i
|
||||
entry := entry
|
||||
rows = append(rows, layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
selected := u.selectedVaultRemoteCredentialEntryID == entry.ID
|
||||
label := entry.Title
|
||||
if entry.Username != "" {
|
||||
label += " · " + entry.Username
|
||||
}
|
||||
return layout.Inset{Bottom: unit.Dp(4)}.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
|
||||
return recentSelectionCard(gtx, selected, func(gtx layout.Context) layout.Dimensions {
|
||||
return u.vaultRemoteCredentialClicks[i].Layout(gtx, func(gtx layout.Context) layout.Dimensions {
|
||||
lbl := material.Label(u.theme, unit.Sp(13), label)
|
||||
lbl.Color = accentColor
|
||||
return lbl.Layout(gtx)
|
||||
})
|
||||
})
|
||||
})
|
||||
}))
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
func (u *ui) buildSyncMenuModel() syncmodel.MenuModel {
|
||||
model := syncmodel.MenuModel{
|
||||
HasOpenVault: u.hasOpenVault(),
|
||||
ShowSelectors: u.shouldShowSavedRemoteBindingSelectors(),
|
||||
ShowShare: supportsVaultShare(runtime.GOOS) && u.vaultSharer != nil && strings.TrimSpace(u.currentShareableVaultPath()) != "",
|
||||
RemoteBaseURL: strings.TrimSpace(u.remoteBaseURL.Text()),
|
||||
RemotePath: strings.TrimSpace(u.remotePath.Text()),
|
||||
RemoteUsername: strings.TrimSpace(u.remoteUsername.Text()),
|
||||
RemotePassword: u.remotePassword.Text(),
|
||||
SelectedVaultSyncMode: normalizeUISyncMode(u.selectedVaultRemoteSyncMode),
|
||||
}
|
||||
_, model.HasSelectedBinding = u.selectedVaultRemoteBinding()
|
||||
model.SavedBindingSummary = u.computeSavedRemoteBindingSummary()
|
||||
model.ShowSaveCurrentBinding = model.HasOpenVault && model.RemoteBaseURL != "" && model.RemotePath != "" && model.RemoteUsername != "" && model.RemotePassword != ""
|
||||
return model
|
||||
}
|
||||
|
||||
func (u *ui) computeSavedRemoteBindingSummary() syncmodel.MenuBindingSummary {
|
||||
profile, ok := u.selectedVaultRemoteProfile()
|
||||
if !ok {
|
||||
return syncmodel.MenuBindingSummary{}
|
||||
}
|
||||
entry, ok := u.selectedVaultRemoteCredentialEntry()
|
||||
if !ok {
|
||||
return syncmodel.MenuBindingSummary{}
|
||||
}
|
||||
credentialLabel := entry.Title
|
||||
if strings.TrimSpace(entry.Username) != "" {
|
||||
credentialLabel += " · " + strings.TrimSpace(entry.Username)
|
||||
}
|
||||
syncLabel := "Sync manually when you choose Use Remote Sync."
|
||||
if normalizeUISyncMode(u.selectedVaultRemoteSyncMode) == appstate.SyncModeAutomaticOnOpenSave {
|
||||
syncLabel = "Syncs automatically on open and save."
|
||||
}
|
||||
return syncmodel.MenuBindingSummary{
|
||||
ProfileLabel: profile.Name,
|
||||
CredentialLabel: credentialLabel,
|
||||
SyncLabel: syncLabel,
|
||||
OK: true,
|
||||
}
|
||||
}
|
||||
@@ -1,32 +1,46 @@
|
||||
package main
|
||||
package appui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"gioui.org/io/event"
|
||||
"gioui.org/io/key"
|
||||
"git.julianfamily.org/keepassgo/appstate"
|
||||
"gioui.org/layout"
|
||||
"git.julianfamily.org/keepassgo/internal/appstate"
|
||||
editormodel "git.julianfamily.org/keepassgo/internal/appui/editor"
|
||||
"git.julianfamily.org/keepassgo/internal/clipboard"
|
||||
)
|
||||
|
||||
type focusID string
|
||||
|
||||
type detailField string
|
||||
type detailField = editormodel.Field
|
||||
|
||||
const (
|
||||
focusSearch focusID = "search"
|
||||
|
||||
detailFieldID detailField = "id"
|
||||
detailFieldTitle detailField = "title"
|
||||
detailFieldUsername detailField = "username"
|
||||
detailFieldPassword detailField = "password"
|
||||
detailFieldURL detailField = "url"
|
||||
detailFieldPath detailField = "path"
|
||||
detailFieldTags detailField = "tags"
|
||||
detailFieldPasswordProfile detailField = "password-profile"
|
||||
detailFieldNotes detailField = "notes"
|
||||
detailFieldFields detailField = "fields"
|
||||
detailFieldHistoryIndex detailField = "history-index"
|
||||
detailFieldID = editormodel.FieldID
|
||||
detailFieldTitle = editormodel.FieldTitle
|
||||
detailFieldUsername = editormodel.FieldUsername
|
||||
detailFieldPassword = editormodel.FieldPassword
|
||||
detailFieldURL = editormodel.FieldURL
|
||||
detailFieldPath = editormodel.FieldPath
|
||||
detailFieldTags = editormodel.FieldTags
|
||||
detailFieldPasswordProfile = editormodel.FieldPasswordProfile
|
||||
detailFieldNotes = editormodel.FieldNotes
|
||||
detailFieldFields = editormodel.FieldFields
|
||||
detailFieldHistoryIndex = editormodel.FieldHistoryIndex
|
||||
)
|
||||
|
||||
const (
|
||||
shortcutSearch = "search"
|
||||
shortcutSave = "save"
|
||||
shortcutLock = "lock"
|
||||
shortcutNewEntry = "new-entry"
|
||||
shortcutCopyUser = "copy-user"
|
||||
shortcutCopyPassword = "copy-password"
|
||||
shortcutCopyURL = "copy-url"
|
||||
)
|
||||
|
||||
func breadcrumbFocusID(index int) focusID {
|
||||
@@ -41,19 +55,81 @@ func detailFocusID(field detailField) focusID {
|
||||
return focusID("detail:" + string(field))
|
||||
}
|
||||
|
||||
func (u *ui) processShortcuts(gtx layout.Context) {
|
||||
event.Op(gtx.Ops, u)
|
||||
for {
|
||||
ev, ok := gtx.Event(
|
||||
key.Filter{Name: "F", Required: key.ModShortcut},
|
||||
key.Filter{Name: "S", Required: key.ModShortcut},
|
||||
key.Filter{Name: "L", Required: key.ModShortcut},
|
||||
key.Filter{Name: "N", Required: key.ModShortcut},
|
||||
key.Filter{Name: "U", Required: key.ModShortcut},
|
||||
key.Filter{Name: "P", Required: key.ModShortcut},
|
||||
key.Filter{Name: "O", Required: key.ModShortcut},
|
||||
key.Filter{Name: key.NameTab, Optional: key.ModShift},
|
||||
key.Filter{Name: key.NameLeftArrow},
|
||||
key.Filter{Name: key.NameRightArrow},
|
||||
key.Filter{Name: key.NameUpArrow},
|
||||
key.Filter{Name: key.NameDownArrow},
|
||||
key.Filter{Name: key.NameReturn},
|
||||
key.Filter{Name: key.NameBack},
|
||||
key.Filter{Name: key.NameEscape},
|
||||
)
|
||||
if !ok {
|
||||
break
|
||||
}
|
||||
|
||||
ke, ok := ev.(key.Event)
|
||||
if !ok || ke.State != key.Press {
|
||||
continue
|
||||
}
|
||||
|
||||
u.handleKeyPress(ke.Name, ke.Modifiers)
|
||||
if ke.Name == key.NameBack || ke.Name == key.NameEscape {
|
||||
_ = u.handlePhoneBack()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (u *ui) performShortcut(name string) error {
|
||||
switch name {
|
||||
case shortcutSearch:
|
||||
u.keyboardFocus = focusSearch
|
||||
return nil
|
||||
case shortcutSave:
|
||||
return u.saveAction()
|
||||
case shortcutLock:
|
||||
return u.lockAction()
|
||||
case shortcutNewEntry:
|
||||
u.state.BeginNewEntry()
|
||||
u.loadSelectedEntryIntoEditor()
|
||||
u.entryPath.SetText(strings.Join(u.state.CurrentPath, " / "))
|
||||
u.keyboardFocus = detailFocusID(detailFieldTitle)
|
||||
return nil
|
||||
case shortcutCopyUser:
|
||||
return u.copySelectedFieldAction(clipboard.TargetUsername)
|
||||
case shortcutCopyPassword:
|
||||
return u.copySelectedFieldAction(clipboard.TargetPassword)
|
||||
case shortcutCopyURL:
|
||||
return u.copySelectedFieldAction(clipboard.TargetURL)
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func (u *ui) handleKeyPress(name key.Name, modifiers key.Modifiers) bool {
|
||||
if u.handleShortcutKey(name, modifiers) {
|
||||
return true
|
||||
}
|
||||
if u.isVaultLocked() && name == key.NameReturn {
|
||||
u.runAction("unlock vault", u.unlockAction)
|
||||
u.startUnlockAction()
|
||||
return true
|
||||
}
|
||||
if u.shouldShowLifecycleSetup() && name == key.NameReturn {
|
||||
if u.lifecycleMode == "remote" {
|
||||
u.runAction("open remote vault", u.openRemoteAction)
|
||||
u.startOpenRemoteAction()
|
||||
} else {
|
||||
u.runAction("open vault", u.openVaultAction)
|
||||
u.startOpenVaultAction()
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -336,19 +412,7 @@ func (u *ui) focusedDetailIndex() int {
|
||||
}
|
||||
|
||||
func detailFocusOrder() []detailField {
|
||||
return []detailField{
|
||||
detailFieldID,
|
||||
detailFieldTitle,
|
||||
detailFieldUsername,
|
||||
detailFieldPassword,
|
||||
detailFieldURL,
|
||||
detailFieldPath,
|
||||
detailFieldTags,
|
||||
detailFieldPasswordProfile,
|
||||
detailFieldNotes,
|
||||
detailFieldFields,
|
||||
detailFieldHistoryIndex,
|
||||
}
|
||||
return editormodel.FocusOrder()
|
||||
}
|
||||
|
||||
func canonicalFocusID(id focusID) focusID {
|
||||
@@ -0,0 +1,9 @@
|
||||
package lifecycle
|
||||
|
||||
type OpenIntent string
|
||||
|
||||
const (
|
||||
OpenIntentNone OpenIntent = ""
|
||||
OpenIntentRemoteSyncSetup OpenIntent = "remote_sync_setup"
|
||||
OpenIntentRemoteSyncSettings OpenIntent = "remote_sync_settings"
|
||||
)
|
||||
@@ -0,0 +1,778 @@
|
||||
package appui
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
|
||||
"gioui.org/layout"
|
||||
"gioui.org/x/explorer"
|
||||
"git.julianfamily.org/keepassgo/internal/appstate"
|
||||
"git.julianfamily.org/keepassgo/internal/session"
|
||||
"git.julianfamily.org/keepassgo/internal/vault"
|
||||
"git.julianfamily.org/keepassgo/internal/webdav"
|
||||
)
|
||||
|
||||
func (u *ui) createVaultAction() error {
|
||||
key, err := u.currentMasterKey()
|
||||
defer u.clearMasterPassword()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := u.state.ConfigureSecurity(vault.SecuritySettings{
|
||||
Cipher: strings.TrimSpace(u.securityCipher.Text()),
|
||||
KDF: strings.TrimSpace(u.securityKDF.Text()),
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := u.state.CreateVault(key); err != nil {
|
||||
return err
|
||||
}
|
||||
if u.lifecycleMode == "local" {
|
||||
u.selectedVaultRemoteProfileID = ""
|
||||
u.selectedVaultRemoteCredentialEntryID = ""
|
||||
u.selectedVaultRemoteSyncMode = appstate.SyncModeManual
|
||||
u.remoteBaseURL.SetText("")
|
||||
u.remotePath.SetText("")
|
||||
u.remoteUsername.SetText("")
|
||||
u.remotePassword.SetText("")
|
||||
if err := u.state.SaveAs(u.saveAsTargetPath()); err != nil {
|
||||
return err
|
||||
}
|
||||
u.vaultPath.SetText(u.saveAsTargetPath())
|
||||
u.noteRecentVault(u.saveAsTargetPath())
|
||||
}
|
||||
u.resetPasswordPeek()
|
||||
u.currentPath = append([]string(nil), u.state.CurrentPath...)
|
||||
u.loadSecuritySettingsFromSession()
|
||||
u.editingEntry = false
|
||||
u.filter()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (u *ui) openVaultAction() error {
|
||||
key, err := u.currentMasterKey()
|
||||
defer u.clearMasterPassword()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
path := strings.TrimSpace(u.vaultPath.Text())
|
||||
if path == "" {
|
||||
return errors.New(errVaultPathRequired)
|
||||
}
|
||||
if err := u.state.OpenVault(path, key); err != nil {
|
||||
return err
|
||||
}
|
||||
u.noteRecentVault(path)
|
||||
u.resetPasswordPeek()
|
||||
u.currentPath = append([]string(nil), u.state.CurrentPath...)
|
||||
u.restoreRecentVaultGroup(path)
|
||||
u.syncSavedRemoteBindingSelection()
|
||||
if err := u.synchronizeSelectedRemoteBindingOnOpen(); err != nil {
|
||||
u.showStatusMessage("Remote sync on open failed: " + err.Error())
|
||||
}
|
||||
u.loadSecuritySettingsFromSession()
|
||||
u.editingEntry = false
|
||||
u.filter()
|
||||
u.applyPendingLifecycleOpenIntent()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (u *ui) startOpenVaultAction() {
|
||||
manager, ok := u.state.Session.(*session.Manager)
|
||||
if !ok {
|
||||
u.runAction("open vault", u.openVaultAction)
|
||||
return
|
||||
}
|
||||
key, err := u.currentMasterKey()
|
||||
u.clearMasterPassword()
|
||||
if err != nil {
|
||||
u.state.ErrorMessage = u.describeActionError("open vault", err)
|
||||
u.requestMasterPassFocus = true
|
||||
return
|
||||
}
|
||||
path := strings.TrimSpace(u.vaultPath.Text())
|
||||
if path == "" {
|
||||
u.state.ErrorMessage = u.describeActionError("open vault", errors.New(errVaultPathRequired))
|
||||
u.requestMasterPassFocus = true
|
||||
return
|
||||
}
|
||||
u.lastLifecycleAction = "open vault"
|
||||
u.runBackgroundAction("open vault", func() (func() error, error) {
|
||||
prepared, err := session.PrepareLocalOpen(path, key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return func() error {
|
||||
manager.ApplyPreparedLocalOpen(prepared)
|
||||
u.noteRecentVault(path)
|
||||
u.resetPasswordPeek()
|
||||
u.currentPath = append([]string(nil), u.state.CurrentPath...)
|
||||
u.restoreRecentVaultGroup(path)
|
||||
u.syncSavedRemoteBindingSelection()
|
||||
if err := u.synchronizeSelectedRemoteBindingOnOpen(); err != nil {
|
||||
u.showStatusMessage("Remote sync on open failed: " + err.Error())
|
||||
}
|
||||
u.loadSecuritySettingsFromSession()
|
||||
u.editingEntry = false
|
||||
u.filter()
|
||||
u.applyPendingLifecycleOpenIntent()
|
||||
return nil
|
||||
}, nil
|
||||
})
|
||||
}
|
||||
|
||||
func (u *ui) shouldShowLifecycleRemoteSyncAction() bool {
|
||||
return strings.TrimSpace(u.vaultPath.Text()) != ""
|
||||
}
|
||||
|
||||
func (u *ui) lifecycleRemoteSyncActionLabel() string {
|
||||
path := strings.TrimSpace(u.vaultPath.Text())
|
||||
if path == "" {
|
||||
return "Open Vault And Set Up Remote Sync"
|
||||
}
|
||||
if hasBoundRecentRemote(u.recentRemotes, path) {
|
||||
return "Open Vault And Open Remote Sync Settings"
|
||||
}
|
||||
return "Open Vault And Set Up Remote Sync"
|
||||
}
|
||||
|
||||
func (u *ui) beginLifecycleRemoteSyncOpen() {
|
||||
path := strings.TrimSpace(u.vaultPath.Text())
|
||||
switch {
|
||||
case path == "":
|
||||
u.pendingLifecycleOpenIntent = lifecycleOpenIntentNone
|
||||
case hasBoundRecentRemote(u.recentRemotes, path):
|
||||
u.pendingLifecycleOpenIntent = lifecycleOpenIntentRemoteSyncSettings
|
||||
default:
|
||||
u.pendingLifecycleOpenIntent = lifecycleOpenIntentRemoteSyncSetup
|
||||
}
|
||||
u.startOpenVaultAction()
|
||||
}
|
||||
|
||||
func (u *ui) applyPendingLifecycleOpenIntent() {
|
||||
intent := u.pendingLifecycleOpenIntent
|
||||
u.pendingLifecycleOpenIntent = lifecycleOpenIntentNone
|
||||
switch intent {
|
||||
case lifecycleOpenIntentRemoteSyncSetup, lifecycleOpenIntentRemoteSyncSettings:
|
||||
u.openRemoteSyncSetupDialog()
|
||||
}
|
||||
}
|
||||
|
||||
func (u *ui) saveAction() error {
|
||||
if err := u.state.Save(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := u.synchronizeSelectedRemoteBindingOnSave(); err != nil {
|
||||
return err
|
||||
}
|
||||
u.filter()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (u *ui) saveAsAction() error {
|
||||
path := u.saveAsTargetPath()
|
||||
if err := u.state.SaveAs(path); err != nil {
|
||||
return err
|
||||
}
|
||||
u.vaultPath.SetText(path)
|
||||
u.noteRecentVault(path)
|
||||
u.filter()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (u *ui) openRemoteAction() error {
|
||||
key, err := u.currentMasterKey()
|
||||
defer u.clearMasterPassword()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if binding, resolved, ok, err := u.bootstrapSelectedVaultRemoteBinding(key); err != nil {
|
||||
return err
|
||||
} else if ok {
|
||||
if err := u.state.OpenBoundRemoteVault(binding, key); err != nil {
|
||||
return err
|
||||
}
|
||||
u.remoteBaseURL.SetText(resolved.Profile.BaseURL)
|
||||
u.remotePath.SetText(resolved.Profile.Path)
|
||||
u.noteRecentRemote(resolved.Profile.BaseURL, resolved.Profile.Path)
|
||||
u.resetPasswordPeek()
|
||||
u.restoreRecentRemoteGroup(resolved.Profile.BaseURL, resolved.Profile.Path)
|
||||
u.loadSecuritySettingsFromSession()
|
||||
u.editingEntry = false
|
||||
u.filter()
|
||||
return nil
|
||||
}
|
||||
client := webdav.Client{
|
||||
BaseURL: strings.TrimSpace(u.remoteBaseURL.Text()),
|
||||
Username: strings.TrimSpace(u.remoteUsername.Text()),
|
||||
Password: u.remotePassword.Text(),
|
||||
}
|
||||
if err := u.state.OpenRemoteVault(client, strings.TrimSpace(u.remotePath.Text()), key); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := u.materializeCurrentRemoteCache(); err != nil {
|
||||
return err
|
||||
}
|
||||
u.noteRecentRemote(
|
||||
strings.TrimSpace(u.remoteBaseURL.Text()),
|
||||
strings.TrimSpace(u.remotePath.Text()),
|
||||
)
|
||||
u.resetPasswordPeek()
|
||||
u.restoreRecentRemoteGroup(strings.TrimSpace(u.remoteBaseURL.Text()), strings.TrimSpace(u.remotePath.Text()))
|
||||
u.loadSecuritySettingsFromSession()
|
||||
u.editingEntry = false
|
||||
u.filter()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (u *ui) startOpenRemoteAction() {
|
||||
manager, ok := u.state.Session.(*session.Manager)
|
||||
if !ok {
|
||||
u.runAction("open remote vault", u.openRemoteAction)
|
||||
return
|
||||
}
|
||||
key, err := u.currentMasterKey()
|
||||
u.clearMasterPassword()
|
||||
if err != nil {
|
||||
u.state.ErrorMessage = u.describeActionError("open remote vault", err)
|
||||
u.requestMasterPassFocus = true
|
||||
return
|
||||
}
|
||||
client := webdav.Client{
|
||||
BaseURL: strings.TrimSpace(u.remoteBaseURL.Text()),
|
||||
Username: strings.TrimSpace(u.remoteUsername.Text()),
|
||||
Password: u.remotePassword.Text(),
|
||||
}
|
||||
remotePath := strings.TrimSpace(u.remotePath.Text())
|
||||
u.lastLifecycleAction = "open remote vault"
|
||||
u.runBackgroundAction("open remote vault", func() (func() error, error) {
|
||||
binding, bindingOK := u.selectedVaultRemoteBinding()
|
||||
if bindingOK && !u.hasOpenVault() && strings.TrimSpace(binding.LocalVaultPath) != "" {
|
||||
preparedLocal, err := session.PrepareLocalOpen(binding.LocalVaultPath, key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resolved, err := binding.Resolve(preparedLocal.Model)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
preparedRemote, err := session.PrepareRemoteOpen(webdav.Client{
|
||||
BaseURL: resolved.Profile.BaseURL,
|
||||
Username: resolved.Credentials.Username,
|
||||
Password: resolved.Credentials.Password,
|
||||
}, resolved.Profile.Path, key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return func() error {
|
||||
manager.ApplyPreparedLocalOpen(preparedLocal)
|
||||
u.vaultPath.SetText(binding.LocalVaultPath)
|
||||
u.noteRecentVault(binding.LocalVaultPath)
|
||||
u.restoreRecentVaultGroup(binding.LocalVaultPath)
|
||||
manager.ApplyPreparedRemoteOpen(preparedRemote)
|
||||
u.remoteBaseURL.SetText(resolved.Profile.BaseURL)
|
||||
u.remotePath.SetText(resolved.Profile.Path)
|
||||
u.noteRecentRemote(resolved.Profile.BaseURL, resolved.Profile.Path)
|
||||
u.resetPasswordPeek()
|
||||
u.restoreRecentRemoteGroup(resolved.Profile.BaseURL, resolved.Profile.Path)
|
||||
u.loadSecuritySettingsFromSession()
|
||||
u.editingEntry = false
|
||||
u.filter()
|
||||
return nil
|
||||
}, nil
|
||||
}
|
||||
if u.hasOpenVault() {
|
||||
if _, resolved, ok, err := u.resolvedSelectedVaultRemoteBinding(); err != nil {
|
||||
return nil, err
|
||||
} else if ok {
|
||||
client = webdav.Client{
|
||||
BaseURL: resolved.Profile.BaseURL,
|
||||
Username: resolved.Credentials.Username,
|
||||
Password: resolved.Credentials.Password,
|
||||
}
|
||||
remotePath = resolved.Profile.Path
|
||||
u.remoteBaseURL.SetText(resolved.Profile.BaseURL)
|
||||
u.remotePath.SetText(resolved.Profile.Path)
|
||||
}
|
||||
}
|
||||
prepared, err := session.PrepareRemoteOpen(client, remotePath, key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return func() error {
|
||||
manager.ApplyPreparedRemoteOpen(prepared)
|
||||
if err := u.materializeCurrentRemoteCache(); err != nil {
|
||||
return err
|
||||
}
|
||||
u.noteRecentRemote(
|
||||
strings.TrimSpace(u.remoteBaseURL.Text()),
|
||||
remotePath,
|
||||
)
|
||||
u.resetPasswordPeek()
|
||||
u.restoreRecentRemoteGroup(strings.TrimSpace(u.remoteBaseURL.Text()), remotePath)
|
||||
u.loadSecuritySettingsFromSession()
|
||||
u.editingEntry = false
|
||||
u.filter()
|
||||
return nil
|
||||
}, nil
|
||||
})
|
||||
}
|
||||
|
||||
func (u *ui) lockAction() error {
|
||||
u.clearMasterPassword()
|
||||
if err := u.state.Lock(); err != nil {
|
||||
return err
|
||||
}
|
||||
u.requestMasterPassFocus = true
|
||||
u.currentPath = append([]string(nil), u.state.CurrentPath...)
|
||||
u.resetPasswordPeek()
|
||||
u.editingEntry = false
|
||||
u.filter()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (u *ui) unlockAction() error {
|
||||
key, err := u.currentMasterKey()
|
||||
defer u.clearMasterPassword()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := u.state.Unlock(key); err != nil {
|
||||
return err
|
||||
}
|
||||
u.resetPasswordPeek()
|
||||
u.currentPath = append([]string(nil), u.state.CurrentPath...)
|
||||
u.loadSecuritySettingsFromSession()
|
||||
u.editingEntry = false
|
||||
u.filter()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (u *ui) startUnlockAction() {
|
||||
manager, ok := u.state.Session.(*session.Manager)
|
||||
if !ok {
|
||||
u.runAction("unlock vault", u.unlockAction)
|
||||
return
|
||||
}
|
||||
key, err := u.currentMasterKey()
|
||||
u.clearMasterPassword()
|
||||
if err != nil {
|
||||
u.state.ErrorMessage = u.describeActionError("unlock vault", err)
|
||||
u.requestMasterPassFocus = true
|
||||
return
|
||||
}
|
||||
encoded := append([]byte(nil), manager.EncodedBytes()...)
|
||||
u.runBackgroundAction("unlock vault", func() (func() error, error) {
|
||||
prepared, err := session.PrepareUnlock(encoded, key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return func() error {
|
||||
manager.ApplyPreparedUnlock(prepared)
|
||||
u.resetPasswordPeek()
|
||||
u.currentPath = append([]string(nil), u.state.CurrentPath...)
|
||||
u.loadSecuritySettingsFromSession()
|
||||
u.editingEntry = false
|
||||
u.filter()
|
||||
return nil
|
||||
}, nil
|
||||
})
|
||||
}
|
||||
|
||||
func (u *ui) changeMasterKeyAction() error {
|
||||
key, err := u.currentMasterKey()
|
||||
defer u.clearMasterPassword()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return u.state.ChangeMasterKey(key)
|
||||
}
|
||||
|
||||
func (u *ui) loadSecuritySettingsFromSession() {
|
||||
settings, err := u.state.SecuritySettings()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
u.securityCipher.SetText(settings.Cipher)
|
||||
u.securityKDF.SetText(settings.KDF)
|
||||
}
|
||||
|
||||
func (u *ui) clearMasterPassword() {
|
||||
u.masterPassword.SetText("")
|
||||
}
|
||||
|
||||
func (u *ui) synchronizeAction() error {
|
||||
if err := u.state.Synchronize(); err != nil {
|
||||
return err
|
||||
}
|
||||
u.syncMenuOpen = false
|
||||
u.filter()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (u *ui) openAdvancedSyncDialog() {
|
||||
u.syncDialogOpen = true
|
||||
u.syncMenuOpen = false
|
||||
u.showSyncPassword = false
|
||||
u.syncDialogList.Position = layout.Position{}
|
||||
u.syncDialogPurpose = syncDialogPurposeAdvanced
|
||||
u.syncSourceMode = u.syncDefaultSourceMode
|
||||
u.syncDirection = u.syncDefaultDirection
|
||||
if strings.TrimSpace(u.syncLocalPath.Text()) == "" {
|
||||
u.syncLocalPath.SetText(strings.TrimSpace(u.vaultPath.Text()))
|
||||
}
|
||||
u.syncSavedRemoteBindingSelection()
|
||||
u.prefillAdvancedSyncRemoteFromSavedBinding()
|
||||
}
|
||||
|
||||
func (u *ui) openRemoteSyncSetupDialog() {
|
||||
u.syncDialogOpen = true
|
||||
u.syncMenuOpen = false
|
||||
u.showSyncPassword = false
|
||||
u.syncDialogList.Position = layout.Position{}
|
||||
u.syncDialogPurpose = syncDialogPurposeRemoteSetup
|
||||
u.syncSourceMode = syncSourceRemote
|
||||
u.syncDirection = syncDirectionPush
|
||||
u.syncSetupAutomatic.Value = true
|
||||
if strings.TrimSpace(u.syncLocalPath.Text()) == "" {
|
||||
u.syncLocalPath.SetText(strings.TrimSpace(u.vaultPath.Text()))
|
||||
}
|
||||
u.syncSavedRemoteBindingSelection()
|
||||
u.prefillAdvancedSyncRemoteFromSavedBinding()
|
||||
if _, ok := u.selectedVaultRemoteBinding(); ok && u.selectedVaultRemoteSyncMode == appstate.SyncModeManual {
|
||||
u.syncSetupAutomatic.Value = false
|
||||
}
|
||||
}
|
||||
|
||||
func (u *ui) clearSyncLocalImport() {
|
||||
u.syncLocalImportName = ""
|
||||
u.syncLocalImportContent = nil
|
||||
}
|
||||
|
||||
func (u *ui) selectedSyncLocalImport() (string, []byte, bool) {
|
||||
name := strings.TrimSpace(u.syncLocalImportName)
|
||||
if name == "" || name != strings.TrimSpace(u.syncLocalPath.Text()) || len(u.syncLocalImportContent) == 0 {
|
||||
return "", nil, false
|
||||
}
|
||||
return name, append([]byte(nil), u.syncLocalImportContent...), true
|
||||
}
|
||||
|
||||
func sanitizeSyncSourceMode(mode syncSourceMode) syncSourceMode {
|
||||
switch mode {
|
||||
case syncSourceRemote:
|
||||
return syncSourceRemote
|
||||
default:
|
||||
return syncSourceLocal
|
||||
}
|
||||
}
|
||||
|
||||
func sanitizeSyncDirection(direction syncDirection) syncDirection {
|
||||
switch direction {
|
||||
case syncDirectionPush:
|
||||
return syncDirectionPush
|
||||
default:
|
||||
return syncDirectionPull
|
||||
}
|
||||
}
|
||||
|
||||
func (u *ui) advancedSyncAction() error {
|
||||
switch u.syncDirection {
|
||||
case syncDirectionPush:
|
||||
return u.advancedSyncToAction()
|
||||
default:
|
||||
return u.advancedSyncFromAction()
|
||||
}
|
||||
}
|
||||
|
||||
func (u *ui) advancedSyncFromAction() error {
|
||||
switch u.syncSourceMode {
|
||||
case syncSourceRemote:
|
||||
client := webdav.Client{
|
||||
BaseURL: strings.TrimSpace(u.syncRemoteBaseURL.Text()),
|
||||
Username: strings.TrimSpace(u.syncRemoteUsername.Text()),
|
||||
Password: u.syncRemotePassword.Text(),
|
||||
}
|
||||
if err := u.state.SynchronizeFromRemote(client, strings.TrimSpace(u.syncRemotePath.Text())); err != nil {
|
||||
return err
|
||||
}
|
||||
default:
|
||||
if name, content, ok := u.selectedSyncLocalImport(); ok {
|
||||
if err := u.state.SynchronizeFromLocalBytes(name, content); err != nil {
|
||||
return err
|
||||
}
|
||||
break
|
||||
}
|
||||
path := strings.TrimSpace(u.syncLocalPath.Text())
|
||||
if path == "" {
|
||||
return errors.New(errVaultPathRequired)
|
||||
}
|
||||
if err := u.state.SynchronizeFromLocal(path); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
u.syncDialogOpen = false
|
||||
u.showSyncPassword = false
|
||||
u.filter()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (u *ui) startChooseSyncLocalSourceAction() {
|
||||
if runtime.GOOS != "android" || u.fileExplorer == nil {
|
||||
u.runAction("choose sync path", func() error {
|
||||
u.clearSyncLocalImport()
|
||||
return u.chooseExistingFileAction(&u.syncLocalPath)
|
||||
})
|
||||
return
|
||||
}
|
||||
u.runBackgroundAction("choose sync file", func() (func() error, error) {
|
||||
file, err := u.fileExplorer.ChooseFile(".kdbx")
|
||||
if err != nil {
|
||||
if errors.Is(err, explorer.ErrUserDecline) {
|
||||
return func() error { return nil }, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
defer file.Close()
|
||||
content, err := io.ReadAll(file)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
label := "Selected Android vault"
|
||||
return func() error {
|
||||
u.syncLocalImportName = label
|
||||
u.syncLocalImportContent = append([]byte(nil), content...)
|
||||
u.syncLocalPath.SetText(label)
|
||||
return nil
|
||||
}, nil
|
||||
})
|
||||
}
|
||||
|
||||
func pickedDocumentName(file io.ReadCloser, fallback string) string {
|
||||
if named, ok := file.(interface{ Name() string }); ok {
|
||||
if base := filepath.Base(strings.TrimSpace(named.Name())); base != "" && base != "." && base != string(filepath.Separator) {
|
||||
return base
|
||||
}
|
||||
}
|
||||
fallback = filepath.Base(strings.TrimSpace(fallback))
|
||||
if fallback == "" || fallback == "." || fallback == string(filepath.Separator) {
|
||||
return "selected-vault.kdbx"
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
func (u *ui) startChooseVaultPathAction() {
|
||||
if runtime.GOOS != "android" || u.fileExplorer == nil {
|
||||
u.runAction("choose vault path", func() error { return u.chooseExistingFileAction(&u.vaultPath) })
|
||||
return
|
||||
}
|
||||
u.runBackgroundAction("choose vault file", func() (func() error, error) {
|
||||
file, err := u.fileExplorer.ChooseFile(".kdbx")
|
||||
if err != nil {
|
||||
if errors.Is(err, explorer.ErrUserDecline) {
|
||||
return func() error { return nil }, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
defer file.Close()
|
||||
content, err := io.ReadAll(file)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
name := pickedDocumentName(file, "selected-vault.kdbx")
|
||||
return func() error {
|
||||
return u.importSharedVaultBytesAction(name, content)
|
||||
}, nil
|
||||
})
|
||||
}
|
||||
|
||||
func (u *ui) startImportSharedVaultAction() {
|
||||
if !supportsSharedVaultImport(runtime.GOOS) || u.fileExplorer == nil {
|
||||
return
|
||||
}
|
||||
u.runBackgroundAction("import shared vault", func() (func() error, error) {
|
||||
file, err := u.fileExplorer.ChooseFile(".kdbx")
|
||||
if err != nil {
|
||||
if errors.Is(err, explorer.ErrUserDecline) {
|
||||
return func() error { return nil }, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
defer file.Close()
|
||||
content, err := io.ReadAll(file)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return func() error {
|
||||
return u.importSharedVaultBytesAction("shared-vault.kdbx", content)
|
||||
}, nil
|
||||
})
|
||||
}
|
||||
|
||||
func (u *ui) advancedSyncToAction() error {
|
||||
switch u.syncSourceMode {
|
||||
case syncSourceRemote:
|
||||
baseURL := strings.TrimSpace(u.syncRemoteBaseURL.Text())
|
||||
remotePath := strings.TrimSpace(u.syncRemotePath.Text())
|
||||
client := webdav.Client{
|
||||
BaseURL: baseURL,
|
||||
Username: strings.TrimSpace(u.syncRemoteUsername.Text()),
|
||||
Password: u.syncRemotePassword.Text(),
|
||||
}
|
||||
if err := u.state.SynchronizeToRemote(client, remotePath); err != nil {
|
||||
return err
|
||||
}
|
||||
if u.syncDialogPurpose == syncDialogPurposeRemoteSetup {
|
||||
if err := u.persistSyncDialogRemoteBinding(baseURL, remotePath); err != nil {
|
||||
return err
|
||||
}
|
||||
u.showStatusMessage("Remote sync is set up for this vault.")
|
||||
}
|
||||
default:
|
||||
path := strings.TrimSpace(u.syncLocalPath.Text())
|
||||
if path == "" {
|
||||
return errors.New(errVaultPathRequired)
|
||||
}
|
||||
if err := u.state.SynchronizeToLocal(path); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
u.syncDialogOpen = false
|
||||
u.showSyncPassword = false
|
||||
u.filter()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (u *ui) persistSyncDialogRemoteBinding(baseURL, remotePath string) error {
|
||||
baseURL = strings.TrimSpace(baseURL)
|
||||
remotePath = strings.TrimSpace(remotePath)
|
||||
if baseURL == "" || remotePath == "" {
|
||||
return fmt.Errorf("remote setup requires base URL and path")
|
||||
}
|
||||
input := appstate.RemoteBindingInput{
|
||||
LocalVaultPath: strings.TrimSpace(u.vaultPath.Text()),
|
||||
RemoteProfileID: "remote-profile-" + remoteBindingSuffix(baseURL, remotePath, strings.TrimSpace(u.syncRemoteUsername.Text())),
|
||||
RemoteProfileName: friendlyRecentRemoteLabel(recentRemoteRecord{BaseURL: baseURL, Path: remotePath}),
|
||||
BaseURL: baseURL,
|
||||
RemotePath: remotePath,
|
||||
CredentialEntryID: "remote-credential-" + remoteBindingSuffix(baseURL, remotePath, strings.TrimSpace(u.syncRemoteUsername.Text())),
|
||||
CredentialTitle: "WebDAV Sign-In" + func() string {
|
||||
if user := strings.TrimSpace(u.syncRemoteUsername.Text()); user != "" {
|
||||
return " · " + user
|
||||
}
|
||||
return ""
|
||||
}(),
|
||||
Username: strings.TrimSpace(u.syncRemoteUsername.Text()),
|
||||
Password: u.syncRemotePassword.Text(),
|
||||
CredentialPath: append([]string(nil), u.currentPath...),
|
||||
SyncMode: u.syncSetupMode(),
|
||||
}
|
||||
binding, err := u.state.ConfigureRemoteBinding(input)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := u.state.Save(); err != nil {
|
||||
return err
|
||||
}
|
||||
u.selectedVaultRemoteProfileID = binding.RemoteProfileID
|
||||
u.selectedVaultRemoteCredentialEntryID = binding.CredentialEntryID
|
||||
u.selectedVaultRemoteSyncMode = binding.SyncMode
|
||||
u.remoteBaseURL.SetText(baseURL)
|
||||
u.remotePath.SetText(remotePath)
|
||||
u.remoteUsername.SetText(strings.TrimSpace(u.syncRemoteUsername.Text()))
|
||||
u.remotePassword.SetText(u.syncRemotePassword.Text())
|
||||
u.noteRecentRemote(baseURL, remotePath)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (u *ui) saveAsTargetPath() string {
|
||||
path := strings.TrimSpace(u.saveAsPath.Text())
|
||||
if path != "" {
|
||||
return path
|
||||
}
|
||||
return u.defaultSaveAsPath
|
||||
}
|
||||
|
||||
func (u *ui) importedVaultDestination(name string) string {
|
||||
baseTarget := u.saveAsTargetPath()
|
||||
baseDir := filepath.Dir(baseTarget)
|
||||
baseName := filepath.Base(strings.TrimSpace(name))
|
||||
switch {
|
||||
case baseName == "" || baseName == "." || baseName == string(filepath.Separator):
|
||||
return baseTarget
|
||||
case strings.HasSuffix(strings.ToLower(baseName), ".kdbx"):
|
||||
return filepath.Join(baseDir, baseName)
|
||||
default:
|
||||
return baseTarget
|
||||
}
|
||||
}
|
||||
|
||||
func (u *ui) consumePendingSharedVaultImport() {
|
||||
path := strings.TrimSpace(u.pendingSharedVaultPath)
|
||||
if path == "" {
|
||||
return
|
||||
}
|
||||
content, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
if !errors.Is(err, os.ErrNotExist) {
|
||||
u.state.ErrorMessage = fmt.Sprintf("import shared vault: %v", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
name := "shared-vault.kdbx"
|
||||
if namePath := strings.TrimSpace(u.pendingSharedVaultNamePath); namePath != "" {
|
||||
if rawName, err := os.ReadFile(namePath); err == nil {
|
||||
if trimmed := strings.TrimSpace(string(rawName)); trimmed != "" {
|
||||
name = trimmed
|
||||
}
|
||||
}
|
||||
}
|
||||
if err := u.importSharedVaultBytesAction(name, content); err != nil {
|
||||
u.state.ErrorMessage = fmt.Sprintf("import shared vault: %v", err)
|
||||
return
|
||||
}
|
||||
_ = os.Remove(path)
|
||||
if namePath := strings.TrimSpace(u.pendingSharedVaultNamePath); namePath != "" {
|
||||
_ = os.Remove(namePath)
|
||||
}
|
||||
}
|
||||
|
||||
func (u *ui) importSharedVaultBytesAction(name string, content []byte) error {
|
||||
target := u.importedVaultDestination(name)
|
||||
if err := os.MkdirAll(filepath.Dir(target), 0o700); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.WriteFile(target, append([]byte(nil), content...), 0o600); err != nil {
|
||||
return err
|
||||
}
|
||||
u.lifecycleMode = "local"
|
||||
u.vaultPath.SetText(target)
|
||||
u.noteRecentVault(target)
|
||||
u.state.ErrorMessage = ""
|
||||
u.state.StatusMessage = ""
|
||||
u.requestMasterPassFocus = true
|
||||
u.filter()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (u *ui) currentShareableVaultPath() string {
|
||||
return strings.TrimSpace(u.vaultPath.Text())
|
||||
}
|
||||
|
||||
func (u *ui) shareCurrentVaultAction() error {
|
||||
if u.vaultSharer == nil {
|
||||
return fmt.Errorf("vault sharing is not available on this platform")
|
||||
}
|
||||
path := u.currentShareableVaultPath()
|
||||
if path == "" {
|
||||
return errors.New(errVaultPathRequired)
|
||||
}
|
||||
if err := u.state.Save(); err != nil {
|
||||
return err
|
||||
}
|
||||
return u.vaultSharer.ShareVault(path, friendlyRecentVaultLabel(path))
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package layout
|
||||
|
||||
type TopSection string
|
||||
|
||||
const (
|
||||
TopSearch TopSection = "search"
|
||||
TopNavigation TopSection = "navigation"
|
||||
TopPath TopSection = "path"
|
||||
TopGroup TopSection = "group"
|
||||
TopGroupTools TopSection = "group_tools"
|
||||
TopPrimary TopSection = "primary"
|
||||
)
|
||||
@@ -0,0 +1,8 @@
|
||||
package list
|
||||
|
||||
type EntriesSectionState struct {
|
||||
Path []string
|
||||
SearchQuery string
|
||||
SelectedEntryID string
|
||||
Editing bool
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
//go:build android
|
||||
|
||||
package platform
|
||||
|
||||
/*
|
||||
#cgo CFLAGS: -Werror
|
||||
#cgo LDFLAGS: -landroid
|
||||
|
||||
#include <android/log.h>
|
||||
#include <stdlib.h>
|
||||
*/
|
||||
import "C"
|
||||
|
||||
import "unsafe"
|
||||
|
||||
func LogInfo(tag, msg string) {
|
||||
ctag := C.CString(tag)
|
||||
defer C.free(unsafe.Pointer(ctag))
|
||||
cmsg := C.CString(msg)
|
||||
defer C.free(unsafe.Pointer(cmsg))
|
||||
C.__android_log_write(C.ANDROID_LOG_INFO, ctag, cmsg)
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
//go:build android
|
||||
|
||||
package platform
|
||||
|
||||
/*
|
||||
#cgo CFLAGS: -Werror
|
||||
#cgo LDFLAGS: -landroid
|
||||
|
||||
#include <jni.h>
|
||||
#include <stdlib.h>
|
||||
|
||||
static jclass jni_GetObjectClass(JNIEnv *env, jobject obj) {
|
||||
return (*env)->GetObjectClass(env, obj);
|
||||
}
|
||||
|
||||
static jmethodID jni_GetMethodID(JNIEnv *env, jclass clazz, const char *name, const char *sig) {
|
||||
return (*env)->GetMethodID(env, clazz, name, sig);
|
||||
}
|
||||
|
||||
static jmethodID jni_GetStaticMethodID(JNIEnv *env, jclass clazz, const char *name, const char *sig) {
|
||||
return (*env)->GetStaticMethodID(env, clazz, name, sig);
|
||||
}
|
||||
|
||||
static jobject jni_CallObjectMethodA(JNIEnv *env, jobject obj, jmethodID method, jvalue *args) {
|
||||
return (*env)->CallObjectMethodA(env, obj, method, args);
|
||||
}
|
||||
|
||||
static void jni_CallStaticVoidMethodA(JNIEnv *env, jclass cls, jmethodID methodID, const jvalue *args) {
|
||||
(*env)->CallStaticVoidMethodA(env, cls, methodID, args);
|
||||
}
|
||||
|
||||
static jvalue jni_ValueObject(jobject obj) {
|
||||
jvalue value;
|
||||
value.l = obj;
|
||||
return value;
|
||||
}
|
||||
|
||||
static jthrowable jni_ExceptionOccurred(JNIEnv *env) {
|
||||
return (*env)->ExceptionOccurred(env);
|
||||
}
|
||||
|
||||
static void jni_ExceptionClear(JNIEnv *env) {
|
||||
(*env)->ExceptionClear(env);
|
||||
}
|
||||
|
||||
static jstring jni_NewString(JNIEnv *env, const jchar *unicodeChars, jsize len) {
|
||||
return (*env)->NewString(env, unicodeChars, len);
|
||||
}
|
||||
|
||||
static int jni_IsNull(jobject obj) {
|
||||
return obj == NULL;
|
||||
}
|
||||
*/
|
||||
import "C"
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"unicode/utf16"
|
||||
"unsafe"
|
||||
|
||||
"gioui.org/app"
|
||||
_ "unsafe"
|
||||
)
|
||||
|
||||
type androidVaultSharer struct{}
|
||||
|
||||
//go:linkname gioJavaVM gioui.org/app.javaVM
|
||||
func gioJavaVM() *C.JavaVM
|
||||
|
||||
//go:linkname gioRunInJVM gioui.org/app.runInJVM
|
||||
func gioRunInJVM(jvm *C.JavaVM, f func(env *C.JNIEnv))
|
||||
|
||||
func NewVaultSharer(goos string) VaultSharer {
|
||||
return androidVaultSharer{}
|
||||
}
|
||||
|
||||
func (androidVaultSharer) ShareVault(path, title string) error {
|
||||
if strings.TrimSpace(path) == "" {
|
||||
return fmt.Errorf("vault path is required")
|
||||
}
|
||||
ctx := C.jobject(unsafe.Pointer(app.AppContext()))
|
||||
if C.jni_IsNull(ctx) != 0 {
|
||||
return fmt.Errorf("android app context is not available")
|
||||
}
|
||||
var callErr error
|
||||
gioRunInJVM(gioJavaVM(), func(env *C.JNIEnv) {
|
||||
sharerClass, err := androidLoadClass(env, ctx, "org.julianfamily.keepassgo.AndroidShare")
|
||||
if err != nil {
|
||||
callErr = err
|
||||
return
|
||||
}
|
||||
methodName := cString("shareVault")
|
||||
defer C.free(unsafe.Pointer(methodName))
|
||||
methodSig := cString("(Landroid/content/Context;Ljava/lang/String;Ljava/lang/String;)V")
|
||||
defer C.free(unsafe.Pointer(methodSig))
|
||||
method := C.jni_GetStaticMethodID(env, sharerClass, methodName, methodSig)
|
||||
if method == nil {
|
||||
callErr = androidJNIError(env, "resolve shareVault method")
|
||||
if callErr == nil {
|
||||
callErr = fmt.Errorf("resolve shareVault method")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
jPath := androidJavaString(env, path)
|
||||
jTitle := androidJavaString(env, title)
|
||||
args := [3]C.jvalue{}
|
||||
args[0] = C.jni_ValueObject(ctx)
|
||||
args[1] = C.jni_ValueObject(C.jobject(jPath))
|
||||
args[2] = C.jni_ValueObject(C.jobject(jTitle))
|
||||
C.jni_CallStaticVoidMethodA(env, sharerClass, method, &args[0])
|
||||
callErr = androidJNIError(env, "share vault")
|
||||
})
|
||||
return callErr
|
||||
}
|
||||
|
||||
func androidLoadClass(env *C.JNIEnv, ctx C.jobject, name string) (C.jclass, error) {
|
||||
var zeroClass C.jclass
|
||||
contextClass := C.jni_GetObjectClass(env, ctx)
|
||||
getClassLoaderName := cString("getClassLoader")
|
||||
defer C.free(unsafe.Pointer(getClassLoaderName))
|
||||
getClassLoaderSig := cString("()Ljava/lang/ClassLoader;")
|
||||
defer C.free(unsafe.Pointer(getClassLoaderSig))
|
||||
getClassLoader := C.jni_GetMethodID(env, contextClass, getClassLoaderName, getClassLoaderSig)
|
||||
if getClassLoader == nil {
|
||||
if err := androidJNIError(env, "resolve getClassLoader"); err != nil {
|
||||
return zeroClass, err
|
||||
}
|
||||
return zeroClass, fmt.Errorf("resolve getClassLoader")
|
||||
}
|
||||
classLoader := C.jni_CallObjectMethodA(env, ctx, getClassLoader, nil)
|
||||
if err := androidJNIError(env, "load class loader"); err != nil {
|
||||
return zeroClass, err
|
||||
}
|
||||
if C.jni_IsNull(classLoader) != 0 {
|
||||
return zeroClass, fmt.Errorf("android class loader is nil")
|
||||
}
|
||||
|
||||
classLoaderClass := C.jni_GetObjectClass(env, classLoader)
|
||||
loadClassName := cString("loadClass")
|
||||
defer C.free(unsafe.Pointer(loadClassName))
|
||||
loadClassSig := cString("(Ljava/lang/String;)Ljava/lang/Class;")
|
||||
defer C.free(unsafe.Pointer(loadClassSig))
|
||||
loadClass := C.jni_GetMethodID(env, classLoaderClass, loadClassName, loadClassSig)
|
||||
if loadClass == nil {
|
||||
if err := androidJNIError(env, "resolve loadClass"); err != nil {
|
||||
return zeroClass, err
|
||||
}
|
||||
return zeroClass, fmt.Errorf("resolve loadClass")
|
||||
}
|
||||
|
||||
jClassName := androidJavaString(env, name)
|
||||
args := [1]C.jvalue{}
|
||||
args[0] = C.jni_ValueObject(C.jobject(jClassName))
|
||||
loaded := C.jni_CallObjectMethodA(env, classLoader, loadClass, &args[0])
|
||||
if err := androidJNIError(env, "load AndroidShare class"); err != nil {
|
||||
return zeroClass, err
|
||||
}
|
||||
if C.jni_IsNull(loaded) != 0 {
|
||||
return zeroClass, fmt.Errorf("load AndroidShare class returned nil")
|
||||
}
|
||||
return C.jclass(loaded), nil
|
||||
}
|
||||
|
||||
func androidJNIError(env *C.JNIEnv, action string) error {
|
||||
if thr := C.jni_ExceptionOccurred(env); C.jni_IsNull(C.jobject(thr)) == 0 {
|
||||
C.jni_ExceptionClear(env)
|
||||
return fmt.Errorf("%s: Java exception", action)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func androidJavaString(env *C.JNIEnv, s string) C.jstring {
|
||||
chars := utf16.Encode([]rune(s))
|
||||
if len(chars) == 0 {
|
||||
return C.jni_NewString(env, nil, 0)
|
||||
}
|
||||
return C.jni_NewString(env, (*C.jchar)(unsafe.Pointer(unsafe.SliceData(chars))), C.jsize(len(chars)))
|
||||
}
|
||||
|
||||
func cString(value string) *C.char {
|
||||
return C.CString(value)
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
//go:build !android
|
||||
|
||||
package platform
|
||||
|
||||
import "log"
|
||||
|
||||
func NewVaultSharer(goos string) VaultSharer {
|
||||
return nil
|
||||
}
|
||||
|
||||
func LogInfo(tag, msg string) {
|
||||
log.Printf("%s: %s", tag, msg)
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package platform
|
||||
|
||||
import (
|
||||
"io"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
gioclipboard "gioui.org/io/clipboard"
|
||||
"gioui.org/layout"
|
||||
|
||||
appclipboard "git.julianfamily.org/keepassgo/internal/clipboard"
|
||||
)
|
||||
|
||||
type VaultSharer interface {
|
||||
ShareVault(path, title string) error
|
||||
}
|
||||
|
||||
type clipboardCommandWriter struct {
|
||||
mu sync.Mutex
|
||||
pending []string
|
||||
invalidate func()
|
||||
}
|
||||
|
||||
func NewClipboardWriter(goos string, invalidate func()) appclipboard.Writer {
|
||||
if strings.EqualFold(goos, "android") {
|
||||
return &clipboardCommandWriter{invalidate: invalidate}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func ProcessClipboardWrites(gtx layout.Context, writer appclipboard.Writer) {
|
||||
commandWriter, ok := writer.(*clipboardCommandWriter)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
commandWriter.Process(gtx)
|
||||
}
|
||||
|
||||
func (w *clipboardCommandWriter) WriteText(text string) error {
|
||||
w.mu.Lock()
|
||||
w.pending = append(w.pending, text)
|
||||
w.mu.Unlock()
|
||||
if w.invalidate != nil {
|
||||
w.invalidate()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (w *clipboardCommandWriter) Process(gtx layout.Context) {
|
||||
for _, text := range w.drain() {
|
||||
gtx.Execute(gioclipboard.WriteCmd{
|
||||
Type: "application/text",
|
||||
Data: io.NopCloser(strings.NewReader(text)),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func (w *clipboardCommandWriter) drain() []string {
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
if len(w.pending) == 0 {
|
||||
return nil
|
||||
}
|
||||
pending := append([]string(nil), w.pending...)
|
||||
w.pending = nil
|
||||
return pending
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package platform
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestNewPlatformClipboardWriterUsesCommandWriterOnAndroid(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
writer := NewClipboardWriter("android", nil)
|
||||
if _, ok := writer.(*clipboardCommandWriter); !ok {
|
||||
t.Fatalf("NewClipboardWriter(android) = %T, want *clipboardCommandWriter", writer)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewPlatformClipboardWriterUsesSystemClipboardOffAndroid(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
if writer := NewClipboardWriter("linux", nil); writer != nil {
|
||||
t.Fatalf("NewClipboardWriter(linux) = %T, want nil", writer)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClipboardCommandWriterDrainsQueuedWrites(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
writer := &clipboardCommandWriter{}
|
||||
if err := writer.WriteText("username"); err != nil {
|
||||
t.Fatalf("WriteText(username) error = %v", err)
|
||||
}
|
||||
if err := writer.WriteText("password"); err != nil {
|
||||
t.Fatalf("WriteText(password) error = %v", err)
|
||||
}
|
||||
|
||||
if got := writer.drain(); !slices.Equal(got, []string{"username", "password"}) {
|
||||
t.Fatalf("drain() = %v, want [username password]", got)
|
||||
}
|
||||
if got := writer.drain(); got != nil {
|
||||
t.Fatalf("drain() after flush = %v, want nil", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
package appui
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"runtime"
|
||||
"strings"
|
||||
|
||||
"gioui.org/app"
|
||||
"gioui.org/op"
|
||||
"gioui.org/unit"
|
||||
"gioui.org/x/explorer"
|
||||
"git.julianfamily.org/keepassgo/internal/api"
|
||||
"git.julianfamily.org/keepassgo/internal/apiapproval"
|
||||
"git.julianfamily.org/keepassgo/internal/apitokens"
|
||||
"git.julianfamily.org/keepassgo/internal/appui/platform"
|
||||
"git.julianfamily.org/keepassgo/internal/passwords"
|
||||
"git.julianfamily.org/keepassgo/internal/session"
|
||||
"git.julianfamily.org/keepassgo/internal/vault"
|
||||
)
|
||||
|
||||
func Main() {
|
||||
mode := flag.String("mode", "", "window mode: desktop or phone")
|
||||
stateDir := flag.String("state-dir", "", "directory for KeePassGO state such as recent-vault history and default save targets")
|
||||
grpcAddr := flag.String("grpc-addr", "", "address for the local gRPC API listener; use 'off' to disable")
|
||||
flag.Parse()
|
||||
|
||||
resolvedMode := resolveFlagOrEnv(*mode, "KEEPASSGO_MODE", defaultModeForRuntime(runtime.GOOS))
|
||||
resolvedStateDir := resolveFlagOrEnv(*stateDir, "KEEPASSGO_STATE_DIR", "")
|
||||
resolvedGRPCAddr := resolveFlagOrEnv(*grpcAddr, "KEEPASSGO_GRPC_ADDR", defaultGRPCAddr(runtime.GOOS))
|
||||
|
||||
width := unit.Dp(1180)
|
||||
height := unit.Dp(760)
|
||||
if strings.EqualFold(resolvedMode, "phone") {
|
||||
width = unit.Dp(412)
|
||||
height = unit.Dp(915)
|
||||
}
|
||||
|
||||
go func() {
|
||||
w := new(app.Window)
|
||||
options := []app.Option{app.Title(productName)}
|
||||
if shouldUsePreviewWindowSize(resolvedMode, runtime.GOOS) {
|
||||
options = append(options, app.Size(width, height))
|
||||
}
|
||||
w.Option(options...)
|
||||
if err := run(w, strings.ToLower(resolvedMode), defaultStatePaths(resolvedStateDir), resolvedGRPCAddr); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
if !strings.EqualFold(runtime.GOOS, "android") {
|
||||
os.Exit(0)
|
||||
}
|
||||
}()
|
||||
app.Main()
|
||||
}
|
||||
|
||||
func defaultGRPCAddr(goos string) string {
|
||||
if strings.EqualFold(strings.TrimSpace(goos), "android") {
|
||||
return "off"
|
||||
}
|
||||
return "127.0.0.1:47777"
|
||||
}
|
||||
|
||||
func run(w *app.Window, mode string, paths statePaths, grpcAddr string) error {
|
||||
var ops op.Ops
|
||||
manager := &session.Manager{}
|
||||
ui := newUIWithSession(mode, manager, paths)
|
||||
ui.fileExplorer = explorer.NewExplorer(w)
|
||||
ui.invalidate = w.Invalidate
|
||||
ui.clipboardWriter = platform.NewClipboardWriter(runtime.GOOS, w.Invalidate)
|
||||
host, err := api.StartHost(grpcAddr, manager, passwords.DefaultProfiles(), ui.clipboardWriter, func() bool { return ui.state.Dirty })
|
||||
if err != nil {
|
||||
ui.state.ErrorMessage = fmt.Sprintf("start gRPC API: %v", err)
|
||||
} else if host != nil {
|
||||
ui.apiHost = host
|
||||
ui.auditLog = host.Server().AuditLog()
|
||||
ui.grpcAddress = host.Address()
|
||||
ui.state.Approvals = &uiApprovalManager{server: host.Server()}
|
||||
defer func() { _ = host.Stop() }()
|
||||
}
|
||||
for {
|
||||
e := w.Event()
|
||||
ui.fileExplorer.ListenEvents(e)
|
||||
switch e := e.(type) {
|
||||
case app.DestroyEvent:
|
||||
return e.Err
|
||||
case app.FrameEvent:
|
||||
gtx := app.NewContext(&ops, e)
|
||||
ui.processBackgroundActions()
|
||||
ui.layout(gtx)
|
||||
platform.ProcessClipboardWrites(gtx, ui.clipboardWriter)
|
||||
e.Frame(gtx.Ops)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type uiApprovalManager struct {
|
||||
server *api.Server
|
||||
}
|
||||
|
||||
func (m *uiApprovalManager) Pending() []apiapproval.Request {
|
||||
if m == nil || m.server == nil {
|
||||
return nil
|
||||
}
|
||||
return m.server.ApprovalBroker().Pending()
|
||||
}
|
||||
|
||||
func (m *uiApprovalManager) Resolve(id string, outcome apiapproval.Outcome) (apiapproval.Request, *apitokens.PolicyRule, error) {
|
||||
if m == nil || m.server == nil {
|
||||
return apiapproval.Request{}, nil, fmt.Errorf("approval manager is not configured")
|
||||
}
|
||||
return m.server.ResolveApproval(id, outcome)
|
||||
}
|
||||
|
||||
type uiSession struct {
|
||||
model vault.Model
|
||||
locked bool
|
||||
}
|
||||
|
||||
func (s *uiSession) HasVault() bool {
|
||||
return len(s.model.Entries) > 0 || len(s.model.Templates) > 0 || len(s.model.RecycleBin) > 0 || len(s.model.Groups) > 0 || s.locked
|
||||
}
|
||||
|
||||
func (s *uiSession) IsLocked() bool {
|
||||
return s.locked
|
||||
}
|
||||
|
||||
func (s *uiSession) IsRemote() bool {
|
||||
return false
|
||||
}
|
||||
|
||||
func (s *uiSession) Current() (vault.Model, error) {
|
||||
if s.locked {
|
||||
return vault.Model{}, session.ErrLocked
|
||||
}
|
||||
return s.model, nil
|
||||
}
|
||||
|
||||
func (s *uiSession) Replace(model vault.Model) {
|
||||
s.model = model
|
||||
}
|
||||
|
||||
func (s *uiSession) Lock() error {
|
||||
s.locked = true
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *uiSession) Unlock(vault.MasterKey) error {
|
||||
if !s.locked {
|
||||
return nil
|
||||
}
|
||||
s.locked = false
|
||||
return nil
|
||||
}
|
||||
|
||||
func pickExistingFile() (string, error) {
|
||||
if path, err := runFilePicker("kdialog", "--getopenfilename", "--title", "Choose KeePass file"); err == nil {
|
||||
return path, nil
|
||||
}
|
||||
if path, err := runFilePicker("zenity", "--file-selection", "--title=Choose KeePass file"); err == nil {
|
||||
return path, nil
|
||||
}
|
||||
return "", fmt.Errorf("no supported file picker found; install kdialog or zenity")
|
||||
}
|
||||
|
||||
func runFilePicker(name string, args ...string) (string, error) {
|
||||
if _, err := exec.LookPath(name); err != nil {
|
||||
return "", err
|
||||
}
|
||||
cmd := exec.Command(name, args...)
|
||||
output, err := cmd.Output()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return parsePickedFilePath(output)
|
||||
}
|
||||
|
||||
func parsePickedFilePath(output []byte) (string, error) {
|
||||
lines := strings.Split(strings.ReplaceAll(string(output), "\r\n", "\n"), "\n")
|
||||
for i := len(lines) - 1; i >= 0; i-- {
|
||||
line := strings.TrimSpace(lines[i])
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(line, "/") || strings.HasPrefix(line, "~/") {
|
||||
return line, nil
|
||||
}
|
||||
}
|
||||
return "", fmt.Errorf("file picker did not return a path")
|
||||
}
|
||||
@@ -0,0 +1,436 @@
|
||||
package appui
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"image"
|
||||
"image/color"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gioui.org/layout"
|
||||
"gioui.org/op/clip"
|
||||
"gioui.org/op/paint"
|
||||
"gioui.org/unit"
|
||||
"gioui.org/widget"
|
||||
"gioui.org/widget/material"
|
||||
editormodel "git.julianfamily.org/keepassgo/internal/appui/editor"
|
||||
headerlayout "git.julianfamily.org/keepassgo/internal/appui/header/layout"
|
||||
"git.julianfamily.org/keepassgo/internal/appui/platform"
|
||||
settingsmodel "git.julianfamily.org/keepassgo/internal/appui/settings"
|
||||
"git.julianfamily.org/keepassgo/internal/vault"
|
||||
)
|
||||
|
||||
const (
|
||||
displayDensityDense = settingsmodel.DisplayDensityDense
|
||||
displayDensityComfortable = settingsmodel.DisplayDensityComfortable
|
||||
|
||||
contrastStandard = settingsmodel.ContrastStandard
|
||||
contrastHigh = settingsmodel.ContrastHigh
|
||||
|
||||
keyboardFocusStandard = settingsmodel.KeyboardFocusStandard
|
||||
keyboardFocusProminent = settingsmodel.KeyboardFocusProminent
|
||||
)
|
||||
|
||||
type accessibilityPreferences = settingsmodel.AccessibilityPreferences
|
||||
|
||||
type settingsFile struct {
|
||||
Sync syncSettings `json:"sync,omitempty"`
|
||||
Debug debugSettings `json:"debug,omitempty"`
|
||||
}
|
||||
|
||||
type syncSettings struct {
|
||||
SourceDefault string `json:"sourceDefault,omitempty"`
|
||||
DirectionDefault string `json:"directionDefault,omitempty"`
|
||||
}
|
||||
|
||||
type debugSettings struct {
|
||||
LogHeaderBounds bool `json:"logHeaderBounds,omitempty"`
|
||||
}
|
||||
|
||||
type syncSettingsDraft struct {
|
||||
SourceDefault syncSourceMode
|
||||
DirectionDefault syncDirection
|
||||
}
|
||||
|
||||
type settingsDraft struct {
|
||||
Accessibility accessibilityPreferences
|
||||
Sync syncSettingsDraft
|
||||
Debug debugSettings
|
||||
}
|
||||
|
||||
type legacySyncPreferences struct {
|
||||
SyncSourceDefault string `json:"syncSourceDefault,omitempty"`
|
||||
SyncDirectionDefault string `json:"syncDirectionDefault,omitempty"`
|
||||
}
|
||||
|
||||
type choiceSpec struct {
|
||||
Click *widget.Clickable
|
||||
Label string
|
||||
Active bool
|
||||
}
|
||||
|
||||
type focusAppearance struct {
|
||||
BorderColor color.NRGBA
|
||||
OutlineColor color.NRGBA
|
||||
OutlineWidth int
|
||||
MinHeight int
|
||||
}
|
||||
|
||||
func defaultAccessibilityPreferences() accessibilityPreferences {
|
||||
return settingsmodel.DefaultAccessibilityPreferences()
|
||||
}
|
||||
|
||||
func displayDensityForDenseLayout(dense bool) string {
|
||||
return settingsmodel.DisplayDensityForDenseLayout(dense)
|
||||
}
|
||||
|
||||
func normalizeAccessibilityPreferences(prefs accessibilityPreferences) accessibilityPreferences {
|
||||
return settingsmodel.NormalizeAccessibilityPreferences(prefs)
|
||||
}
|
||||
|
||||
func (u *ui) applyAccessibilityPreferences(prefs accessibilityPreferences) {
|
||||
normalized := normalizeAccessibilityPreferences(prefs)
|
||||
u.denseLayout = normalized.DisplayDensity == displayDensityDense
|
||||
u.accessibilityPrefs = normalized
|
||||
}
|
||||
|
||||
func fieldFocusAppearance(metric unit.Metric, prefs accessibilityPreferences, focused bool) focusAppearance {
|
||||
prefs = normalizeAccessibilityPreferences(prefs)
|
||||
appearance := focusAppearance{
|
||||
BorderColor: color.NRGBA{R: 202, G: 194, B: 180, A: 255},
|
||||
OutlineColor: color.NRGBA{A: 0},
|
||||
OutlineWidth: max(1, metric.Dp(unit.Dp(1))),
|
||||
MinHeight: metric.Dp(unit.Dp(44)),
|
||||
}
|
||||
if prefs.DisplayDensity == displayDensityComfortable {
|
||||
appearance.MinHeight = metric.Dp(unit.Dp(52))
|
||||
}
|
||||
if prefs.Contrast == contrastHigh {
|
||||
appearance.BorderColor = color.NRGBA{R: 108, G: 101, B: 90, A: 255}
|
||||
}
|
||||
if focused {
|
||||
appearance.BorderColor = accentColor
|
||||
appearance.OutlineColor = color.NRGBA{R: 28, G: 83, B: 63, A: 72}
|
||||
appearance.OutlineWidth = max(2, metric.Dp(unit.Dp(2)))
|
||||
if prefs.Contrast == contrastHigh {
|
||||
appearance.BorderColor = color.NRGBA{R: 16, G: 60, B: 44, A: 255}
|
||||
appearance.OutlineColor = color.NRGBA{R: 20, G: 74, B: 55, A: 124}
|
||||
}
|
||||
if prefs.KeyboardFocus == keyboardFocusProminent {
|
||||
appearance.OutlineWidth = max(3, metric.Dp(unit.Dp(3)))
|
||||
appearance.OutlineColor = color.NRGBA{R: 20, G: 74, B: 55, A: 148}
|
||||
}
|
||||
}
|
||||
return appearance
|
||||
}
|
||||
|
||||
func buttonFocusColors(prefs accessibilityPreferences, focused bool) (background color.NRGBA, text color.NRGBA) {
|
||||
prefs = normalizeAccessibilityPreferences(prefs)
|
||||
background = color.NRGBA{R: 231, G: 239, B: 235, A: 255}
|
||||
text = accentColor
|
||||
if prefs.Contrast == contrastHigh {
|
||||
background = color.NRGBA{R: 225, G: 235, B: 230, A: 255}
|
||||
text = color.NRGBA{R: 19, G: 57, B: 43, A: 255}
|
||||
}
|
||||
if focused {
|
||||
background = color.NRGBA{R: 214, G: 229, B: 221, A: 255}
|
||||
if prefs.Contrast == contrastHigh || prefs.KeyboardFocus == keyboardFocusProminent {
|
||||
background = color.NRGBA{R: 202, G: 222, B: 212, A: 255}
|
||||
}
|
||||
}
|
||||
return background, text
|
||||
}
|
||||
|
||||
func (u *ui) accessibilityLabel(id focusID) string {
|
||||
switch {
|
||||
case id == focusSearch:
|
||||
return "Search vault"
|
||||
case strings.HasPrefix(string(id), "breadcrumb:"):
|
||||
index := focusIndex(id)
|
||||
crumbs := u.breadcrumbLabels()
|
||||
if index >= 0 && index < len(crumbs) {
|
||||
return fmt.Sprintf("Navigate to %s", crumbs[index])
|
||||
}
|
||||
case strings.HasPrefix(string(id), "list:"):
|
||||
index := focusIndex(id)
|
||||
if index >= 0 && index < len(u.visible) {
|
||||
return fmt.Sprintf("Select entry %s", u.visible[index].Title)
|
||||
}
|
||||
case strings.HasPrefix(string(id), "detail:"):
|
||||
name := strings.TrimPrefix(string(id), "detail:")
|
||||
return fmt.Sprintf("Edit %s", detailFieldLabel(detailField(name)))
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func drawFocusOutline(gtx layout.Context, appearance focusAppearance, size image.Point) layout.Dimensions {
|
||||
if appearance.OutlineColor.A == 0 || appearance.OutlineWidth <= 0 {
|
||||
return layout.Dimensions{Size: size}
|
||||
}
|
||||
|
||||
width := appearance.OutlineWidth
|
||||
paint.FillShape(gtx.Ops, appearance.OutlineColor, clip.Rect{Max: image.Pt(size.X, width)}.Op())
|
||||
paint.FillShape(gtx.Ops, appearance.OutlineColor, clip.Rect{Min: image.Pt(0, size.Y-width), Max: image.Pt(size.X, size.Y)}.Op())
|
||||
paint.FillShape(gtx.Ops, appearance.OutlineColor, clip.Rect{Max: image.Pt(width, size.Y)}.Op())
|
||||
paint.FillShape(gtx.Ops, appearance.OutlineColor, clip.Rect{Min: image.Pt(size.X-width, 0), Max: image.Pt(size.X, size.Y)}.Op())
|
||||
return layout.Dimensions{Size: size}
|
||||
}
|
||||
|
||||
func (u *ui) isFocused(id focusID) bool {
|
||||
return u.keyboardFocus == id
|
||||
}
|
||||
|
||||
func detailFieldLabel(field detailField) string {
|
||||
return editormodel.Label(field)
|
||||
}
|
||||
|
||||
func (u *ui) loadSettingsDraft() {
|
||||
u.settingsDraft = settingsDraft{
|
||||
Accessibility: accessibilityPreferences{
|
||||
DisplayDensity: displayDensityForDenseLayout(u.denseLayout),
|
||||
Contrast: u.accessibilityPrefs.Contrast,
|
||||
ReducedMotion: u.accessibilityPrefs.ReducedMotion,
|
||||
KeyboardFocus: u.accessibilityPrefs.KeyboardFocus,
|
||||
},
|
||||
Sync: syncSettingsDraft{
|
||||
SourceDefault: u.syncDefaultSourceMode,
|
||||
DirectionDefault: u.syncDefaultDirection,
|
||||
},
|
||||
Debug: debugSettings{
|
||||
LogHeaderBounds: u.debugLogHeaderBounds,
|
||||
},
|
||||
}
|
||||
u.settingsDebugHeaderBounds.Value = u.settingsDraft.Debug.LogHeaderBounds
|
||||
}
|
||||
|
||||
func (u *ui) saveSecuritySettingsAction() error {
|
||||
if err := u.applySecuritySettingsLive(); err != nil {
|
||||
return err
|
||||
}
|
||||
u.securityDialogOpen = false
|
||||
return nil
|
||||
}
|
||||
|
||||
func (u *ui) applySecuritySettingsLive() error {
|
||||
settings := vault.SecuritySettings{
|
||||
Cipher: strings.TrimSpace(u.securityCipher.Text()),
|
||||
KDF: strings.TrimSpace(u.securityKDF.Text()),
|
||||
}
|
||||
if err := u.state.ConfigureSecurity(settings); err != nil {
|
||||
return err
|
||||
}
|
||||
if u.settingsDraft.Accessibility.DisplayDensity == displayDensityForDenseLayout(u.denseLayout) {
|
||||
u.settingsDraft.Accessibility.DisplayDensity = displayDensityForDenseLayout(u.settingsDenseLayout.Value)
|
||||
}
|
||||
u.settingsDraft.Debug.LogHeaderBounds = u.settingsDebugHeaderBounds.Value
|
||||
u.settingsDenseLayout.Value = u.settingsDraft.Accessibility.DisplayDensity == displayDensityDense
|
||||
u.syncDefaultSourceMode = sanitizeSyncSourceMode(u.settingsDraft.Sync.SourceDefault)
|
||||
u.syncDefaultDirection = sanitizeSyncDirection(u.settingsDraft.Sync.DirectionDefault)
|
||||
u.debugLogHeaderBounds = u.settingsDraft.Debug.LogHeaderBounds
|
||||
if !u.debugLogHeaderBounds {
|
||||
u.lastHeaderBoundsLog = ""
|
||||
}
|
||||
u.applySettingsFormToPreferences()
|
||||
u.applyAccessibilityPreferences(u.settingsDraft.Accessibility)
|
||||
u.saveSettings()
|
||||
u.saveUIPreferences()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (u *ui) loadSettings() {
|
||||
u.syncDefaultSourceMode = syncSourceLocal
|
||||
u.syncDefaultDirection = syncDirectionPull
|
||||
|
||||
if strings.TrimSpace(u.settingsPath) != "" {
|
||||
content, err := os.ReadFile(u.settingsPath)
|
||||
if err == nil {
|
||||
var settings settingsFile
|
||||
if json.Unmarshal(content, &settings) == nil {
|
||||
u.syncDefaultSourceMode = sanitizeSyncSourceMode(syncSourceMode(settings.Sync.SourceDefault))
|
||||
u.syncDefaultDirection = sanitizeSyncDirection(syncDirection(settings.Sync.DirectionDefault))
|
||||
u.debugLogHeaderBounds = settings.Debug.LogHeaderBounds
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
u.loadLegacySyncDefaultsFromUIPreferences()
|
||||
}
|
||||
|
||||
func (u *ui) loadLegacySyncDefaultsFromUIPreferences() {
|
||||
if strings.TrimSpace(u.uiPreferencesPath) == "" {
|
||||
return
|
||||
}
|
||||
content, err := os.ReadFile(u.uiPreferencesPath)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
var prefs legacySyncPreferences
|
||||
if err := json.Unmarshal(content, &prefs); err != nil {
|
||||
return
|
||||
}
|
||||
u.syncDefaultSourceMode = sanitizeSyncSourceMode(syncSourceMode(prefs.SyncSourceDefault))
|
||||
u.syncDefaultDirection = sanitizeSyncDirection(syncDirection(prefs.SyncDirectionDefault))
|
||||
}
|
||||
|
||||
func (u *ui) saveSettings() {
|
||||
if strings.TrimSpace(u.settingsPath) == "" {
|
||||
return
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(u.settingsPath), 0o700); err != nil {
|
||||
return
|
||||
}
|
||||
content, err := json.MarshalIndent(settingsFile{
|
||||
Sync: syncSettings{
|
||||
SourceDefault: string(u.syncDefaultSourceMode),
|
||||
DirectionDefault: string(u.syncDefaultDirection),
|
||||
},
|
||||
Debug: debugSettings{
|
||||
LogHeaderBounds: u.debugLogHeaderBounds,
|
||||
},
|
||||
}, "", " ")
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
_ = os.WriteFile(u.settingsPath, content, 0o600)
|
||||
}
|
||||
|
||||
func (u *ui) maybeLogHeaderBounds(bounds headerButtonBounds) {
|
||||
if !u.debugLogHeaderBounds {
|
||||
return
|
||||
}
|
||||
line := bounds.logLine(u.mode)
|
||||
if line == u.lastHeaderBoundsLog {
|
||||
return
|
||||
}
|
||||
platform.LogInfo("KeePassGO", line)
|
||||
u.lastHeaderBoundsLog = line
|
||||
}
|
||||
|
||||
func (u *ui) maybeLogHeaderMenuToggle(menu string, open bool) {
|
||||
if !u.debugLogHeaderBounds {
|
||||
return
|
||||
}
|
||||
platform.LogInfo("KeePassGO", fmt.Sprintf("keepassgo header-menu-toggle menu=%s open=%t", menu, open))
|
||||
}
|
||||
|
||||
func (u *ui) maybeLogHeaderMenuPlacement(menu string, surface headerlayout.DropdownSurface, placement headerlayout.DropdownPlacement) {
|
||||
if !u.debugLogHeaderBounds {
|
||||
return
|
||||
}
|
||||
platform.LogInfo("KeePassGO", fmt.Sprintf(
|
||||
"keepassgo header-menu-placement menu=%s anchor=%d,%d origin=%d,%d size=%dx%d container=%d inset=%d,%d",
|
||||
menu,
|
||||
placement.Anchor.TriggerRightX,
|
||||
placement.Anchor.TriggerBottomY,
|
||||
placement.Origin.X,
|
||||
placement.Origin.Y,
|
||||
placement.Size.X,
|
||||
placement.Size.Y,
|
||||
surface.ContainerWidth,
|
||||
surface.LeftInset,
|
||||
surface.TopInset,
|
||||
))
|
||||
}
|
||||
|
||||
func (u *ui) showStatusMessage(message string) {
|
||||
u.state.StatusMessage = message
|
||||
if u.accessibilityPrefs.ReducedMotion {
|
||||
u.statusExpiresAt = time.Time{}
|
||||
return
|
||||
}
|
||||
u.statusExpiresAt = u.now().Add(u.statusBannerTTL)
|
||||
}
|
||||
|
||||
func (u *ui) settingsPreferenceCard(gtx layout.Context, title, detail string, body layout.Widget) layout.Dimensions {
|
||||
return sectionCard(gtx, u.theme, title, detail, body)
|
||||
}
|
||||
|
||||
func settingsSummaryCard(gtx layout.Context, th *material.Theme, title, body string) layout.Dimensions {
|
||||
return compactCard(gtx, func(gtx layout.Context) layout.Dimensions {
|
||||
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
lbl := material.Label(th, unit.Sp(12), title)
|
||||
lbl.Color = mutedColor
|
||||
return lbl.Layout(gtx)
|
||||
}),
|
||||
layout.Rigid(layout.Spacer{Height: unit.Dp(2)}.Layout),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
lbl := material.Label(th, unit.Sp(13), body)
|
||||
lbl.Color = th.Palette.Fg
|
||||
return lbl.Layout(gtx)
|
||||
}),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
func (u *ui) settingsChoiceRow(gtx layout.Context, choices ...choiceSpec) layout.Dimensions {
|
||||
children := make([]layout.FlexChild, 0, len(choices)*2)
|
||||
for i, choice := range choices {
|
||||
if i > 0 {
|
||||
children = append(children, layout.Rigid(layout.Spacer{Width: unit.Dp(6)}.Layout))
|
||||
}
|
||||
current := choice
|
||||
children = append(children, layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
return syncChoiceButton(gtx, u.theme, current.Click, current.Label, current.Active)
|
||||
}))
|
||||
}
|
||||
return layout.Flex{Spacing: layout.SpaceStart}.Layout(gtx, children...)
|
||||
}
|
||||
|
||||
type listRowColors struct {
|
||||
Title color.NRGBA
|
||||
Meta color.NRGBA
|
||||
Secondary color.NRGBA
|
||||
Divider color.NRGBA
|
||||
Fill color.NRGBA
|
||||
Edge color.NRGBA
|
||||
}
|
||||
|
||||
func (u *ui) listRowColors(selected, focused, recycleBin bool) listRowColors {
|
||||
colors := listRowColors{
|
||||
Title: accentColor,
|
||||
Meta: color.NRGBA{R: 61, G: 60, B: 56, A: 255},
|
||||
Secondary: mutedColor,
|
||||
Divider: color.NRGBA{R: 225, G: 219, B: 210, A: 255},
|
||||
Fill: color.NRGBA{R: 231, G: 239, B: 235, A: 255},
|
||||
Edge: color.NRGBA{R: 69, G: 118, B: 97, A: 255},
|
||||
}
|
||||
if selected {
|
||||
colors.Title = color.NRGBA{R: 19, G: 57, B: 43, A: 255}
|
||||
colors.Meta = color.NRGBA{R: 31, G: 53, B: 44, A: 255}
|
||||
colors.Secondary = color.NRGBA{R: 72, G: 88, B: 80, A: 255}
|
||||
colors.Divider = color.NRGBA{R: 173, G: 196, B: 184, A: 255}
|
||||
colors.Fill = color.NRGBA{R: 212, G: 228, B: 220, A: 255}
|
||||
colors.Edge = color.NRGBA{R: 46, G: 106, B: 82, A: 255}
|
||||
}
|
||||
if recycleBin {
|
||||
colors.Fill = color.NRGBA{R: 244, G: 229, B: 219, A: 255}
|
||||
colors.Edge = color.NRGBA{R: 133, G: 65, B: 41, A: 255}
|
||||
}
|
||||
if focused && !selected {
|
||||
colors.Meta = color.NRGBA{R: 49, G: 74, B: 63, A: 255}
|
||||
colors.Secondary = color.NRGBA{R: 86, G: 102, B: 95, A: 255}
|
||||
colors.Divider = color.NRGBA{R: 190, G: 208, B: 199, A: 255}
|
||||
}
|
||||
if u.accessibilityPrefs.Contrast == contrastHigh {
|
||||
colors.Meta = color.NRGBA{R: 39, G: 39, B: 36, A: 255}
|
||||
colors.Secondary = color.NRGBA{R: 58, G: 57, B: 52, A: 255}
|
||||
if focused || selected {
|
||||
colors.Fill = color.NRGBA{R: 211, G: 228, B: 219, A: 255}
|
||||
colors.Edge = color.NRGBA{R: 16, G: 60, B: 44, A: 255}
|
||||
}
|
||||
}
|
||||
if u.accessibilityPrefs.KeyboardFocus == keyboardFocusProminent && focused && !selected {
|
||||
colors.Fill = color.NRGBA{R: 220, G: 234, B: 226, A: 255}
|
||||
colors.Edge = color.NRGBA{R: 20, G: 74, B: 55, A: 255}
|
||||
}
|
||||
if recycleBin && (focused || selected) && u.accessibilityPrefs.Contrast == contrastHigh {
|
||||
colors.Fill = color.NRGBA{R: 242, G: 223, B: 209, A: 255}
|
||||
colors.Edge = color.NRGBA{R: 116, G: 43, B: 19, A: 255}
|
||||
}
|
||||
return colors
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package settings
|
||||
|
||||
type AccessibilityPreferences struct {
|
||||
DisplayDensity string
|
||||
Contrast string
|
||||
ReducedMotion bool
|
||||
KeyboardFocus string
|
||||
}
|
||||
|
||||
const (
|
||||
DisplayDensityDense = "dense"
|
||||
DisplayDensityComfortable = "comfortable"
|
||||
ContrastStandard = "standard"
|
||||
ContrastHigh = "high"
|
||||
KeyboardFocusStandard = "standard"
|
||||
KeyboardFocusProminent = "prominent"
|
||||
)
|
||||
|
||||
func DefaultAccessibilityPreferences() AccessibilityPreferences {
|
||||
return AccessibilityPreferences{
|
||||
DisplayDensity: DisplayDensityDense,
|
||||
Contrast: ContrastStandard,
|
||||
KeyboardFocus: KeyboardFocusStandard,
|
||||
}
|
||||
}
|
||||
|
||||
func DisplayDensityForDenseLayout(dense bool) string {
|
||||
if dense {
|
||||
return DisplayDensityDense
|
||||
}
|
||||
return DisplayDensityComfortable
|
||||
}
|
||||
|
||||
func NormalizeAccessibilityPreferences(prefs AccessibilityPreferences) AccessibilityPreferences {
|
||||
normalized := DefaultAccessibilityPreferences()
|
||||
switch prefs.DisplayDensity {
|
||||
case DisplayDensityDense, DisplayDensityComfortable:
|
||||
normalized.DisplayDensity = prefs.DisplayDensity
|
||||
}
|
||||
switch prefs.Contrast {
|
||||
case ContrastStandard, ContrastHigh:
|
||||
normalized.Contrast = prefs.Contrast
|
||||
}
|
||||
switch prefs.KeyboardFocus {
|
||||
case KeyboardFocusStandard, KeyboardFocusProminent:
|
||||
normalized.KeyboardFocus = prefs.KeyboardFocus
|
||||
}
|
||||
normalized.ReducedMotion = prefs.ReducedMotion
|
||||
return normalized
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
package sync
|
||||
|
||||
import "git.julianfamily.org/keepassgo/internal/appstate"
|
||||
|
||||
type SourceMode string
|
||||
|
||||
const (
|
||||
SourceLocal SourceMode = "local"
|
||||
SourceRemote SourceMode = "remote"
|
||||
)
|
||||
|
||||
type Direction string
|
||||
|
||||
const (
|
||||
DirectionPull Direction = "pull"
|
||||
DirectionPush Direction = "push"
|
||||
)
|
||||
|
||||
type DialogPurpose string
|
||||
|
||||
const (
|
||||
DialogPurposeAdvanced DialogPurpose = "advanced"
|
||||
DialogPurposeRemoteSetup DialogPurpose = "remote-setup"
|
||||
)
|
||||
|
||||
type MenuModel struct {
|
||||
HasOpenVault bool
|
||||
HasSelectedBinding bool
|
||||
ShowSelectors bool
|
||||
ShowShare bool
|
||||
ShowSaveCurrentBinding bool
|
||||
SavedBindingSummary MenuBindingSummary
|
||||
RemoteBaseURL string
|
||||
RemotePath string
|
||||
RemoteUsername string
|
||||
RemotePassword string
|
||||
SelectedVaultSyncMode appstate.SyncMode
|
||||
}
|
||||
|
||||
type MenuBindingSummary struct {
|
||||
ProfileLabel string
|
||||
CredentialLabel string
|
||||
SyncLabel string
|
||||
OK bool
|
||||
}
|
||||
|
||||
func (m MenuModel) SavedBindingHeading() string {
|
||||
if !m.ShowSelectors {
|
||||
return "Use this vault's saved remote sync target"
|
||||
}
|
||||
return "Use a saved remote profile from this vault"
|
||||
}
|
||||
|
||||
func (m MenuModel) OpenSelectedButtonLabel() string {
|
||||
if !m.ShowSelectors {
|
||||
return "Use Remote Sync"
|
||||
}
|
||||
return "Open Saved Remote"
|
||||
}
|
||||
|
||||
func (m MenuModel) ShowDirectRemoteSyncShortcut() bool {
|
||||
return m.HasOpenVault && m.HasSelectedBinding
|
||||
}
|
||||
|
||||
func (m MenuModel) DirectRemoteSyncShortcutLabel() string { return "Use Remote Sync" }
|
||||
|
||||
func (m MenuModel) ShowRemoteSyncSettingsShortcut() bool {
|
||||
return m.HasOpenVault && m.HasSelectedBinding
|
||||
}
|
||||
|
||||
func (m MenuModel) RemoteSyncSettingsShortcutLabel() string { return "Remote Sync Settings" }
|
||||
|
||||
func (m MenuModel) ShowRemoveRemoteSyncShortcut() bool { return m.ShowRemoteSyncSettingsShortcut() }
|
||||
|
||||
func (m MenuModel) RemoveRemoteSyncShortcutLabel() string { return "Stop Using Remote Sync" }
|
||||
|
||||
func (m MenuModel) ShowRemoteSyncSetupShortcut() bool {
|
||||
return m.HasOpenVault && !m.HasSelectedBinding
|
||||
}
|
||||
|
||||
func (m MenuModel) RemoteSyncSetupShortcutLabel() string { return "Set Up Remote Sync" }
|
||||
|
||||
func (m MenuModel) ActionLabels() []string {
|
||||
labels := []string{"Open Advanced Sync"}
|
||||
if m.ShowRemoteSyncSetupShortcut() {
|
||||
labels = append(labels, m.RemoteSyncSetupShortcutLabel())
|
||||
}
|
||||
if m.ShowDirectRemoteSyncShortcut() {
|
||||
labels = append(labels, m.DirectRemoteSyncShortcutLabel())
|
||||
}
|
||||
if m.ShowRemoteSyncSettingsShortcut() {
|
||||
labels = append(labels, m.RemoteSyncSettingsShortcutLabel())
|
||||
}
|
||||
if m.ShowRemoveRemoteSyncShortcut() {
|
||||
labels = append(labels, m.RemoveRemoteSyncShortcutLabel())
|
||||
}
|
||||
return labels
|
||||
}
|
||||
|
||||
func (m MenuModel) SaveCurrentRemoteBindingHeading() string {
|
||||
return "Bind this local vault to the current remote target"
|
||||
}
|
||||
|
||||
func (m MenuModel) SaveCurrentRemoteBindingButtonLabel() string { return "Save Remote In Vault" }
|
||||
|
||||
func SummaryText(purpose DialogPurpose, source SourceMode, direction Direction) string {
|
||||
if purpose == DialogPurposeRemoteSetup {
|
||||
return "Push this local vault to a WebDAV target and save that target for future sync."
|
||||
}
|
||||
sourceLabel := "another local vault file"
|
||||
if source == SourceRemote {
|
||||
sourceLabel = "another WebDAV-backed vault"
|
||||
}
|
||||
action := "Pull changes from"
|
||||
if direction == DirectionPush {
|
||||
action = "Push the current vault into"
|
||||
}
|
||||
return action + " " + sourceLabel + "."
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
package appui
|
||||
|
||||
import (
|
||||
"image/color"
|
||||
"runtime"
|
||||
"strings"
|
||||
|
||||
"gioui.org/layout"
|
||||
"gioui.org/op/clip"
|
||||
"gioui.org/op/paint"
|
||||
"gioui.org/unit"
|
||||
"gioui.org/widget"
|
||||
"gioui.org/widget/material"
|
||||
)
|
||||
|
||||
func (u *ui) syncDialog(gtx layout.Context) layout.Dimensions {
|
||||
return layout.Stack{}.Layout(gtx,
|
||||
layout.Expanded(func(gtx layout.Context) layout.Dimensions {
|
||||
paint.FillShape(gtx.Ops, color.NRGBA{A: 90}, clip.Rect{Max: gtx.Constraints.Max}.Op())
|
||||
return layout.Dimensions{Size: gtx.Constraints.Max}
|
||||
}),
|
||||
layout.Stacked(func(gtx layout.Context) layout.Dimensions {
|
||||
return layout.Center.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
|
||||
width := gtx.Dp(unit.Dp(620))
|
||||
if width > gtx.Constraints.Max.X {
|
||||
width = gtx.Constraints.Max.X - gtx.Dp(unit.Dp(24))
|
||||
}
|
||||
if width < 1 {
|
||||
width = gtx.Constraints.Max.X
|
||||
}
|
||||
gtx.Constraints.Min.X = width
|
||||
gtx.Constraints.Max.X = width
|
||||
return card(gtx, u.syncDialogContent)
|
||||
})
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
func (u *ui) syncDialogContent(gtx layout.Context) layout.Dimensions {
|
||||
matchingCredentials := u.matchingAdvancedSyncRemoteCredentialEntries()
|
||||
if len(u.syncRemoteCredentialClicks) < len(matchingCredentials) {
|
||||
u.syncRemoteCredentialClicks = make([]widget.Clickable, len(matchingCredentials))
|
||||
}
|
||||
return material.List(u.theme, &u.syncDialogList).Layout(gtx, 1, func(gtx layout.Context, _ int) layout.Dimensions {
|
||||
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
lbl := material.Label(u.theme, unit.Sp(20), u.syncDialogTitle())
|
||||
lbl.Color = accentColor
|
||||
return lbl.Layout(gtx)
|
||||
}),
|
||||
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
lbl := material.Label(u.theme, unit.Sp(14), u.syncDialogDescription())
|
||||
lbl.Color = mutedColor
|
||||
return lbl.Layout(gtx)
|
||||
}),
|
||||
layout.Rigid(layout.Spacer{Height: unit.Dp(12)}.Layout),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
if !u.shouldShowSyncDirectionChoices() {
|
||||
return layout.Dimensions{}
|
||||
}
|
||||
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
|
||||
layout.Rigid(syncDialogSectionLabel(u.theme, "Direction")),
|
||||
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
return layout.Flex{Spacing: layout.SpaceStart}.Layout(gtx,
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
return syncChoiceButton(gtx, u.theme, &u.showSyncPull, "Pull Into Current Vault", u.syncDirection == syncDirectionPull)
|
||||
}),
|
||||
layout.Rigid(layout.Spacer{Width: unit.Dp(6)}.Layout),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
return syncChoiceButton(gtx, u.theme, &u.showSyncPush, "Push Current Vault Out", u.syncDirection == syncDirectionPush)
|
||||
}),
|
||||
)
|
||||
}),
|
||||
)
|
||||
}),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
if !u.shouldShowSyncDirectionChoices() {
|
||||
return layout.Dimensions{}
|
||||
}
|
||||
return layout.Spacer{Height: unit.Dp(12)}.Layout(gtx)
|
||||
}),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
if !u.shouldShowSyncSourceChoices() {
|
||||
return layout.Dimensions{}
|
||||
}
|
||||
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
|
||||
layout.Rigid(syncDialogSectionLabel(u.theme, "Other Source")),
|
||||
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
return layout.Flex{Spacing: layout.SpaceStart}.Layout(gtx,
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
return syncChoiceButton(gtx, u.theme, &u.showSyncLocal, "Local File", u.syncSourceMode == syncSourceLocal)
|
||||
}),
|
||||
layout.Rigid(layout.Spacer{Width: unit.Dp(6)}.Layout),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
return syncChoiceButton(gtx, u.theme, &u.showSyncRemote, "Remote WebDAV", u.syncSourceMode == syncSourceRemote)
|
||||
}),
|
||||
)
|
||||
}),
|
||||
)
|
||||
}),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
if !u.shouldShowSyncSourceChoices() {
|
||||
return layout.Dimensions{}
|
||||
}
|
||||
return layout.Spacer{Height: unit.Dp(12)}.Layout(gtx)
|
||||
}),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
return syncDialogSummaryCard(gtx, u.theme, u.syncDialogPurpose, u.syncSourceMode, u.syncDirection)
|
||||
}),
|
||||
layout.Rigid(layout.Spacer{Height: unit.Dp(12)}.Layout),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
if u.syncSourceMode == syncSourceRemote {
|
||||
children := []layout.FlexChild{
|
||||
layout.Rigid(labeledEditorHelp(u.theme, "Remote Base URL", "WebDAV base URL for the other source.", &u.syncRemoteBaseURL, false)),
|
||||
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
|
||||
layout.Rigid(labeledEditorHelp(u.theme, "Remote Path", "Path to the other remote .kdbx file.", &u.syncRemotePath, false)),
|
||||
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
|
||||
layout.Rigid(labeledEditorHelp(u.theme, "Remote Username", "Username for the other WebDAV source.", &u.syncRemoteUsername, false)),
|
||||
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
return u.syncPasswordField(gtx)
|
||||
}),
|
||||
}
|
||||
if u.syncDialogPurpose == syncDialogPurposeRemoteSetup {
|
||||
children = append(children,
|
||||
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
check := material.CheckBox(u.theme, &u.syncSetupAutomatic, "Sync automatically on open and save")
|
||||
check.Color = accentColor
|
||||
return check.Layout(gtx)
|
||||
}),
|
||||
)
|
||||
}
|
||||
if len(matchingCredentials) > 0 {
|
||||
children = append(children,
|
||||
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
lbl := material.Label(u.theme, unit.Sp(11), "Matching vault credentials")
|
||||
lbl.Color = mutedColor
|
||||
return lbl.Layout(gtx)
|
||||
}),
|
||||
)
|
||||
for i, entry := range matchingCredentials {
|
||||
i := i
|
||||
entry := entry
|
||||
children = append(children,
|
||||
layout.Rigid(layout.Spacer{Height: unit.Dp(4)}.Layout),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
label := entry.Title
|
||||
if strings.TrimSpace(entry.Username) != "" {
|
||||
label += " · " + strings.TrimSpace(entry.Username)
|
||||
}
|
||||
selected := strings.TrimSpace(u.selectedSyncRemoteCredentialEntryID) == entry.ID
|
||||
return recentSelectionCard(gtx, selected, func(gtx layout.Context) layout.Dimensions {
|
||||
return u.syncRemoteCredentialClicks[i].Layout(gtx, func(gtx layout.Context) layout.Dimensions {
|
||||
lbl := material.Label(u.theme, unit.Sp(13), label)
|
||||
lbl.Color = accentColor
|
||||
return lbl.Layout(gtx)
|
||||
})
|
||||
})
|
||||
}),
|
||||
)
|
||||
}
|
||||
}
|
||||
return layout.Flex{Axis: layout.Vertical}.Layout(gtx, children...)
|
||||
}
|
||||
if supportsDesktopFilePicker(runtime.GOOS) {
|
||||
return selectorEditorHelp(u.theme, "Local Vault Path", "Choose the other local .kdbx file to synchronize with.", &u.syncLocalPath, &u.pickSyncLocalPath, "Choose File", false)(gtx)
|
||||
}
|
||||
return labeledEditorHelp(u.theme, "Local Vault Path", "Enter the shared-storage path to the other local .kdbx file to synchronize with.", &u.syncLocalPath, false)(gtx)
|
||||
}),
|
||||
layout.Rigid(layout.Spacer{Height: unit.Dp(14)}.Layout),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
return layout.Flex{Spacing: layout.SpaceStart}.Layout(gtx,
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
return tonedButton(gtx, u.theme, &u.runAdvancedSync, u.syncDialogConfirmButtonLabel())
|
||||
}),
|
||||
layout.Rigid(layout.Spacer{Width: unit.Dp(6)}.Layout),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
return tonedButton(gtx, u.theme, &u.closeAdvancedSync, "Cancel")
|
||||
}),
|
||||
)
|
||||
}),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
func (u *ui) syncPasswordField(gtx layout.Context) layout.Dimensions {
|
||||
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
lbl := material.Label(u.theme, unit.Sp(12), "REMOTE PASSWORD")
|
||||
lbl.Color = mutedColor
|
||||
return lbl.Layout(gtx)
|
||||
}),
|
||||
layout.Rigid(layout.Spacer{Height: unit.Dp(4)}.Layout),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
field := func(gtx layout.Context) layout.Dimensions {
|
||||
editor := material.Editor(u.theme, &u.syncRemotePassword, "Remote Password")
|
||||
editor.Color = u.theme.Palette.Fg
|
||||
editor.HintColor = mutedColor
|
||||
return layout.UniformInset(unit.Dp(10)).Layout(gtx, editor.Layout)
|
||||
}
|
||||
return layout.Flex{Alignment: layout.Middle}.Layout(gtx,
|
||||
layout.Flexed(1, func(gtx layout.Context) layout.Dimensions {
|
||||
return u.outlinedFieldState(gtx, false, field)
|
||||
}),
|
||||
layout.Rigid(layout.Spacer{Width: unit.Dp(8)}.Layout),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
return u.inlinePasswordToggle(gtx, &u.toggleSyncPassword, u.showSyncPassword)
|
||||
}),
|
||||
)
|
||||
}),
|
||||
layout.Rigid(layout.Spacer{Height: unit.Dp(4)}.Layout),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
lbl := material.Label(u.theme, unit.Sp(12), "Password or app token for the other WebDAV source.")
|
||||
lbl.Color = mutedColor
|
||||
return lbl.Layout(gtx)
|
||||
}),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package assets
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"embed"
|
||||
"image"
|
||||
"image/png"
|
||||
)
|
||||
|
||||
//go:embed *.png *.svg
|
||||
var files embed.FS
|
||||
|
||||
func MustPNG(name string) image.Image {
|
||||
data, err := files.ReadFile(name)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
img, err := png.Decode(bytes.NewReader(data))
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return img
|
||||
}
|
||||
|
After Width: | Height: | Size: 14 KiB |
@@ -0,0 +1,18 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="256" height="256" viewBox="0 0 256 256" fill="none">
|
||||
<title>KeePassGO icon</title>
|
||||
<desc>Vault-shaped mark with lock, layered record lines, and navigation accent.</desc>
|
||||
<polygon points="128,18 214,38 214,176 128,218 42,176 42,38" fill="#3F4E63"/>
|
||||
<polygon points="128,34 198,50 198,166 128,200 58,166 58,50" fill="#55657A" opacity="0.16"/>
|
||||
<polygon points="128,178 128,218 42,176 42,160" fill="#6D89A8"/>
|
||||
<rect x="44" y="78" width="62" height="10" rx="2" fill="#FFFFFF" opacity="0.92"/>
|
||||
<rect x="44" y="98" width="52" height="10" rx="2" fill="#FFFFFF" opacity="0.92"/>
|
||||
|
||||
<path d="M100 86C100 70.536 112.536 58 128 58C143.464 58 156 70.536 156 86V106H142V86C142 78.268 135.732 72 128 72C120.268 72 114 78.268 114 86V106H100V86Z" fill="#FFFFFF"/>
|
||||
<rect x="76" y="102" width="104" height="64" rx="10" fill="#FFFFFF"/>
|
||||
<rect x="80" y="106" width="96" height="56" rx="8" fill="#E9EDF0"/>
|
||||
<path d="M128 118C139.046 118 148 126.954 148 138C148 145.669 143.68 152.329 137.338 155.7V173H118.662V155.7C112.32 152.329 108 145.669 108 138C108 126.954 116.954 118 128 118Z" fill="#3F4E63"/>
|
||||
<circle cx="128" cy="138" r="8" fill="#FFFFFF" opacity="0.2"/>
|
||||
|
||||
<path d="M178 130H214V150H166V144L178 130Z" fill="#E79A17"/>
|
||||
<path d="M178 130H214V140H174L166 144V144L178 130Z" fill="#F0B13D" opacity="0.35"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 40 KiB |
@@ -0,0 +1,22 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="920" height="260" viewBox="0 0 920 260" fill="none">
|
||||
<title>KeePassGO horizontal logo</title>
|
||||
<desc>KeePassGO symbol with wordmark for light-theme desktop UI.</desc>
|
||||
<defs>
|
||||
<symbol id="kpg-icon" viewBox="0 0 256 256">
|
||||
<polygon points="128,18 214,38 214,176 128,218 42,176 42,38" fill="#3F4E63"/>
|
||||
<polygon points="128,34 198,50 198,166 128,200 58,166 58,50" fill="#55657A" opacity="0.16"/>
|
||||
<polygon points="128,178 128,218 42,176 42,160" fill="#6D89A8"/>
|
||||
<rect x="44" y="78" width="62" height="10" rx="2" fill="#FFFFFF" opacity="0.92"/>
|
||||
<rect x="44" y="98" width="52" height="10" rx="2" fill="#FFFFFF" opacity="0.92"/>
|
||||
<path d="M100 86C100 70.536 112.536 58 128 58C143.464 58 156 70.536 156 86V106H142V86C142 78.268 135.732 72 128 72C120.268 72 114 78.268 114 86V106H100V86Z" fill="#FFFFFF"/>
|
||||
<rect x="76" y="102" width="104" height="64" rx="10" fill="#FFFFFF"/>
|
||||
<rect x="80" y="106" width="96" height="56" rx="8" fill="#E9EDF0"/>
|
||||
<path d="M128 118C139.046 118 148 126.954 148 138C148 145.669 143.68 152.329 137.338 155.7V173H118.662V155.7C112.32 152.329 108 145.669 108 138C108 126.954 116.954 118 128 118Z" fill="#3F4E63"/>
|
||||
<path d="M178 130H214V150H166V144L178 130Z" fill="#E79A17"/>
|
||||
<path d="M178 130H214V140H174L166 144V144L178 130Z" fill="#F0B13D" opacity="0.35"/>
|
||||
</symbol>
|
||||
</defs>
|
||||
<rect width="920" height="260" fill="transparent"/>
|
||||
<use href="#kpg-icon" x="24" y="20" width="176" height="176"/>
|
||||
<text x="220" y="132" font-family="Inter, Noto Sans, Segoe UI, Arial, sans-serif" font-size="84" font-weight="650" fill="#3F4E63">KeePassGO</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.7 KiB |
@@ -0,0 +1,75 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="1280" height="800" viewBox="0 0 1280 800" fill="none">
|
||||
<title>KeePassGO splash screen</title>
|
||||
<desc>Light-theme desktop splash screen with structured panels and the KeePassGO logo.</desc>
|
||||
<defs>
|
||||
<symbol id="kpg-icon" viewBox="0 0 256 256">
|
||||
<polygon points="128,18 214,38 214,176 128,218 42,176 42,38" fill="#3F4E63"/>
|
||||
<polygon points="128,34 198,50 198,166 128,200 58,166 58,50" fill="#55657A" opacity="0.16"/>
|
||||
<polygon points="128,178 128,218 42,176 42,160" fill="#6D89A8"/>
|
||||
<rect x="44" y="78" width="62" height="10" rx="2" fill="#FFFFFF" opacity="0.92"/>
|
||||
<rect x="44" y="98" width="52" height="10" rx="2" fill="#FFFFFF" opacity="0.92"/>
|
||||
<path d="M100 86C100 70.536 112.536 58 128 58C143.464 58 156 70.536 156 86V106H142V86C142 78.268 135.732 72 128 72C120.268 72 114 78.268 114 86V106H100V86Z" fill="#FFFFFF"/>
|
||||
<rect x="76" y="102" width="104" height="64" rx="10" fill="#FFFFFF"/>
|
||||
<rect x="80" y="106" width="96" height="56" rx="8" fill="#E9EDF0"/>
|
||||
<path d="M128 118C139.046 118 148 126.954 148 138C148 145.669 143.68 152.329 137.338 155.7V173H118.662V155.7C112.32 152.329 108 145.669 108 138C108 126.954 116.954 118 128 118Z" fill="#3F4E63"/>
|
||||
<path d="M178 130H214V150H166V144L178 130Z" fill="#E79A17"/>
|
||||
<path d="M178 130H214V140H174L166 144V144L178 130Z" fill="#F0B13D" opacity="0.35"/>
|
||||
</symbol>
|
||||
<linearGradient id="fade" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0" stop-color="#D9E0E6" stop-opacity="0.9"/>
|
||||
<stop offset="1" stop-color="#D9E0E6" stop-opacity="0.2"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
|
||||
<rect width="1280" height="800" fill="#F7F7F5"/>
|
||||
|
||||
<g opacity="0.9">
|
||||
<rect x="48" y="48" width="238" height="188" fill="#EEF2F4"/>
|
||||
<rect x="286" y="48" width="152" height="104" fill="#F7F7F5" stroke="#C7D0D8"/>
|
||||
<rect x="438" y="48" width="214" height="104" fill="#E7EDF1"/>
|
||||
<rect x="652" y="48" width="146" height="188" fill="#F7F7F5" stroke="#C7D0D8"/>
|
||||
<rect x="798" y="48" width="224" height="118" fill="#EEF2F4"/>
|
||||
<rect x="1022" y="48" width="210" height="188" fill="#F7F7F5" stroke="#C7D0D8"/>
|
||||
|
||||
<rect x="48" y="236" width="128" height="164" fill="#F7F7F5" stroke="#C7D0D8"/>
|
||||
<rect x="176" y="236" width="262" height="164" fill="#E7EDF1"/>
|
||||
<rect x="438" y="236" width="160" height="84" fill="#F7F7F5" stroke="#C7D0D8"/>
|
||||
<rect x="598" y="236" width="200" height="164" fill="#F7F7F5" stroke="#C7D0D8"/>
|
||||
<rect x="798" y="236" width="434" height="164" fill="#EEF2F4"/>
|
||||
|
||||
<rect x="48" y="400" width="224" height="128" fill="#EEF2F4"/>
|
||||
<rect x="272" y="400" width="166" height="128" fill="#F7F7F5" stroke="#C7D0D8"/>
|
||||
<rect x="438" y="400" width="360" height="128" fill="#F7F7F5" stroke="#C7D0D8"/>
|
||||
<rect x="798" y="400" width="152" height="128" fill="#E7EDF1"/>
|
||||
<rect x="950" y="400" width="282" height="128" fill="#F7F7F5" stroke="#C7D0D8"/>
|
||||
|
||||
<rect x="48" y="528" width="114" height="224" fill="#F7F7F5" stroke="#C7D0D8"/>
|
||||
<rect x="162" y="528" width="276" height="224" fill="#E7EDF1"/>
|
||||
<rect x="438" y="528" width="212" height="224" fill="#F7F7F5" stroke="#C7D0D8"/>
|
||||
<rect x="650" y="528" width="318" height="224" fill="#EEF2F4"/>
|
||||
<rect x="968" y="528" width="264" height="224" fill="#F7F7F5" stroke="#C7D0D8"/>
|
||||
</g>
|
||||
|
||||
<g opacity="0.45">
|
||||
<path d="M48 152H1232" stroke="#C7D0D8"/>
|
||||
<path d="M48 320H1232" stroke="#C7D0D8"/>
|
||||
<path d="M48 528H1232" stroke="#C7D0D8"/>
|
||||
<path d="M176 48V752" stroke="#C7D0D8"/>
|
||||
<path d="M438 48V752" stroke="#C7D0D8"/>
|
||||
<path d="M798 48V752" stroke="#C7D0D8"/>
|
||||
<path d="M1022 48V752" stroke="#C7D0D8"/>
|
||||
</g>
|
||||
|
||||
<rect x="290" y="190" width="700" height="420" rx="18" fill="#F7F7F5" opacity="0.92"/>
|
||||
<rect x="290" y="190" width="700" height="420" rx="18" stroke="#C7D0D8"/>
|
||||
<rect x="290" y="190" width="700" height="120" rx="18" fill="url(#fade)"/>
|
||||
|
||||
<use href="#kpg-icon" x="530" y="232" width="220" height="220"/>
|
||||
<text x="640" y="530" text-anchor="middle" font-family="Inter, Noto Sans, Segoe UI, Arial, sans-serif" font-size="88" font-weight="650" fill="#3F4E63">KeePassGO</text>
|
||||
<text x="640" y="582" text-anchor="middle" font-family="Inter, Noto Sans, Segoe UI, Arial, sans-serif" font-size="28" font-weight="500" fill="#55657A">KeePass-compatible password manager</text>
|
||||
|
||||
<g opacity="0.9">
|
||||
<rect x="516" y="634" width="248" height="10" rx="5" fill="#D9E0E6"/>
|
||||
<rect x="516" y="634" width="132" height="10" rx="5" fill="#6D89A8"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.5 KiB |
|
After Width: | Height: | Size: 47 KiB |
@@ -0,0 +1,41 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="1024" height="1024" viewBox="0 0 1024 1024" fill="none">
|
||||
<title>KeePassGO square splash preview</title>
|
||||
<desc>Square crop of the KeePassGO splash system.</desc>
|
||||
<defs>
|
||||
<symbol id="kpg-icon" viewBox="0 0 256 256">
|
||||
<polygon points="128,18 214,38 214,176 128,218 42,176 42,38" fill="#3F4E63"/>
|
||||
<polygon points="128,34 198,50 198,166 128,200 58,166 58,50" fill="#55657A" opacity="0.16"/>
|
||||
<polygon points="128,178 128,218 42,176 42,160" fill="#6D89A8"/>
|
||||
<rect x="44" y="78" width="62" height="10" rx="2" fill="#FFFFFF" opacity="0.92"/>
|
||||
<rect x="44" y="98" width="52" height="10" rx="2" fill="#FFFFFF" opacity="0.92"/>
|
||||
<path d="M100 86C100 70.536 112.536 58 128 58C143.464 58 156 70.536 156 86V106H142V86C142 78.268 135.732 72 128 72C120.268 72 114 78.268 114 86V106H100V86Z" fill="#FFFFFF"/>
|
||||
<rect x="76" y="102" width="104" height="64" rx="10" fill="#FFFFFF"/>
|
||||
<rect x="80" y="106" width="96" height="56" rx="8" fill="#E9EDF0"/>
|
||||
<path d="M128 118C139.046 118 148 126.954 148 138C148 145.669 143.68 152.329 137.338 155.7V173H118.662V155.7C112.32 152.329 108 145.669 108 138C108 126.954 116.954 118 128 118Z" fill="#3F4E63"/>
|
||||
<path d="M178 130H214V150H166V144L178 130Z" fill="#E79A17"/>
|
||||
<path d="M178 130H214V140H174L166 144V144L178 130Z" fill="#F0B13D" opacity="0.35"/>
|
||||
</symbol>
|
||||
</defs>
|
||||
|
||||
<rect width="1024" height="1024" fill="#F7F7F5"/>
|
||||
<g opacity="0.9">
|
||||
<rect x="32" y="32" width="180" height="184" fill="#EEF2F4"/>
|
||||
<rect x="212" y="32" width="264" height="140" fill="#F7F7F5" stroke="#C7D0D8"/>
|
||||
<rect x="476" y="32" width="180" height="184" fill="#E7EDF1"/>
|
||||
<rect x="656" y="32" width="336" height="140" fill="#F7F7F5" stroke="#C7D0D8"/>
|
||||
<rect x="656" y="172" width="336" height="180" fill="#EEF2F4"/>
|
||||
<rect x="32" y="216" width="236" height="210" fill="#F7F7F5" stroke="#C7D0D8"/>
|
||||
<rect x="268" y="216" width="388" height="210" fill="#EEF2F4"/>
|
||||
<rect x="32" y="426" width="180" height="268" fill="#E7EDF1"/>
|
||||
<rect x="212" y="426" width="312" height="268" fill="#F7F7F5" stroke="#C7D0D8"/>
|
||||
<rect x="524" y="426" width="468" height="268" fill="#F7F7F5" stroke="#C7D0D8"/>
|
||||
<rect x="32" y="694" width="280" height="298" fill="#F7F7F5" stroke="#C7D0D8"/>
|
||||
<rect x="312" y="694" width="268" height="298" fill="#EEF2F4"/>
|
||||
<rect x="580" y="694" width="412" height="298" fill="#F7F7F5" stroke="#C7D0D8"/>
|
||||
</g>
|
||||
<rect x="162" y="190" width="700" height="644" rx="24" fill="#F7F7F5" opacity="0.95"/>
|
||||
<rect x="162" y="190" width="700" height="644" rx="24" stroke="#C7D0D8"/>
|
||||
<use href="#kpg-icon" x="332" y="252" width="360" height="360"/>
|
||||
<text x="512" y="680" text-anchor="middle" font-family="Inter, Noto Sans, Segoe UI, Arial, sans-serif" font-size="88" font-weight="650" fill="#3F4E63">KeePassGO</text>
|
||||
<text x="512" y="740" text-anchor="middle" font-family="Inter, Noto Sans, Segoe UI, Arial, sans-serif" font-size="28" font-weight="500" fill="#55657A">KeePass-compatible password manager</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.0 KiB |
@@ -0,0 +1,296 @@
|
||||
package autofillcache
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.julianfamily.org/keepassgo/internal/vault"
|
||||
)
|
||||
|
||||
type Entry struct {
|
||||
ID string `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
URL string `json:"url"`
|
||||
Host string `json:"host"`
|
||||
Targets []string `json:"targets,omitempty"`
|
||||
Path []string `json:"path,omitempty"`
|
||||
}
|
||||
|
||||
type File struct {
|
||||
UpdatedAt string `json:"updatedAt"`
|
||||
Entries []Entry `json:"entries"`
|
||||
}
|
||||
|
||||
type MatchStatus string
|
||||
|
||||
const (
|
||||
MatchStatusNone MatchStatus = ""
|
||||
MatchStatusFound MatchStatus = "found"
|
||||
MatchStatusAmbiguous MatchStatus = "ambiguous"
|
||||
MatchStatusMissing MatchStatus = "missing"
|
||||
)
|
||||
|
||||
type MatchResult struct {
|
||||
Status MatchStatus `json:"status"`
|
||||
Entry Entry `json:"entry,omitempty"`
|
||||
}
|
||||
|
||||
func Match(cache File, webURL string) (Entry, bool) {
|
||||
result := Resolve(cache, webURL)
|
||||
return result.Entry, result.Status == MatchStatusFound
|
||||
}
|
||||
|
||||
func Resolve(cache File, webURL string) MatchResult {
|
||||
target := normalizeURL(webURL)
|
||||
if target.host == "" {
|
||||
return MatchResult{Status: MatchStatusMissing}
|
||||
}
|
||||
|
||||
exactHost := make([]Entry, 0)
|
||||
parentHost := make([]Entry, 0)
|
||||
for _, entry := range cache.Entries {
|
||||
if entryMatchesHost(entry, target.host) {
|
||||
exactHost = append(exactHost, entry)
|
||||
continue
|
||||
}
|
||||
if entryMatchesParentHost(entry, target.host) {
|
||||
parentHost = append(parentHost, entry)
|
||||
}
|
||||
}
|
||||
|
||||
if result := chooseEntry(target, exactHost); result.Status != MatchStatusMissing {
|
||||
return result
|
||||
}
|
||||
return chooseEntry(target, parentHost)
|
||||
}
|
||||
|
||||
func Build(model vault.Model, now time.Time) File {
|
||||
entries := make([]Entry, 0, len(model.Entries))
|
||||
for _, item := range model.Entries {
|
||||
targets := collectTargets(item)
|
||||
host := normalizeHost(item.URL)
|
||||
if host == "" {
|
||||
for _, target := range targets {
|
||||
host = normalizeHost(target)
|
||||
if host != "" {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if host == "" {
|
||||
continue
|
||||
}
|
||||
if strings.TrimSpace(item.Username) == "" || strings.TrimSpace(item.Password) == "" {
|
||||
continue
|
||||
}
|
||||
entries = append(entries, Entry{
|
||||
ID: item.ID,
|
||||
Title: item.Title,
|
||||
Username: item.Username,
|
||||
Password: item.Password,
|
||||
URL: item.URL,
|
||||
Host: host,
|
||||
Targets: targets,
|
||||
Path: append([]string(nil), item.Path...),
|
||||
})
|
||||
}
|
||||
return File{
|
||||
UpdatedAt: now.UTC().Format(time.RFC3339),
|
||||
Entries: entries,
|
||||
}
|
||||
}
|
||||
|
||||
func Write(path string, model vault.Model, now time.Time) error {
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
data, err := json.MarshalIndent(Build(model, now), "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(path, data, 0o600)
|
||||
}
|
||||
|
||||
func Clear(path string) error {
|
||||
if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func normalizeHost(raw string) string {
|
||||
return normalizeURL(raw).host
|
||||
}
|
||||
|
||||
type normalizedTarget struct {
|
||||
host string
|
||||
path string
|
||||
url string
|
||||
}
|
||||
|
||||
func normalizeURL(raw string) normalizedTarget {
|
||||
value := strings.TrimSpace(raw)
|
||||
if value == "" {
|
||||
return normalizedTarget{}
|
||||
}
|
||||
if !strings.Contains(value, "://") {
|
||||
value = "https://" + value
|
||||
}
|
||||
parsed, err := url.Parse(value)
|
||||
if err != nil {
|
||||
return normalizedTarget{}
|
||||
}
|
||||
host := strings.TrimSpace(parsed.Hostname())
|
||||
path := cleanPath(parsed.EscapedPath())
|
||||
return normalizedTarget{
|
||||
host: strings.ToLower(host),
|
||||
path: path,
|
||||
url: strings.ToLower(host) + path,
|
||||
}
|
||||
}
|
||||
|
||||
func cleanPath(path string) string {
|
||||
path = strings.TrimSpace(path)
|
||||
if path == "" || path == "/" {
|
||||
return "/"
|
||||
}
|
||||
path = strings.TrimRight(path, "/")
|
||||
if path == "" {
|
||||
return "/"
|
||||
}
|
||||
if !strings.HasPrefix(path, "/") {
|
||||
path = "/" + path
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
func chooseEntry(target normalizedTarget, entries []Entry) MatchResult {
|
||||
switch len(entries) {
|
||||
case 0:
|
||||
return MatchResult{Status: MatchStatusMissing}
|
||||
case 1:
|
||||
return MatchResult{Status: MatchStatusFound, Entry: entries[0]}
|
||||
}
|
||||
|
||||
exact := make([]Entry, 0)
|
||||
bestPrefixLen := -1
|
||||
bestPrefix := make([]Entry, 0)
|
||||
for _, entry := range entries {
|
||||
exactMatch, prefixLen := bestTargetMatch(entry, target)
|
||||
if exactMatch {
|
||||
exact = append(exact, entry)
|
||||
continue
|
||||
}
|
||||
if prefixLen <= 0 {
|
||||
continue
|
||||
}
|
||||
switch {
|
||||
case prefixLen > bestPrefixLen:
|
||||
bestPrefixLen = prefixLen
|
||||
bestPrefix = []Entry{entry}
|
||||
case prefixLen == bestPrefixLen:
|
||||
bestPrefix = append(bestPrefix, entry)
|
||||
}
|
||||
}
|
||||
if len(exact) == 1 {
|
||||
return MatchResult{Status: MatchStatusFound, Entry: exact[0]}
|
||||
}
|
||||
if len(exact) > 1 {
|
||||
return MatchResult{Status: MatchStatusAmbiguous}
|
||||
}
|
||||
if len(bestPrefix) == 1 {
|
||||
return MatchResult{Status: MatchStatusFound, Entry: bestPrefix[0]}
|
||||
}
|
||||
if len(bestPrefix) == 0 {
|
||||
return MatchResult{Status: MatchStatusMissing}
|
||||
}
|
||||
return MatchResult{Status: MatchStatusAmbiguous}
|
||||
}
|
||||
|
||||
func collectTargets(item vault.Entry) []string {
|
||||
seen := make(map[string]struct{})
|
||||
targets := make([]string, 0, 1+len(item.Fields))
|
||||
appendTarget := func(raw string) {
|
||||
value := strings.TrimSpace(raw)
|
||||
if value == "" {
|
||||
return
|
||||
}
|
||||
if _, ok := seen[value]; ok {
|
||||
return
|
||||
}
|
||||
seen[value] = struct{}{}
|
||||
targets = append(targets, value)
|
||||
}
|
||||
|
||||
appendTarget(item.URL)
|
||||
|
||||
keys := make([]string, 0, len(item.Fields))
|
||||
for key := range item.Fields {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
for _, key := range keys {
|
||||
upper := strings.ToUpper(strings.TrimSpace(key))
|
||||
if strings.HasPrefix(upper, "ANDROIDAPP") || strings.HasPrefix(upper, "KP2A_URL") {
|
||||
appendTarget(item.Fields[key])
|
||||
}
|
||||
}
|
||||
|
||||
return targets
|
||||
}
|
||||
|
||||
func entryTargets(entry Entry) []normalizedTarget {
|
||||
values := entry.Targets
|
||||
if len(values) == 0 {
|
||||
values = []string{entry.URL}
|
||||
}
|
||||
targets := make([]normalizedTarget, 0, len(values))
|
||||
for _, value := range values {
|
||||
target := normalizeURL(value)
|
||||
if target.host == "" {
|
||||
continue
|
||||
}
|
||||
targets = append(targets, target)
|
||||
}
|
||||
return targets
|
||||
}
|
||||
|
||||
func entryMatchesHost(entry Entry, host string) bool {
|
||||
for _, target := range entryTargets(entry) {
|
||||
if target.host == host {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func entryMatchesParentHost(entry Entry, host string) bool {
|
||||
for _, target := range entryTargets(entry) {
|
||||
if target.host != "" && strings.HasSuffix(host, "."+target.host) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func bestTargetMatch(entry Entry, target normalizedTarget) (bool, int) {
|
||||
bestPrefixLen := -1
|
||||
for _, candidate := range entryTargets(entry) {
|
||||
if candidate.url == target.url {
|
||||
return true, 0
|
||||
}
|
||||
if candidate.path != "/" && strings.HasPrefix(target.path, candidate.path) {
|
||||
if pathLen := len(candidate.path); pathLen > bestPrefixLen {
|
||||
bestPrefixLen = pathLen
|
||||
}
|
||||
}
|
||||
}
|
||||
return false, bestPrefixLen
|
||||
}
|
||||
@@ -0,0 +1,339 @@
|
||||
package autofillcache
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.julianfamily.org/keepassgo/internal/vault"
|
||||
)
|
||||
|
||||
func TestBuildFiltersAndNormalizesEntries(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
now := time.Date(2026, time.March, 31, 12, 0, 0, 0, time.UTC)
|
||||
got := Build(vault.Model{
|
||||
Entries: []vault.Entry{
|
||||
{
|
||||
ID: "one",
|
||||
Title: "Chrome Test",
|
||||
Username: "joe",
|
||||
Password: "secret",
|
||||
URL: "https://10.0.2.2:8443/login",
|
||||
Path: []string{"Crew", "Internet"},
|
||||
},
|
||||
{
|
||||
ID: "two",
|
||||
Title: "No Password",
|
||||
Username: "joe",
|
||||
URL: "https://example.com",
|
||||
},
|
||||
{
|
||||
ID: "three",
|
||||
Title: "Bare Host",
|
||||
Username: "user",
|
||||
Password: "pass",
|
||||
URL: "surveillance.crew.example.invalid",
|
||||
Fields: map[string]string{
|
||||
"AndroidApp1": "androidapp://com.lights.mobile",
|
||||
"KP2A_URL_1": "https://surveillance.crew.example.invalid/account",
|
||||
},
|
||||
},
|
||||
},
|
||||
}, now)
|
||||
|
||||
if len(got.Entries) != 2 {
|
||||
t.Fatalf("entry count = %d, want 2", len(got.Entries))
|
||||
}
|
||||
if got.Entries[0].Host != "10.0.2.2" {
|
||||
t.Fatalf("first host = %q, want 10.0.2.2", got.Entries[0].Host)
|
||||
}
|
||||
if got.Entries[1].Host != "surveillance.crew.example.invalid" {
|
||||
t.Fatalf("second host = %q, want surveillance.crew.example.invalid", got.Entries[1].Host)
|
||||
}
|
||||
if len(got.Entries[1].Targets) != 3 {
|
||||
t.Fatalf("len(second targets) = %d, want 3", len(got.Entries[1].Targets))
|
||||
}
|
||||
if got.UpdatedAt != "2026-03-31T12:00:00Z" {
|
||||
t.Fatalf("updatedAt = %q", got.UpdatedAt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteAndClear(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "autofill-cache.json")
|
||||
model := vault.Model{
|
||||
Entries: []vault.Entry{
|
||||
{ID: "one", Title: "Chrome Test", Username: "joe", Password: "secret", URL: "https://10.0.2.2:8443/login"},
|
||||
},
|
||||
}
|
||||
|
||||
if err := Write(path, model, time.Date(2026, time.March, 31, 12, 0, 0, 0, time.UTC)); err != nil {
|
||||
t.Fatalf("Write() error = %v", err)
|
||||
}
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile() error = %v", err)
|
||||
}
|
||||
var got File
|
||||
if err := json.Unmarshal(data, &got); err != nil {
|
||||
t.Fatalf("Unmarshal() error = %v", err)
|
||||
}
|
||||
if len(got.Entries) != 1 || got.Entries[0].Host != "10.0.2.2" {
|
||||
t.Fatalf("cache entries = %#v", got.Entries)
|
||||
}
|
||||
if err := Clear(path); err != nil {
|
||||
t.Fatalf("Clear() error = %v", err)
|
||||
}
|
||||
if _, err := os.Stat(path); !os.IsNotExist(err) {
|
||||
t.Fatalf("cache path still exists, stat err = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMatchChoosesExactURLWhenHostsRepeat(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cache := File{
|
||||
Entries: []Entry{
|
||||
{
|
||||
ID: "one",
|
||||
Title: "Primary Login",
|
||||
Username: "first",
|
||||
Password: "secret1",
|
||||
URL: "https://10.0.2.2:8443/login/",
|
||||
Host: "10.0.2.2",
|
||||
},
|
||||
{
|
||||
ID: "two",
|
||||
Title: "Alt Login",
|
||||
Username: "second",
|
||||
Password: "secret2",
|
||||
URL: "https://10.0.2.2:8443/alt/",
|
||||
Host: "10.0.2.2",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
got, ok := Match(cache, "https://10.0.2.2:8443/alt/")
|
||||
if !ok {
|
||||
t.Fatalf("Match() found no entry")
|
||||
}
|
||||
if got.ID != "two" {
|
||||
t.Fatalf("Match() entry = %q, want two", got.ID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMatchRejectsAmbiguousSharedHost(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cache := File{
|
||||
Entries: []Entry{
|
||||
{
|
||||
ID: "one",
|
||||
Title: "Host A",
|
||||
Username: "first",
|
||||
Password: "secret1",
|
||||
URL: "https://surveillance.crew.example.invalid/",
|
||||
Host: "surveillance.crew.example.invalid",
|
||||
},
|
||||
{
|
||||
ID: "two",
|
||||
Title: "Host B",
|
||||
Username: "second",
|
||||
Password: "secret2",
|
||||
URL: "https://surveillance.crew.example.invalid/",
|
||||
Host: "surveillance.crew.example.invalid",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
if _, ok := Match(cache, "https://surveillance.crew.example.invalid/"); ok {
|
||||
t.Fatalf("Match() unexpectedly resolved ambiguous shared host")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveReportsFoundAmbiguousAndMissingStatuses(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cache := File{
|
||||
Entries: []Entry{
|
||||
{
|
||||
ID: "one",
|
||||
Title: "Admin Login",
|
||||
Username: "admin",
|
||||
Password: "secret1",
|
||||
URL: "https://example.com/admin",
|
||||
Host: "example.com",
|
||||
},
|
||||
{
|
||||
ID: "two",
|
||||
Title: "Shared Login A",
|
||||
Username: "shared-a",
|
||||
Password: "secret2",
|
||||
URL: "https://shared.example.com",
|
||||
Host: "shared.example.com",
|
||||
},
|
||||
{
|
||||
ID: "three",
|
||||
Title: "Shared Login B",
|
||||
Username: "shared-b",
|
||||
Password: "secret3",
|
||||
URL: "https://shared.example.com",
|
||||
Host: "shared.example.com",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
if got := Resolve(cache, "https://example.com/admin/login"); got.Status != MatchStatusFound || got.Entry.ID != "one" {
|
||||
t.Fatalf("Resolve(found) = %#v, want found entry one", got)
|
||||
}
|
||||
if got := Resolve(cache, "https://shared.example.com"); got.Status != MatchStatusAmbiguous {
|
||||
t.Fatalf("Resolve(ambiguous) = %#v, want ambiguous", got)
|
||||
}
|
||||
if got := Resolve(cache, "https://nowhere.invalid"); got.Status != MatchStatusMissing {
|
||||
t.Fatalf("Resolve(missing) = %#v, want missing", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMatchChoosesLongestPathPrefix(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cache := File{
|
||||
Entries: []Entry{
|
||||
{
|
||||
ID: "one",
|
||||
Title: "Generic Login",
|
||||
Username: "generic",
|
||||
Password: "secret1",
|
||||
URL: "https://example.com/",
|
||||
Host: "example.com",
|
||||
},
|
||||
{
|
||||
ID: "two",
|
||||
Title: "Admin Login",
|
||||
Username: "admin",
|
||||
Password: "secret2",
|
||||
URL: "https://example.com/admin",
|
||||
Host: "example.com",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
got, ok := Match(cache, "https://example.com/admin/login")
|
||||
if !ok {
|
||||
t.Fatalf("Match() found no entry")
|
||||
}
|
||||
if got.ID != "two" {
|
||||
t.Fatalf("Match() entry = %q, want two", got.ID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMatchSupportsAndroidAppPackageTargets(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cache := File{
|
||||
Entries: []Entry{
|
||||
{
|
||||
ID: "one",
|
||||
Title: "Thunderbird",
|
||||
Username: "mail-user",
|
||||
Password: "secret1",
|
||||
URL: "androidapp://org.mozilla.thunderbird/login",
|
||||
Host: "org.mozilla.thunderbird",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
got, ok := Match(cache, "androidapp://org.mozilla.thunderbird")
|
||||
if !ok {
|
||||
t.Fatalf("Match() found no entry")
|
||||
}
|
||||
if got.ID != "one" {
|
||||
t.Fatalf("Match() entry = %q, want one", got.ID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMatchRejectsAmbiguousAndroidAppPackageTargets(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cache := File{
|
||||
Entries: []Entry{
|
||||
{
|
||||
ID: "one",
|
||||
Title: "Thunderbird Primary",
|
||||
Username: "mail-user",
|
||||
Password: "secret1",
|
||||
URL: "androidapp://org.mozilla.thunderbird",
|
||||
Host: "org.mozilla.thunderbird",
|
||||
},
|
||||
{
|
||||
ID: "two",
|
||||
Title: "Thunderbird Secondary",
|
||||
Username: "other-user",
|
||||
Password: "secret2",
|
||||
URL: "androidapp://org.mozilla.thunderbird",
|
||||
Host: "org.mozilla.thunderbird",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
if _, ok := Match(cache, "androidapp://org.mozilla.thunderbird"); ok {
|
||||
t.Fatalf("Match() unexpectedly resolved ambiguous android app package target")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMatchUsesAndroidAppCustomFieldTarget(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cache := File{
|
||||
Entries: []Entry{
|
||||
{
|
||||
ID: "one",
|
||||
Title: "Blink",
|
||||
Username: "blink-user",
|
||||
Password: "secret1",
|
||||
URL: "https://account.blinknetwork.com",
|
||||
Host: "account.blinknetwork.com",
|
||||
Targets: []string{"https://account.blinknetwork.com", "androidapp://com.blinknetwork.mobile2"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
got, ok := Match(cache, "androidapp://com.blinknetwork.mobile2")
|
||||
if !ok {
|
||||
t.Fatalf("Match() found no entry")
|
||||
}
|
||||
if got.ID != "one" {
|
||||
t.Fatalf("Match() entry = %q, want one", got.ID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMatchUsesKP2AURLCustomFieldTarget(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cache := File{
|
||||
Entries: []Entry{
|
||||
{
|
||||
ID: "one",
|
||||
Title: "Blink",
|
||||
Username: "blink-user",
|
||||
Password: "secret1",
|
||||
URL: "https://blinknetwork.com",
|
||||
Host: "blinknetwork.com",
|
||||
Targets: []string{"https://blinknetwork.com", "https://account.blinknetwork.com"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
got, ok := Match(cache, "https://account.blinknetwork.com")
|
||||
if !ok {
|
||||
t.Fatalf("Match() found no entry")
|
||||
}
|
||||
if got.ID != "one" {
|
||||
t.Fatalf("Match() entry = %q, want one", got.ID)
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,7 @@ import (
|
||||
|
||||
systemclipboard "github.com/atotto/clipboard"
|
||||
|
||||
"git.julianfamily.org/keepassgo/vault"
|
||||
"git.julianfamily.org/keepassgo/internal/vault"
|
||||
)
|
||||
|
||||
var ErrUnsupportedTarget = errors.New("unsupported clipboard target")
|
||||
@@ -52,6 +52,10 @@ func (s Service) writer() Writer {
|
||||
return systemWriter{}
|
||||
}
|
||||
|
||||
func WriteText(text string) error {
|
||||
return systemWriter{}.WriteText(text)
|
||||
}
|
||||
|
||||
func findEntry(model vault.Model, entryID string) (vault.Entry, error) {
|
||||
for _, entry := range model.Entries {
|
||||
if entry.ID == entryID {
|
||||
@@ -4,7 +4,7 @@ import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"git.julianfamily.org/keepassgo/vault"
|
||||
"git.julianfamily.org/keepassgo/internal/vault"
|
||||
)
|
||||
|
||||
func TestServiceCopiesUsernamePasswordAndURL(t *testing.T) {
|
||||
@@ -13,11 +13,11 @@ func TestServiceCopiesUsernamePasswordAndURL(t *testing.T) {
|
||||
model := vault.Model{
|
||||
Entries: []vault.Entry{
|
||||
{
|
||||
ID: "git-server",
|
||||
Title: "Git Server",
|
||||
Username: "joejulian",
|
||||
ID: "vault-console",
|
||||
Title: "Vault Console",
|
||||
Username: "dannyocean",
|
||||
Password: "token-1",
|
||||
URL: "https://git.julianfamily.org",
|
||||
URL: "https://vault.crew.example.invalid",
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -27,9 +27,9 @@ func TestServiceCopiesUsernamePasswordAndURL(t *testing.T) {
|
||||
target Target
|
||||
want string
|
||||
}{
|
||||
{name: "username", target: TargetUsername, want: "joejulian"},
|
||||
{name: "username", target: TargetUsername, want: "dannyocean"},
|
||||
{name: "password", target: TargetPassword, want: "token-1"},
|
||||
{name: "url", target: TargetURL, want: "https://git.julianfamily.org"},
|
||||
{name: "url", target: TargetURL, want: "https://vault.crew.example.invalid"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
@@ -37,7 +37,7 @@ func TestServiceCopiesUsernamePasswordAndURL(t *testing.T) {
|
||||
var writer memoryWriter
|
||||
service := Service{Writer: &writer}
|
||||
|
||||
if err := service.Copy(model, "git-server", tt.target); err != nil {
|
||||
if err := service.Copy(model, "vault-console", tt.target); err != nil {
|
||||
t.Fatalf("Copy() error = %v", err)
|
||||
}
|
||||
|
||||
@@ -60,9 +60,9 @@ func TestServiceRejectsUnknownEntryAndUnsupportedTarget(t *testing.T) {
|
||||
}
|
||||
|
||||
model := vault.Model{
|
||||
Entries: []vault.Entry{{ID: "git-server", Username: "joejulian"}},
|
||||
Entries: []vault.Entry{{ID: "vault-console", Username: "dannyocean"}},
|
||||
}
|
||||
err = service.Copy(model, "git-server", Target("unsupported"))
|
||||
err = service.Copy(model, "vault-console", Target("unsupported"))
|
||||
if !errors.Is(err, ErrUnsupportedTarget) {
|
||||
t.Fatalf("Copy() unsupported target error = %v, want ErrUnsupportedTarget", err)
|
||||
}
|
||||
@@ -74,11 +74,11 @@ func TestServiceSanitizesClipboardWriteErrors(t *testing.T) {
|
||||
service := Service{Writer: failingWriter{err: errors.New("backend refused token-1")}}
|
||||
model := vault.Model{
|
||||
Entries: []vault.Entry{
|
||||
{ID: "git-server", Password: "token-1"},
|
||||
{ID: "vault-console", Password: "token-1"},
|
||||
},
|
||||
}
|
||||
|
||||
err := service.Copy(model, "git-server", TargetPassword)
|
||||
err := service.Copy(model, "vault-console", TargetPassword)
|
||||
if !errors.Is(err, ErrWriteFailed) {
|
||||
t.Fatalf("Copy() write error = %v, want ErrWriteFailed", err)
|
||||
}
|
||||
@@ -4,14 +4,15 @@ import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"git.julianfamily.org/keepassgo/vault"
|
||||
"git.julianfamily.org/keepassgo/webdav"
|
||||
"git.julianfamily.org/keepassgo/internal/vault"
|
||||
"git.julianfamily.org/keepassgo/internal/webdav"
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -32,6 +33,46 @@ type Manager struct {
|
||||
remoteVersion webdav.Version
|
||||
}
|
||||
|
||||
type PreparedLocalOpen struct {
|
||||
Model vault.Model
|
||||
Config *vault.KDBXConfig
|
||||
Path string
|
||||
Key vault.MasterKey
|
||||
Encoded []byte
|
||||
VaultRoot string
|
||||
}
|
||||
|
||||
type PreparedRemoteOpen struct {
|
||||
Model vault.Model
|
||||
Config *vault.KDBXConfig
|
||||
Client webdav.Client
|
||||
Path string
|
||||
Key vault.MasterKey
|
||||
Encoded []byte
|
||||
VaultRoot string
|
||||
RemoteVersion webdav.Version
|
||||
}
|
||||
|
||||
type PreparedUnlock struct {
|
||||
Model vault.Model
|
||||
Config *vault.KDBXConfig
|
||||
Key vault.MasterKey
|
||||
VaultRoot string
|
||||
}
|
||||
|
||||
func (m *Manager) SecuritySettings() vault.SecuritySettings {
|
||||
return vault.DetectSecuritySettings(m.config)
|
||||
}
|
||||
|
||||
func (m *Manager) ConfigureSecurity(settings vault.SecuritySettings) error {
|
||||
config, err := vault.ApplySecuritySettings(configOrCurrent(m.config, nil), settings)
|
||||
if err != nil {
|
||||
return fmt.Errorf("configure security settings: %w", err)
|
||||
}
|
||||
m.config = config
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Manager) Create(model vault.Model, key vault.MasterKey) error {
|
||||
root := detectSingleVaultRoot(model)
|
||||
model = normalizeUnderRoot(model, root)
|
||||
@@ -52,6 +93,10 @@ func (m *Manager) HasVault() bool {
|
||||
return len(m.encoded) > 0 || m.path != "" || m.remotePath != ""
|
||||
}
|
||||
|
||||
func (m *Manager) EncodedBytes() []byte {
|
||||
return append([]byte(nil), m.encoded...)
|
||||
}
|
||||
|
||||
func (m *Manager) IsLocked() bool {
|
||||
return m.locked
|
||||
}
|
||||
@@ -61,23 +106,11 @@ func (m *Manager) IsRemote() bool {
|
||||
}
|
||||
|
||||
func (m *Manager) Open(path string, key vault.MasterKey) error {
|
||||
content, err := os.ReadFile(path)
|
||||
prepared, err := PrepareLocalOpen(path, key)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read %s: %w", path, err)
|
||||
return err
|
||||
}
|
||||
|
||||
model, config, err := vault.LoadKDBXWithConfig(bytes.NewReader(content), key)
|
||||
if err != nil {
|
||||
return fmt.Errorf("open %s: %w", path, err)
|
||||
}
|
||||
|
||||
m.model = model
|
||||
m.config = config
|
||||
m.path = path
|
||||
m.key = key
|
||||
m.vaultRoot = detectSingleVaultRoot(model)
|
||||
m.encoded = content
|
||||
m.locked = false
|
||||
m.ApplyPreparedLocalOpen(prepared)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -94,25 +127,11 @@ func (m *Manager) Save() error {
|
||||
}
|
||||
|
||||
func (m *Manager) OpenRemote(client webdav.Client, path string, key vault.MasterKey) error {
|
||||
content, version, err := client.Open(path)
|
||||
prepared, err := PrepareRemoteOpen(client, path, key)
|
||||
if err != nil {
|
||||
return fmt.Errorf("open remote %s: %w", path, err)
|
||||
return err
|
||||
}
|
||||
|
||||
model, config, err := vault.LoadKDBXWithConfig(bytes.NewReader(content), key)
|
||||
if err != nil {
|
||||
return fmt.Errorf("decode remote %s: %w", path, err)
|
||||
}
|
||||
|
||||
m.model = model
|
||||
m.config = config
|
||||
m.key = key
|
||||
m.vaultRoot = detectSingleVaultRoot(model)
|
||||
m.encoded = content
|
||||
m.locked = false
|
||||
m.remoteClient = &client
|
||||
m.remotePath = path
|
||||
m.remoteVersion = version
|
||||
m.ApplyPreparedRemoteOpen(prepared)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -159,6 +178,18 @@ func (m *Manager) SynchronizeFromLocal(path string) error {
|
||||
return m.persistMergedToCurrentSource(merged)
|
||||
}
|
||||
|
||||
func (m *Manager) SynchronizeFromLocalBytes(name string, content []byte) error {
|
||||
other, _, err := loadLocalSourceBytes(name, content, m.key)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
merged, err := m.mergedWithPeer(other)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return m.persistMergedToCurrentSource(merged)
|
||||
}
|
||||
|
||||
func (m *Manager) SynchronizeToLocal(path string) error {
|
||||
other, config, err := loadLocalSourceOrEmpty(path, m.key)
|
||||
if err != nil {
|
||||
@@ -252,19 +283,101 @@ func (m *Manager) Lock() error {
|
||||
}
|
||||
|
||||
func (m *Manager) Unlock(key vault.MasterKey) error {
|
||||
model, config, err := vault.LoadKDBXWithConfig(bytes.NewReader(m.encoded), key)
|
||||
prepared, err := PrepareUnlock(m.encoded, key)
|
||||
if err != nil {
|
||||
return fmt.Errorf("unlock vault: %w", err)
|
||||
return err
|
||||
}
|
||||
|
||||
m.model = model
|
||||
m.config = config
|
||||
m.key = key
|
||||
m.vaultRoot = detectSingleVaultRoot(model)
|
||||
m.locked = false
|
||||
m.ApplyPreparedUnlock(prepared)
|
||||
return nil
|
||||
}
|
||||
|
||||
func PrepareLocalOpen(path string, key vault.MasterKey) (PreparedLocalOpen, error) {
|
||||
content, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return PreparedLocalOpen{}, fmt.Errorf("read %s: %w", path, err)
|
||||
}
|
||||
model, config, err := vault.LoadKDBXWithConfig(bytes.NewReader(content), key)
|
||||
if err != nil {
|
||||
return PreparedLocalOpen{}, fmt.Errorf("open %s: %w", path, err)
|
||||
}
|
||||
return PreparedLocalOpen{
|
||||
Model: model,
|
||||
Config: config,
|
||||
Path: path,
|
||||
Key: key,
|
||||
Encoded: content,
|
||||
VaultRoot: detectSingleVaultRoot(model),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func PrepareRemoteOpen(client webdav.Client, path string, key vault.MasterKey) (PreparedRemoteOpen, error) {
|
||||
content, version, err := client.Open(path)
|
||||
if err != nil {
|
||||
return PreparedRemoteOpen{}, fmt.Errorf("open remote %s: %w", path, err)
|
||||
}
|
||||
model, config, err := vault.LoadKDBXWithConfig(bytes.NewReader(content), key)
|
||||
if err != nil {
|
||||
return PreparedRemoteOpen{}, fmt.Errorf("decode remote %s: %w", path, err)
|
||||
}
|
||||
return PreparedRemoteOpen{
|
||||
Model: model,
|
||||
Config: config,
|
||||
Client: client,
|
||||
Path: path,
|
||||
Key: key,
|
||||
Encoded: content,
|
||||
VaultRoot: detectSingleVaultRoot(model),
|
||||
RemoteVersion: version,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func PrepareUnlock(encoded []byte, key vault.MasterKey) (PreparedUnlock, error) {
|
||||
model, config, err := vault.LoadKDBXWithConfig(bytes.NewReader(encoded), key)
|
||||
if err != nil {
|
||||
return PreparedUnlock{}, fmt.Errorf("unlock vault: %w", err)
|
||||
}
|
||||
return PreparedUnlock{
|
||||
Model: model,
|
||||
Config: config,
|
||||
Key: key,
|
||||
VaultRoot: detectSingleVaultRoot(model),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (m *Manager) ApplyPreparedLocalOpen(prepared PreparedLocalOpen) {
|
||||
m.model = prepared.Model
|
||||
m.config = prepared.Config
|
||||
m.path = prepared.Path
|
||||
m.key = prepared.Key
|
||||
m.vaultRoot = prepared.VaultRoot
|
||||
m.encoded = prepared.Encoded
|
||||
m.locked = false
|
||||
m.remoteClient = nil
|
||||
m.remotePath = ""
|
||||
m.remoteVersion = webdav.Version{}
|
||||
}
|
||||
|
||||
func (m *Manager) ApplyPreparedRemoteOpen(prepared PreparedRemoteOpen) {
|
||||
m.model = prepared.Model
|
||||
m.config = prepared.Config
|
||||
m.key = prepared.Key
|
||||
m.vaultRoot = prepared.VaultRoot
|
||||
m.encoded = prepared.Encoded
|
||||
m.locked = false
|
||||
m.remoteClient = &prepared.Client
|
||||
m.remotePath = prepared.Path
|
||||
m.remoteVersion = prepared.RemoteVersion
|
||||
m.path = ""
|
||||
}
|
||||
|
||||
func (m *Manager) ApplyPreparedUnlock(prepared PreparedUnlock) {
|
||||
m.model = prepared.Model
|
||||
m.config = prepared.Config
|
||||
m.key = prepared.Key
|
||||
m.vaultRoot = prepared.VaultRoot
|
||||
m.locked = false
|
||||
}
|
||||
|
||||
func (m *Manager) ChangeMasterKey(key vault.MasterKey) error {
|
||||
var (
|
||||
model vault.Model
|
||||
@@ -808,6 +921,17 @@ func loadLocalSource(path string, key vault.MasterKey) (vault.Model, *vault.KDBX
|
||||
return model, config, nil
|
||||
}
|
||||
|
||||
func loadLocalSourceBytes(name string, content []byte, key vault.MasterKey) (vault.Model, *vault.KDBXConfig, error) {
|
||||
if len(content) == 0 {
|
||||
return vault.Model{}, nil, fmt.Errorf("open %s for synchronize: %w", name, io.EOF)
|
||||
}
|
||||
model, config, err := vault.LoadKDBXWithConfig(bytes.NewReader(content), key)
|
||||
if err != nil {
|
||||
return vault.Model{}, nil, fmt.Errorf("decode %s for synchronize: %w", name, err)
|
||||
}
|
||||
return model, config, nil
|
||||
}
|
||||
|
||||
func loadLocalSourceOrEmpty(path string, key vault.MasterKey) (vault.Model, *vault.KDBXConfig, error) {
|
||||
model, config, err := loadLocalSource(path, key)
|
||||
if err == nil {
|
||||
@@ -10,8 +10,8 @@ import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"git.julianfamily.org/keepassgo/vault"
|
||||
"git.julianfamily.org/keepassgo/webdav"
|
||||
"git.julianfamily.org/keepassgo/internal/vault"
|
||||
"git.julianfamily.org/keepassgo/internal/webdav"
|
||||
"github.com/tobischo/gokeepasslib/v3"
|
||||
w "github.com/tobischo/gokeepasslib/v3/wrappers"
|
||||
)
|
||||
@@ -24,10 +24,10 @@ func TestCreateSaveAsLockAndUnlockRoundTripsVault(t *testing.T) {
|
||||
Entries: []vault.Entry{
|
||||
{
|
||||
ID: "entry-1",
|
||||
Title: "Git Server",
|
||||
Username: "joejulian",
|
||||
Title: "Vault Console",
|
||||
Username: "dannyocean",
|
||||
Password: "token-1",
|
||||
URL: "https://git.julianfamily.org",
|
||||
URL: "https://vault.crew.example.invalid",
|
||||
Path: []string{"Root", "Internet"},
|
||||
},
|
||||
},
|
||||
@@ -65,8 +65,8 @@ func TestCreateSaveAsLockAndUnlockRoundTripsVault(t *testing.T) {
|
||||
}
|
||||
|
||||
got := current.EntriesInPath([]string{"Root", "Internet"})
|
||||
if len(got) != 1 || got[0].Title != "Git Server" || got[0].Password != "token-1" {
|
||||
t.Fatalf("Current() entries = %#v, want persisted Git Server entry", got)
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -78,10 +78,10 @@ func TestOpenLoadsExistingKDBXFromDisk(t *testing.T) {
|
||||
Entries: []vault.Entry{
|
||||
{
|
||||
ID: "entry-1",
|
||||
Title: "Home Assistant (Codex)",
|
||||
Title: "Surveillance Console",
|
||||
Username: "codex",
|
||||
Password: "token-2",
|
||||
URL: "https://lights.julianfamily.org",
|
||||
URL: "https://surveillance.crew.example.invalid",
|
||||
Path: []string{"Root", "Home Assistant"},
|
||||
},
|
||||
},
|
||||
@@ -124,10 +124,10 @@ func TestSavePersistsEditsBackToCurrentPath(t *testing.T) {
|
||||
Entries: []vault.Entry{
|
||||
{
|
||||
ID: "entry-1",
|
||||
Title: "Git Server",
|
||||
Username: "joejulian",
|
||||
Title: "Vault Console",
|
||||
Username: "dannyocean",
|
||||
Password: "token-1",
|
||||
URL: "https://git.julianfamily.org",
|
||||
URL: "https://vault.crew.example.invalid",
|
||||
Path: []string{"Root", "Internet"},
|
||||
},
|
||||
},
|
||||
@@ -146,10 +146,10 @@ func TestSavePersistsEditsBackToCurrentPath(t *testing.T) {
|
||||
updated := model
|
||||
updated.UpsertEntry(vault.Entry{
|
||||
ID: "entry-1",
|
||||
Title: "Git Server",
|
||||
Username: "joejulian",
|
||||
Title: "Vault Console",
|
||||
Username: "dannyocean",
|
||||
Password: "token-2",
|
||||
URL: "https://git.julianfamily.org",
|
||||
URL: "https://vault.crew.example.invalid",
|
||||
Path: []string{"Root", "Internet"},
|
||||
})
|
||||
sess.Replace(updated)
|
||||
@@ -186,19 +186,19 @@ func TestSaveReparentsMixedPathsUnderSingleVaultRoot(t *testing.T) {
|
||||
Entries: []vault.Entry{
|
||||
{
|
||||
ID: "entry-1",
|
||||
Title: "Git Server",
|
||||
Username: "joejulian",
|
||||
Title: "Vault Console",
|
||||
Username: "dannyocean",
|
||||
Password: "token-1",
|
||||
URL: "https://git.julianfamily.org",
|
||||
Path: []string{"keepass", "Joe", "Internet"},
|
||||
URL: "https://vault.crew.example.invalid",
|
||||
Path: []string{"keepass", "Crew", "Internet"},
|
||||
},
|
||||
{
|
||||
ID: "entry-2",
|
||||
Title: "Mail",
|
||||
Username: "joejulian",
|
||||
Username: "dannyocean",
|
||||
Password: "token-2",
|
||||
URL: "https://mail.julianfamily.org",
|
||||
Path: []string{"keepass", "Joe", "eMail"},
|
||||
URL: "https://dispatch.crew.example.invalid",
|
||||
Path: []string{"keepass", "Crew", "eMail"},
|
||||
},
|
||||
},
|
||||
}, key.Password); err != nil {
|
||||
@@ -217,8 +217,8 @@ func TestSaveReparentsMixedPathsUnderSingleVaultRoot(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("Current() error = %v", err)
|
||||
}
|
||||
current.Entries[0].Path = []string{"Joe", "Internet"}
|
||||
current.Groups = append(current.Groups, []string{"Joe"}, []string{"Joe", "Internet"}, []string{"Joe", "eMail"})
|
||||
current.Entries[0].Path = []string{"Crew", "Internet"}
|
||||
current.Groups = append(current.Groups, []string{"Crew"}, []string{"Crew", "Internet"}, []string{"Crew", "eMail"})
|
||||
sess.Replace(current)
|
||||
|
||||
if err := sess.Save(); err != nil {
|
||||
@@ -244,8 +244,8 @@ func TestSaveReparentsMixedPathsUnderSingleVaultRoot(t *testing.T) {
|
||||
t.Fatalf("top-level groups = %#v, want single keepass root", db.Content.Root.Groups)
|
||||
}
|
||||
rootGroups := db.Content.Root.Groups[0].Groups
|
||||
if len(rootGroups) != 1 || rootGroups[0].Name != "Joe" {
|
||||
t.Fatalf("keepass child groups = %#v, want single Joe group", rootGroups)
|
||||
if len(rootGroups) != 1 || rootGroups[0].Name != "Crew" {
|
||||
t.Fatalf("keepass child groups = %#v, want single Crew group", rootGroups)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -271,10 +271,10 @@ func TestOpenRemoteLoadsExistingKDBXFromWebDAV(t *testing.T) {
|
||||
Entries: []vault.Entry{
|
||||
{
|
||||
ID: "entry-1",
|
||||
Title: "Git Server",
|
||||
Username: "joejulian",
|
||||
Title: "Vault Console",
|
||||
Username: "dannyocean",
|
||||
Password: "token-1",
|
||||
URL: "https://git.julianfamily.org",
|
||||
URL: "https://vault.crew.example.invalid",
|
||||
Path: []string{"Root", "Internet"},
|
||||
},
|
||||
},
|
||||
@@ -309,7 +309,7 @@ func TestOpenRemoteLoadsExistingKDBXFromWebDAV(t *testing.T) {
|
||||
|
||||
got := current.EntriesInPath([]string{"Root", "Internet"})
|
||||
if len(got) != 1 || got[0].Password != "token-1" {
|
||||
t.Fatalf("Current() entries = %#v, want Git Server entry from remote vault", got)
|
||||
t.Fatalf("Current() entries = %#v, want Vault Console entry from remote vault", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -321,10 +321,10 @@ func TestSaveRemotePersistsEditsBackToWebDAV(t *testing.T) {
|
||||
Entries: []vault.Entry{
|
||||
{
|
||||
ID: "entry-1",
|
||||
Title: "Home Assistant (Codex)",
|
||||
Title: "Surveillance Console",
|
||||
Username: "codex",
|
||||
Password: "token-1",
|
||||
URL: "https://lights.julianfamily.org",
|
||||
URL: "https://surveillance.crew.example.invalid",
|
||||
Path: []string{"Root", "Home Assistant"},
|
||||
},
|
||||
},
|
||||
@@ -371,10 +371,10 @@ func TestSaveRemotePersistsEditsBackToWebDAV(t *testing.T) {
|
||||
}
|
||||
current.UpsertEntry(vault.Entry{
|
||||
ID: "entry-1",
|
||||
Title: "Home Assistant (Codex)",
|
||||
Title: "Surveillance Console",
|
||||
Username: "codex",
|
||||
Password: "token-2",
|
||||
URL: "https://lights.julianfamily.org",
|
||||
URL: "https://surveillance.crew.example.invalid",
|
||||
Path: []string{"Root", "Home Assistant"},
|
||||
})
|
||||
sess.Replace(current)
|
||||
@@ -406,10 +406,10 @@ func TestSaveUsesRemoteTargetWhenVaultWasOpenedFromWebDAV(t *testing.T) {
|
||||
Entries: []vault.Entry{
|
||||
{
|
||||
ID: "entry-1",
|
||||
Title: "Git Server",
|
||||
Username: "joejulian",
|
||||
Title: "Vault Console",
|
||||
Username: "dannyocean",
|
||||
Password: "token-1",
|
||||
URL: "https://git.julianfamily.org",
|
||||
URL: "https://vault.crew.example.invalid",
|
||||
Path: []string{"Root", "Internet"},
|
||||
},
|
||||
},
|
||||
@@ -448,10 +448,10 @@ func TestSaveUsesRemoteTargetWhenVaultWasOpenedFromWebDAV(t *testing.T) {
|
||||
}
|
||||
current.UpsertEntry(vault.Entry{
|
||||
ID: "entry-1",
|
||||
Title: "Git Server",
|
||||
Username: "joejulian",
|
||||
Title: "Vault Console",
|
||||
Username: "dannyocean",
|
||||
Password: "token-2",
|
||||
URL: "https://git.julianfamily.org",
|
||||
URL: "https://vault.crew.example.invalid",
|
||||
Path: []string{"Root", "Internet"},
|
||||
})
|
||||
sess.Replace(current)
|
||||
@@ -477,10 +477,10 @@ func TestChangeMasterKeyReencryptsSavedAndLockedVault(t *testing.T) {
|
||||
Entries: []vault.Entry{
|
||||
{
|
||||
ID: "entry-1",
|
||||
Title: "Git Server",
|
||||
Username: "joejulian",
|
||||
Title: "Vault Console",
|
||||
Username: "dannyocean",
|
||||
Password: "token-1",
|
||||
URL: "https://git.julianfamily.org",
|
||||
URL: "https://vault.crew.example.invalid",
|
||||
Path: []string{"Root", "Internet"},
|
||||
},
|
||||
},
|
||||
@@ -514,8 +514,8 @@ func TestChangeMasterKeyReencryptsSavedAndLockedVault(t *testing.T) {
|
||||
t.Fatalf("Current() error = %v", err)
|
||||
}
|
||||
got := current.EntriesInPath([]string{"Root", "Internet"})
|
||||
if len(got) != 1 || got[0].Title != "Git Server" {
|
||||
t.Fatalf("Current() entries = %#v, want Git Server entry after ChangeMasterKey", got)
|
||||
if len(got) != 1 || got[0].Title != "Vault Console" {
|
||||
t.Fatalf("Current() entries = %#v, want Vault Console entry after ChangeMasterKey", got)
|
||||
}
|
||||
|
||||
var reopened Manager
|
||||
@@ -540,10 +540,10 @@ func TestSavePreservesOpenedKDBXSecuritySettings(t *testing.T) {
|
||||
{
|
||||
UUID: gokeepasslib.NewUUID(),
|
||||
Values: []gokeepasslib.ValueData{
|
||||
{Key: "Title", Value: gokeepasslib.V{Content: "Git Server"}},
|
||||
{Key: "UserName", Value: gokeepasslib.V{Content: "joejulian"}},
|
||||
{Key: "Title", Value: gokeepasslib.V{Content: "Vault Console"}},
|
||||
{Key: "UserName", Value: gokeepasslib.V{Content: "dannyocean"}},
|
||||
{Key: "Password", Value: gokeepasslib.V{Content: "token-1", Protected: w.NewBoolWrapper(true)}},
|
||||
{Key: "URL", Value: gokeepasslib.V{Content: "https://git.julianfamily.org"}},
|
||||
{Key: "URL", Value: gokeepasslib.V{Content: "https://vault.crew.example.invalid"}},
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -577,10 +577,10 @@ func TestSavePreservesOpenedKDBXSecuritySettings(t *testing.T) {
|
||||
}
|
||||
current.UpsertEntry(vault.Entry{
|
||||
ID: current.Entries[0].ID,
|
||||
Title: "Git Server",
|
||||
Username: "joejulian",
|
||||
Title: "Vault Console",
|
||||
Username: "dannyocean",
|
||||
Password: "token-2",
|
||||
URL: "https://git.julianfamily.org",
|
||||
URL: "https://vault.crew.example.invalid",
|
||||
Path: current.Entries[0].Path,
|
||||
})
|
||||
sess.Replace(current)
|
||||
@@ -620,10 +620,10 @@ func TestRemoteSaveAndReopenPreservesCrossFeatureState(t *testing.T) {
|
||||
Entries: []vault.Entry{
|
||||
{
|
||||
ID: "entry-1",
|
||||
Title: "Git Server",
|
||||
Username: "joejulian",
|
||||
Title: "Vault Console",
|
||||
Username: "dannyocean",
|
||||
Password: "token-2",
|
||||
URL: "https://git.julianfamily.org",
|
||||
URL: "https://vault.crew.example.invalid",
|
||||
Path: []string{"Root", "Internet"},
|
||||
Attachments: map[string][]byte{
|
||||
"token.txt": []byte("secret attachment contents"),
|
||||
@@ -631,10 +631,10 @@ func TestRemoteSaveAndReopenPreservesCrossFeatureState(t *testing.T) {
|
||||
History: []vault.Entry{
|
||||
{
|
||||
ID: "entry-1-history-1",
|
||||
Title: "Git Server",
|
||||
Username: "joejulian",
|
||||
Title: "Vault Console",
|
||||
Username: "dannyocean",
|
||||
Password: "token-1",
|
||||
URL: "https://git.julianfamily.org",
|
||||
URL: "https://vault.crew.example.invalid",
|
||||
Path: []string{"Root", "Internet"},
|
||||
},
|
||||
},
|
||||
@@ -741,6 +741,35 @@ func TestRemoteSaveAndReopenPreservesCrossFeatureState(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigureSecurityAppliesToCreatedVaultAndPersists(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var sess Manager
|
||||
if err := sess.ConfigureSecurity(vault.SecuritySettings{
|
||||
Cipher: vault.CipherAES256,
|
||||
KDF: vault.KDFAES,
|
||||
}); err != nil {
|
||||
t.Fatalf("ConfigureSecurity() error = %v", err)
|
||||
}
|
||||
if err := sess.Create(vault.Model{}, vault.MasterKey{Password: "correct horse battery staple"}); err != nil {
|
||||
t.Fatalf("Create() error = %v", err)
|
||||
}
|
||||
|
||||
path := filepath.Join(t.TempDir(), "secure.kdbx")
|
||||
if err := sess.SaveAs(path); err != nil {
|
||||
t.Fatalf("SaveAs() error = %v", err)
|
||||
}
|
||||
|
||||
var reopened Manager
|
||||
if err := reopened.Open(path, vault.MasterKey{Password: "correct horse battery staple"}); err != nil {
|
||||
t.Fatalf("Open() error = %v", err)
|
||||
}
|
||||
got := reopened.SecuritySettings()
|
||||
if got.Cipher != vault.CipherAES256 || got.KDF != vault.KDFAES {
|
||||
t.Fatalf("SecuritySettings() = %#v, want aes256/aes-kdf", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSynchronizeRemotePreservesOverwrittenRemoteVariantInHistory(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -749,10 +778,10 @@ func TestSynchronizeRemotePreservesOverwrittenRemoteVariantInHistory(t *testing.
|
||||
Entries: []vault.Entry{
|
||||
{
|
||||
ID: "entry-1",
|
||||
Title: "Git Server",
|
||||
Username: "joejulian",
|
||||
Title: "Vault Console",
|
||||
Username: "dannyocean",
|
||||
Password: "token-1",
|
||||
URL: "https://git.julianfamily.org",
|
||||
URL: "https://vault.crew.example.invalid",
|
||||
Path: []string{"Root", "Internet"},
|
||||
},
|
||||
},
|
||||
@@ -812,10 +841,10 @@ func TestSynchronizeRemotePreservesOverwrittenRemoteVariantInHistory(t *testing.
|
||||
}
|
||||
firstCurrent.UpsertEntry(vault.Entry{
|
||||
ID: "entry-1",
|
||||
Title: "Git Server",
|
||||
Username: "joejulian",
|
||||
Title: "Vault Console",
|
||||
Username: "dannyocean",
|
||||
Password: "remote-token-2",
|
||||
URL: "https://git.julianfamily.org",
|
||||
URL: "https://vault.crew.example.invalid",
|
||||
Notes: "updated remotely first",
|
||||
Path: []string{"Root", "Internet"},
|
||||
})
|
||||
@@ -830,10 +859,10 @@ func TestSynchronizeRemotePreservesOverwrittenRemoteVariantInHistory(t *testing.
|
||||
}
|
||||
secondCurrent.UpsertEntry(vault.Entry{
|
||||
ID: "entry-1",
|
||||
Title: "Git Server",
|
||||
Username: "joejulian",
|
||||
Title: "Vault Console",
|
||||
Username: "dannyocean",
|
||||
Password: "local-token-2",
|
||||
URL: "https://git.julianfamily.org/settings/tokens",
|
||||
URL: "https://vault.crew.example.invalid/security/badges",
|
||||
Path: []string{"Root", "Internet"},
|
||||
})
|
||||
second.Replace(secondCurrent)
|
||||
@@ -854,7 +883,7 @@ func TestSynchronizeRemotePreservesOverwrittenRemoteVariantInHistory(t *testing.
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("len(EntriesInPath(Root/Internet)) = %d, want 1", len(got))
|
||||
}
|
||||
if got[0].Password != "local-token-2" || got[0].URL != "https://git.julianfamily.org/settings/tokens" {
|
||||
if got[0].Password != "local-token-2" || got[0].URL != "https://vault.crew.example.invalid/security/badges" {
|
||||
t.Fatalf("entry after synchronize = %#v, want local winning version", got[0])
|
||||
}
|
||||
if len(got[0].History) == 0 {
|
||||
@@ -876,10 +905,10 @@ func TestSynchronizeFromLocalMergesOtherVaultIntoCurrentSource(t *testing.T) {
|
||||
Entries: []vault.Entry{
|
||||
{
|
||||
ID: "entry-current",
|
||||
Title: "Git Server",
|
||||
Username: "joejulian",
|
||||
Title: "Vault Console",
|
||||
Username: "dannyocean",
|
||||
Password: "token-current",
|
||||
URL: "https://git.julianfamily.org",
|
||||
URL: "https://vault.crew.example.invalid",
|
||||
Path: []string{"Root", "Internet"},
|
||||
},
|
||||
},
|
||||
@@ -888,10 +917,10 @@ func TestSynchronizeFromLocalMergesOtherVaultIntoCurrentSource(t *testing.T) {
|
||||
Entries: []vault.Entry{
|
||||
{
|
||||
ID: "entry-other",
|
||||
Title: "Dynadot",
|
||||
Username: "jjulian",
|
||||
Title: "Bellagio",
|
||||
Username: "rustyryan",
|
||||
Password: "token-other",
|
||||
URL: "https://www.dynadot.com",
|
||||
URL: "https://bellagio.example.invalid",
|
||||
Path: []string{"Root", "Internet"},
|
||||
},
|
||||
},
|
||||
@@ -924,6 +953,63 @@ func TestSynchronizeFromLocalMergesOtherVaultIntoCurrentSource(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSynchronizeFromLocalBytesMergesOtherVaultIntoCurrentSource(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
key := vault.MasterKey{Password: "correct horse battery staple"}
|
||||
currentPath := filepath.Join(t.TempDir(), "current.kdbx")
|
||||
|
||||
currentModel := vault.Model{
|
||||
Entries: []vault.Entry{{
|
||||
ID: "entry-current",
|
||||
Title: "Vault Console",
|
||||
Username: "dannyocean",
|
||||
Password: "token-current",
|
||||
URL: "https://vault.crew.example.invalid",
|
||||
Path: []string{"Root", "Internet"},
|
||||
}},
|
||||
}
|
||||
otherModel := vault.Model{
|
||||
Entries: []vault.Entry{{
|
||||
ID: "entry-other",
|
||||
Title: "Bellagio",
|
||||
Username: "rustyryan",
|
||||
Password: "token-other",
|
||||
URL: "https://bellagio.example.invalid",
|
||||
Path: []string{"Root", "Internet"},
|
||||
}},
|
||||
}
|
||||
|
||||
writeKDBXTestFile(t, currentPath, currentModel, key)
|
||||
var other bytes.Buffer
|
||||
if err := vault.SaveKDBX(&other, otherModel, key.Password); err != nil {
|
||||
t.Fatalf("SaveKDBX(other) error = %v", err)
|
||||
}
|
||||
|
||||
var sess Manager
|
||||
if err := sess.Open(currentPath, key); err != nil {
|
||||
t.Fatalf("Open(current) error = %v", err)
|
||||
}
|
||||
|
||||
if err := sess.SynchronizeFromLocalBytes("picked-other.kdbx", other.Bytes()); err != nil {
|
||||
t.Fatalf("SynchronizeFromLocalBytes() error = %v", err)
|
||||
}
|
||||
|
||||
var reopened Manager
|
||||
if err := reopened.Open(currentPath, key); err != nil {
|
||||
t.Fatalf("reopen Open(current) error = %v", err)
|
||||
}
|
||||
current, err := reopened.Current()
|
||||
if err != nil {
|
||||
t.Fatalf("reopened Current() error = %v", err)
|
||||
}
|
||||
|
||||
got := current.EntriesInPath([]string{"Root", "Internet"})
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("len(EntriesInPath(Root/Internet)) = %d, want 2", len(got))
|
||||
}
|
||||
}
|
||||
|
||||
func TestSynchronizeToLocalWritesMergedVaultToTarget(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -935,10 +1021,10 @@ func TestSynchronizeToLocalWritesMergedVaultToTarget(t *testing.T) {
|
||||
Entries: []vault.Entry{
|
||||
{
|
||||
ID: "entry-current",
|
||||
Title: "Git Server",
|
||||
Username: "joejulian",
|
||||
Title: "Vault Console",
|
||||
Username: "dannyocean",
|
||||
Password: "token-current",
|
||||
URL: "https://git.julianfamily.org",
|
||||
URL: "https://vault.crew.example.invalid",
|
||||
Path: []string{"Root", "Internet"},
|
||||
},
|
||||
},
|
||||
@@ -947,10 +1033,10 @@ func TestSynchronizeToLocalWritesMergedVaultToTarget(t *testing.T) {
|
||||
Entries: []vault.Entry{
|
||||
{
|
||||
ID: "entry-other",
|
||||
Title: "Dynadot",
|
||||
Username: "jjulian",
|
||||
Title: "Bellagio",
|
||||
Username: "rustyryan",
|
||||
Password: "token-other",
|
||||
URL: "https://www.dynadot.com",
|
||||
URL: "https://bellagio.example.invalid",
|
||||
Path: []string{"Root", "Internet"},
|
||||
},
|
||||
},
|
||||
@@ -992,10 +1078,10 @@ func TestSynchronizeToRemoteWritesMergedVaultToTarget(t *testing.T) {
|
||||
Entries: []vault.Entry{
|
||||
{
|
||||
ID: "entry-current",
|
||||
Title: "Git Server",
|
||||
Username: "joejulian",
|
||||
Title: "Vault Console",
|
||||
Username: "dannyocean",
|
||||
Password: "token-current",
|
||||
URL: "https://git.julianfamily.org",
|
||||
URL: "https://vault.crew.example.invalid",
|
||||
Path: []string{"Root", "Internet"},
|
||||
},
|
||||
},
|
||||
@@ -1004,10 +1090,10 @@ func TestSynchronizeToRemoteWritesMergedVaultToTarget(t *testing.T) {
|
||||
Entries: []vault.Entry{
|
||||
{
|
||||
ID: "entry-remote",
|
||||
Title: "Dynadot",
|
||||
Username: "jjulian",
|
||||
Title: "Bellagio",
|
||||
Username: "rustyryan",
|
||||
Password: "token-remote",
|
||||
URL: "https://www.dynadot.com",
|
||||
URL: "https://bellagio.example.invalid",
|
||||
Path: []string{"Root", "Internet"},
|
||||
},
|
||||
},
|
||||
@@ -8,11 +8,11 @@ func TestUpsertEntryPreservesPreviousVersionInHistory(t *testing.T) {
|
||||
model := Model{
|
||||
Entries: []Entry{
|
||||
{
|
||||
ID: "git-server",
|
||||
Title: "Git Server",
|
||||
Username: "joejulian",
|
||||
ID: "vault-console",
|
||||
Title: "Vault Console",
|
||||
Username: "dannyocean",
|
||||
Password: "old-token",
|
||||
URL: "https://git.julianfamily.org",
|
||||
URL: "https://vault.crew.example.invalid",
|
||||
Notes: "Original note",
|
||||
Path: []string{"Root", "Internet"},
|
||||
},
|
||||
@@ -20,11 +20,11 @@ func TestUpsertEntryPreservesPreviousVersionInHistory(t *testing.T) {
|
||||
}
|
||||
|
||||
model.UpsertEntry(Entry{
|
||||
ID: "git-server",
|
||||
Title: "Git Server",
|
||||
Username: "joejulian",
|
||||
ID: "vault-console",
|
||||
Title: "Vault Console",
|
||||
Username: "dannyocean",
|
||||
Password: "new-token",
|
||||
URL: "https://git.julianfamily.org",
|
||||
URL: "https://vault.crew.example.invalid",
|
||||
Notes: "Updated note",
|
||||
Path: []string{"Root", "Internet"},
|
||||
})
|
||||
@@ -53,17 +53,17 @@ func TestDeleteEntryMovesItToRecycleBin(t *testing.T) {
|
||||
model := Model{
|
||||
Entries: []Entry{
|
||||
{
|
||||
ID: "ha-codex",
|
||||
Title: "Home Assistant (Codex)",
|
||||
ID: "surveillance-console",
|
||||
Title: "Surveillance Console",
|
||||
Username: "codex",
|
||||
Password: "token-2",
|
||||
URL: "https://lights.julianfamily.org",
|
||||
URL: "https://surveillance.crew.example.invalid",
|
||||
Path: []string{"Root", "Home Assistant"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
if err := model.DeleteEntry("ha-codex"); err != nil {
|
||||
if err := model.DeleteEntry("surveillance-console"); err != nil {
|
||||
t.Fatalf("DeleteEntry() error = %v", err)
|
||||
}
|
||||
|
||||
@@ -75,8 +75,8 @@ func TestDeleteEntryMovesItToRecycleBin(t *testing.T) {
|
||||
t.Fatalf("len(RecycleBin) = %d, want 1", len(model.RecycleBin))
|
||||
}
|
||||
|
||||
if model.RecycleBin[0].Title != "Home Assistant (Codex)" {
|
||||
t.Fatalf("RecycleBin[0].Title = %q, want %q", model.RecycleBin[0].Title, "Home Assistant (Codex)")
|
||||
if model.RecycleBin[0].Title != "Surveillance Console" {
|
||||
t.Fatalf("RecycleBin[0].Title = %q, want %q", model.RecycleBin[0].Title, "Surveillance Console")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -86,17 +86,17 @@ func TestRestoreEntryMovesItBackFromRecycleBin(t *testing.T) {
|
||||
model := Model{
|
||||
RecycleBin: []Entry{
|
||||
{
|
||||
ID: "dynadot",
|
||||
Title: "Dynadot",
|
||||
Username: "jjulian",
|
||||
ID: "bellagio",
|
||||
Title: "Bellagio",
|
||||
Username: "rustyryan",
|
||||
Password: "token-3",
|
||||
URL: "https://www.dynadot.com",
|
||||
URL: "https://bellagio.example.invalid",
|
||||
Path: []string{"Root", "Internet"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
if err := model.RestoreEntry("dynadot"); err != nil {
|
||||
if err := model.RestoreEntry("bellagio"); err != nil {
|
||||
t.Fatalf("RestoreEntry() error = %v", err)
|
||||
}
|
||||
|
||||
@@ -105,8 +105,8 @@ func TestRestoreEntryMovesItBackFromRecycleBin(t *testing.T) {
|
||||
t.Fatalf("len(EntriesInPath()) = %d, want 1", len(got))
|
||||
}
|
||||
|
||||
if got[0].Title != "Dynadot" {
|
||||
t.Fatalf("EntriesInPath()[0].Title = %q, want %q", got[0].Title, "Dynadot")
|
||||
if got[0].Title != "Bellagio" {
|
||||
t.Fatalf("EntriesInPath()[0].Title = %q, want %q", got[0].Title, "Bellagio")
|
||||
}
|
||||
|
||||
if len(model.RecycleBin) != 0 {
|
||||
@@ -120,17 +120,17 @@ func TestRestoreEntryVersionPromotesHistoricalVersionAndRetainsCurrentInHistory(
|
||||
model := Model{
|
||||
Entries: []Entry{
|
||||
{
|
||||
ID: "git-server",
|
||||
Title: "Git Server",
|
||||
Username: "joejulian",
|
||||
ID: "vault-console",
|
||||
Title: "Vault Console",
|
||||
Username: "dannyocean",
|
||||
Password: "new-token",
|
||||
Notes: "Current note",
|
||||
Path: []string{"Root", "Internet"},
|
||||
History: []Entry{
|
||||
{
|
||||
ID: "git-server-history-1",
|
||||
Title: "Git Server",
|
||||
Username: "joejulian",
|
||||
ID: "vault-console-history-1",
|
||||
Title: "Vault Console",
|
||||
Username: "dannyocean",
|
||||
Password: "old-token",
|
||||
Notes: "Previous note",
|
||||
Path: []string{"Root", "Internet"},
|
||||
@@ -140,7 +140,7 @@ func TestRestoreEntryVersionPromotesHistoricalVersionAndRetainsCurrentInHistory(
|
||||
},
|
||||
}
|
||||
|
||||
if err := model.RestoreEntryVersion("git-server", 0); err != nil {
|
||||
if err := model.RestoreEntryVersion("vault-console", 0); err != nil {
|
||||
t.Fatalf("RestoreEntryVersion() error = %v", err)
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package vault
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
@@ -23,9 +24,10 @@ type KDBXConfig struct {
|
||||
var ErrInvalidMasterKey = errors.New("invalid master key")
|
||||
|
||||
const (
|
||||
templatesRoot = "Templates"
|
||||
recycleBinRoot = "Recycle Bin"
|
||||
keepassGOIDField = "KeePassGO-ID"
|
||||
templatesRoot = "Templates"
|
||||
recycleBinRoot = "Recycle Bin"
|
||||
keepassGOIDField = "KeePassGO-ID"
|
||||
remoteProfilesKey = "keepassgo.remoteProfiles"
|
||||
)
|
||||
|
||||
func LoadKDBX(r io.Reader, password string) (Model, error) {
|
||||
@@ -49,6 +51,7 @@ func SaveKDBXWithConfigAndKey(wr io.Writer, model Model, key MasterKey, config *
|
||||
db := gokeepasslib.NewDatabase(gokeepasslib.WithDatabaseKDBXVersion4())
|
||||
db.Credentials = credentials
|
||||
db.Content.Meta = gokeepasslib.NewMetaData()
|
||||
db.Content.Meta.CustomData = customDataForModel(model)
|
||||
db.Content.Root = &gokeepasslib.RootData{}
|
||||
if config != nil && config.Header != nil {
|
||||
db.Header = cloneHeader(config.Header)
|
||||
@@ -325,6 +328,7 @@ func LoadKDBXWithConfig(r io.Reader, key MasterKey) (Model, *KDBXConfig, error)
|
||||
for _, group := range db.Content.Root.Groups {
|
||||
appendGroupEntries(&model, db, group, nil)
|
||||
}
|
||||
model.RemoteProfiles = remoteProfilesFromMeta(db.Content.Meta)
|
||||
|
||||
return model, &KDBXConfig{
|
||||
Header: cloneHeader(db.Header),
|
||||
@@ -332,6 +336,39 @@ func LoadKDBXWithConfig(r io.Reader, key MasterKey) (Model, *KDBXConfig, error)
|
||||
}, nil
|
||||
}
|
||||
|
||||
func customDataForModel(model Model) []gokeepasslib.CustomData {
|
||||
if len(model.RemoteProfiles) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
content, err := json.Marshal(model.RemoteProfiles)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
return []gokeepasslib.CustomData{{
|
||||
Key: remoteProfilesKey,
|
||||
Value: string(content),
|
||||
}}
|
||||
}
|
||||
|
||||
func remoteProfilesFromMeta(meta *gokeepasslib.MetaData) []RemoteProfile {
|
||||
if meta == nil {
|
||||
return nil
|
||||
}
|
||||
for _, item := range meta.CustomData {
|
||||
if item.Key != remoteProfilesKey {
|
||||
continue
|
||||
}
|
||||
var profiles []RemoteProfile
|
||||
if err := json.Unmarshal([]byte(item.Value), &profiles); err != nil {
|
||||
return nil
|
||||
}
|
||||
return profiles
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func newCredentials(key MasterKey) (*gokeepasslib.DBCredentials, error) {
|
||||
switch {
|
||||
case key.Password != "" && len(key.KeyFileData) > 0:
|
||||
@@ -22,11 +22,11 @@ func TestLoadKDBXBuildsModelFromNestedGroups(t *testing.T) {
|
||||
Groups: []gokeepasslib.Group{
|
||||
mustGroup("Root",
|
||||
mustGroup("Internet",
|
||||
mustEntry("Dynadot", "jjulian", "https://www.dynadot.com", "hunter2"),
|
||||
mustEntry("Git Server", "joejulian", "https://git.julianfamily.org", "token-1"),
|
||||
mustEntry("Bellagio", "rustyryan", "https://bellagio.example.invalid", "hunter2"),
|
||||
mustEntry("Vault Console", "dannyocean", "https://vault.crew.example.invalid", "bellagio-pass-1"),
|
||||
),
|
||||
mustGroup("Home Assistant",
|
||||
mustEntry("Home Assistant (Codex)", "codex", "https://lights.julianfamily.org", "token-2"),
|
||||
mustGroup("Security Office",
|
||||
mustEntry("Surveillance Console", "bashertarr", "https://surveillance.crew.example.invalid", "bellagio-pass-2"),
|
||||
),
|
||||
),
|
||||
},
|
||||
@@ -53,24 +53,24 @@ func TestLoadKDBXBuildsModelFromNestedGroups(t *testing.T) {
|
||||
t.Fatalf("len(EntriesInPath()) = %d, want 2", len(got))
|
||||
}
|
||||
|
||||
if got[0].Title != "Dynadot" || got[0].Username != "jjulian" || got[0].URL != "https://www.dynadot.com" {
|
||||
if got[0].Title != "Bellagio" || got[0].Username != "rustyryan" || got[0].URL != "https://bellagio.example.invalid" {
|
||||
t.Fatalf("unexpected first entry: %#v", got[0])
|
||||
}
|
||||
|
||||
if got[1].Title != "Git Server" || got[1].Username != "joejulian" || got[1].URL != "https://git.julianfamily.org" {
|
||||
if got[1].Title != "Vault Console" || got[1].Username != "dannyocean" || got[1].URL != "https://vault.crew.example.invalid" {
|
||||
t.Fatalf("unexpected second entry: %#v", got[1])
|
||||
}
|
||||
|
||||
groups := model.ChildGroups([]string{"Root"})
|
||||
if len(groups) != 2 || groups[0] != "Home Assistant" || groups[1] != "Internet" {
|
||||
t.Fatalf("ChildGroups() = %v, want [Home Assistant Internet]", groups)
|
||||
if len(groups) != 2 || groups[0] != "Internet" || groups[1] != "Security Office" {
|
||||
t.Fatalf("ChildGroups() = %v, want [Internet Security Office]", groups)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadKDBXPreservesEntryDetails(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
entry := mustEntry("Home Assistant (Codex)", "codex", "https://lights.julianfamily.org", "token-2")
|
||||
entry := mustEntry("Surveillance Console", "bashertarr", "https://surveillance.crew.example.invalid", "bellagio-pass-2")
|
||||
entry.Tags = "automation; home"
|
||||
entry.Values = append(entry.Values,
|
||||
mkValue("Notes", "Long-lived token used by Codex for home automation tasks."),
|
||||
@@ -84,7 +84,7 @@ func TestLoadKDBXPreservesEntryDetails(t *testing.T) {
|
||||
Meta: gokeepasslib.NewMetaData(),
|
||||
Root: &gokeepasslib.RootData{
|
||||
Groups: []gokeepasslib.Group{
|
||||
mustGroup("Root", mustGroup("Home Assistant", entry)),
|
||||
mustGroup("Root", mustGroup("Security Office", entry)),
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -104,13 +104,13 @@ func TestLoadKDBXPreservesEntryDetails(t *testing.T) {
|
||||
t.Fatalf("LoadKDBX failed: %v", err)
|
||||
}
|
||||
|
||||
got := model.EntriesInPath([]string{"Root", "Home Assistant"})
|
||||
got := model.EntriesInPath([]string{"Root", "Security Office"})
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("len(EntriesInPath()) = %d, want 1", len(got))
|
||||
}
|
||||
|
||||
if got[0].Password != "token-2" {
|
||||
t.Fatalf("Entry.Password = %q, want %q", got[0].Password, "token-2")
|
||||
if got[0].Password != "bellagio-pass-2" {
|
||||
t.Fatalf("Entry.Password = %q, want %q", got[0].Password, "bellagio-pass-2")
|
||||
}
|
||||
|
||||
if got[0].Notes != "Long-lived token used by Codex for home automation tasks." {
|
||||
@@ -133,10 +133,10 @@ func TestSaveKDBXRoundTripsModel(t *testing.T) {
|
||||
Entries: []Entry{
|
||||
{
|
||||
ID: "entry-1",
|
||||
Title: "Git Server",
|
||||
Username: "joejulian",
|
||||
Password: "token-1",
|
||||
URL: "https://git.julianfamily.org",
|
||||
Title: "Vault Console",
|
||||
Username: "dannyocean",
|
||||
Password: "bellagio-pass-1",
|
||||
URL: "https://vault.crew.example.invalid",
|
||||
Notes: "Personal git server token entry used for automation and CLI auth.",
|
||||
Tags: []string{"git", "infra"},
|
||||
Fields: map[string]string{
|
||||
@@ -146,13 +146,13 @@ func TestSaveKDBXRoundTripsModel(t *testing.T) {
|
||||
},
|
||||
{
|
||||
ID: "entry-2",
|
||||
Title: "Home Assistant (Codex)",
|
||||
Username: "codex",
|
||||
Password: "token-2",
|
||||
URL: "https://lights.julianfamily.org",
|
||||
Title: "Surveillance Console",
|
||||
Username: "bashertarr",
|
||||
Password: "bellagio-pass-2",
|
||||
URL: "https://surveillance.crew.example.invalid",
|
||||
Notes: "Long-lived token used by Codex for home automation tasks.",
|
||||
Tags: []string{"automation", "home"},
|
||||
Path: []string{"Root", "Home Assistant"},
|
||||
Path: []string{"Root", "Security Office"},
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -167,7 +167,7 @@ func TestSaveKDBXRoundTripsModel(t *testing.T) {
|
||||
t.Fatalf("LoadKDBX() error = %v", err)
|
||||
}
|
||||
|
||||
got := loaded.Search("git")
|
||||
got := loaded.Search("vault")
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("len(Search(\"git\")) = %d, want 1", len(got))
|
||||
}
|
||||
@@ -180,13 +180,13 @@ func TestSaveKDBXRoundTripsModel(t *testing.T) {
|
||||
t.Fatalf("Search(\"git\") X-Role = %q, want %q", got[0].Entry.Fields["X-Role"], "automation")
|
||||
}
|
||||
|
||||
homeAssistant := loaded.EntriesInPath([]string{"Root", "Home Assistant"})
|
||||
homeAssistant := loaded.EntriesInPath([]string{"Root", "Security Office"})
|
||||
if len(homeAssistant) != 1 {
|
||||
t.Fatalf("len(EntriesInPath(Home Assistant)) = %d, want 1", len(homeAssistant))
|
||||
t.Fatalf("len(EntriesInPath(Security Office)) = %d, want 1", len(homeAssistant))
|
||||
}
|
||||
|
||||
if homeAssistant[0].Password != "token-2" {
|
||||
t.Fatalf("Home Assistant password = %q, want %q", homeAssistant[0].Password, "token-2")
|
||||
if homeAssistant[0].Password != "bellagio-pass-2" {
|
||||
t.Fatalf("Security Office password = %q, want %q", homeAssistant[0].Password, "bellagio-pass-2")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -238,6 +238,50 @@ func TestSaveKDBXRoundTripsTemplates(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveKDBXRoundTripsRemoteProfiles(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
model := Model{
|
||||
RemoteProfiles: []RemoteProfile{
|
||||
{
|
||||
ID: "bellagio-webdav",
|
||||
Name: "Bellagio Vault",
|
||||
Backend: RemoteBackendWebDAV,
|
||||
BaseURL: "https://dav.example.invalid/remote.php/dav",
|
||||
Path: "files/bellagio/keepass.kdbx",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
if len(loaded.RemoteProfiles) != 1 {
|
||||
t.Fatalf("len(RemoteProfiles) = %d, want 1", len(loaded.RemoteProfiles))
|
||||
}
|
||||
|
||||
got := loaded.RemoteProfiles[0]
|
||||
if got.ID != "bellagio-webdav" || got.Name != "Bellagio Vault" {
|
||||
t.Fatalf("loaded remote profile = %#v, want bellagio-webdav Bellagio Vault", got)
|
||||
}
|
||||
if got.Backend != RemoteBackendWebDAV {
|
||||
t.Fatalf("remote backend = %q, want %q", got.Backend, RemoteBackendWebDAV)
|
||||
}
|
||||
if got.BaseURL != "https://dav.example.invalid/remote.php/dav" {
|
||||
t.Fatalf("remote base URL = %q, want remote.php/dav URL", got.BaseURL)
|
||||
}
|
||||
if got.Path != "files/bellagio/keepass.kdbx" {
|
||||
t.Fatalf("remote path = %q, want files/bellagio/keepass.kdbx", got.Path)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveKDBXRoundTripsEntryHistory(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -245,18 +289,18 @@ func TestSaveKDBXRoundTripsEntryHistory(t *testing.T) {
|
||||
Entries: []Entry{
|
||||
{
|
||||
ID: "entry-1",
|
||||
Title: "Git Server",
|
||||
Username: "joejulian",
|
||||
Title: "Vault Console",
|
||||
Username: "dannyocean",
|
||||
Password: "new-token",
|
||||
URL: "https://git.julianfamily.org",
|
||||
URL: "https://vault.crew.example.invalid",
|
||||
Path: []string{"Root", "Internet"},
|
||||
History: []Entry{
|
||||
{
|
||||
ID: "entry-1-old",
|
||||
Title: "Git Server",
|
||||
Username: "joejulian",
|
||||
Title: "Vault Console",
|
||||
Username: "dannyocean",
|
||||
Password: "old-token",
|
||||
URL: "https://git.julianfamily.org",
|
||||
URL: "https://vault.crew.example.invalid",
|
||||
Path: []string{"Root", "Internet"},
|
||||
Notes: "Original version",
|
||||
},
|
||||
@@ -296,11 +340,11 @@ func TestSaveKDBXRoundTripsRecycleBinEntries(t *testing.T) {
|
||||
RecycleBin: []Entry{
|
||||
{
|
||||
ID: "entry-1",
|
||||
Title: "Home Assistant (Codex)",
|
||||
Username: "codex",
|
||||
Password: "token-2",
|
||||
URL: "https://lights.julianfamily.org",
|
||||
Path: []string{"Root", "Home Assistant"},
|
||||
Title: "Surveillance Console",
|
||||
Username: "bashertarr",
|
||||
Password: "bellagio-pass-2",
|
||||
URL: "https://surveillance.crew.example.invalid",
|
||||
Path: []string{"Root", "Security Office"},
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -319,12 +363,12 @@ func TestSaveKDBXRoundTripsRecycleBinEntries(t *testing.T) {
|
||||
t.Fatalf("len(RecycleBin) = %d, want 1", len(loaded.RecycleBin))
|
||||
}
|
||||
|
||||
if loaded.RecycleBin[0].Title != "Home Assistant (Codex)" {
|
||||
t.Fatalf("RecycleBin[0].Title = %q, want %q", loaded.RecycleBin[0].Title, "Home Assistant (Codex)")
|
||||
if loaded.RecycleBin[0].Title != "Surveillance Console" {
|
||||
t.Fatalf("RecycleBin[0].Title = %q, want %q", loaded.RecycleBin[0].Title, "Surveillance Console")
|
||||
}
|
||||
|
||||
if len(loaded.RecycleBin[0].Path) != 2 || loaded.RecycleBin[0].Path[0] != "Root" || loaded.RecycleBin[0].Path[1] != "Home Assistant" {
|
||||
t.Fatalf("RecycleBin[0].Path = %v, want [Root Home Assistant]", loaded.RecycleBin[0].Path)
|
||||
if len(loaded.RecycleBin[0].Path) != 2 || loaded.RecycleBin[0].Path[0] != "Root" || loaded.RecycleBin[0].Path[1] != "Security Office" {
|
||||
t.Fatalf("RecycleBin[0].Path = %v, want [Root Security Office]", loaded.RecycleBin[0].Path)
|
||||
}
|
||||
|
||||
if len(loaded.Entries) != 0 {
|
||||
@@ -358,7 +402,7 @@ func TestLoadKDBXWithKeyFileCredentials(t *testing.T) {
|
||||
Meta: gokeepasslib.NewMetaData(),
|
||||
Root: &gokeepasslib.RootData{
|
||||
Groups: []gokeepasslib.Group{
|
||||
mustGroup("Root", mustGroup("Internet", mustEntry("Git Server", "joejulian", "https://git.julianfamily.org", "token-1"))),
|
||||
mustGroup("Root", mustGroup("Internet", mustEntry("Vault Console", "dannyocean", "https://vault.crew.example.invalid", "bellagio-pass-1"))),
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -378,9 +422,9 @@ func TestLoadKDBXWithKeyFileCredentials(t *testing.T) {
|
||||
t.Fatalf("LoadKDBXWithKey() error = %v", err)
|
||||
}
|
||||
|
||||
got := model.Search("git")
|
||||
if len(got) != 1 || got[0].Entry.Password != "token-1" {
|
||||
t.Fatalf("LoadKDBXWithKey() = %#v, want password-preserving git entry", got)
|
||||
got := model.Search("vault")
|
||||
if len(got) != 1 || got[0].Entry.Password != "bellagio-pass-1" {
|
||||
t.Fatalf("LoadKDBXWithKey() = %#v, want password-preserving vault entry", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -413,7 +457,7 @@ func TestLoadKDBXWithCompositeCredentials(t *testing.T) {
|
||||
Meta: gokeepasslib.NewMetaData(),
|
||||
Root: &gokeepasslib.RootData{
|
||||
Groups: []gokeepasslib.Group{
|
||||
mustGroup("Root", mustGroup("Home Assistant", mustEntry("Home Assistant (Codex)", "codex", "https://lights.julianfamily.org", "token-2"))),
|
||||
mustGroup("Root", mustGroup("Security Office", mustEntry("Surveillance Console", "bashertarr", "https://surveillance.crew.example.invalid", "bellagio-pass-2"))),
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -436,9 +480,9 @@ func TestLoadKDBXWithCompositeCredentials(t *testing.T) {
|
||||
t.Fatalf("LoadKDBXWithKey() error = %v", err)
|
||||
}
|
||||
|
||||
got := model.EntriesInPath([]string{"Root", "Home Assistant"})
|
||||
if len(got) != 1 || got[0].Password != "token-2" {
|
||||
t.Fatalf("LoadKDBXWithKey() = %#v, want Home Assistant entry with password", got)
|
||||
got := model.EntriesInPath([]string{"Root", "Security Office"})
|
||||
if len(got) != 1 || got[0].Password != "bellagio-pass-2" {
|
||||
t.Fatalf("LoadKDBXWithKey() = %#v, want Security Office entry with password", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -452,7 +496,7 @@ func TestLoadKDBXReturnsInvalidCredentialsError(t *testing.T) {
|
||||
Meta: gokeepasslib.NewMetaData(),
|
||||
Root: &gokeepasslib.RootData{
|
||||
Groups: []gokeepasslib.Group{
|
||||
mustGroup("Root", mustGroup("Internet", mustEntry("Git Server", "joejulian", "https://git.julianfamily.org", "token-1"))),
|
||||
mustGroup("Root", mustGroup("Internet", mustEntry("Vault Console", "dannyocean", "https://vault.crew.example.invalid", "bellagio-pass-1"))),
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -490,11 +534,11 @@ func TestSaveKDBXWithKeyRoundTripsModel(t *testing.T) {
|
||||
model := Model{
|
||||
Entries: []Entry{
|
||||
{
|
||||
ID: "git-server",
|
||||
Title: "Git Server",
|
||||
Username: "joejulian",
|
||||
Password: "token-1",
|
||||
URL: "https://git.julianfamily.org",
|
||||
ID: "vault-console",
|
||||
Title: "Vault Console",
|
||||
Username: "dannyocean",
|
||||
Password: "bellagio-pass-1",
|
||||
URL: "https://vault.crew.example.invalid",
|
||||
Path: []string{"Root", "Internet"},
|
||||
},
|
||||
},
|
||||
@@ -510,9 +554,9 @@ func TestSaveKDBXWithKeyRoundTripsModel(t *testing.T) {
|
||||
t.Fatalf("LoadKDBXWithKey() error = %v", err)
|
||||
}
|
||||
|
||||
got := loaded.Search("git")
|
||||
if len(got) != 1 || got[0].Entry.Password != "token-1" {
|
||||
t.Fatalf("round-trip with key file = %#v, want git entry with password", got)
|
||||
got := loaded.Search("vault")
|
||||
if len(got) != 1 || got[0].Entry.Password != "bellagio-pass-1" {
|
||||
t.Fatalf("round-trip with key file = %#v, want vault entry with password", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -533,12 +577,12 @@ func TestSaveKDBXWithCompositeKeyRoundTripsModel(t *testing.T) {
|
||||
model := Model{
|
||||
Entries: []Entry{
|
||||
{
|
||||
ID: "ha-codex",
|
||||
Title: "Home Assistant (Codex)",
|
||||
Username: "codex",
|
||||
Password: "token-2",
|
||||
URL: "https://lights.julianfamily.org",
|
||||
Path: []string{"Root", "Home Assistant"},
|
||||
ID: "surveillance-console",
|
||||
Title: "Surveillance Console",
|
||||
Username: "bashertarr",
|
||||
Password: "bellagio-pass-2",
|
||||
URL: "https://surveillance.crew.example.invalid",
|
||||
Path: []string{"Root", "Security Office"},
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -558,9 +602,9 @@ func TestSaveKDBXWithCompositeKeyRoundTripsModel(t *testing.T) {
|
||||
t.Fatalf("LoadKDBXWithKey() error = %v", err)
|
||||
}
|
||||
|
||||
got := loaded.EntriesInPath([]string{"Root", "Home Assistant"})
|
||||
if len(got) != 1 || got[0].Password != "token-2" {
|
||||
t.Fatalf("composite key round-trip = %#v, want Home Assistant entry with password", got)
|
||||
got := loaded.EntriesInPath([]string{"Root", "Security Office"})
|
||||
if len(got) != 1 || got[0].Password != "bellagio-pass-2" {
|
||||
t.Fatalf("composite key round-trip = %#v, want Security Office entry with password", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -570,11 +614,11 @@ func TestKDBXRoundTripsEntryAttachments(t *testing.T) {
|
||||
model := Model{
|
||||
Entries: []Entry{
|
||||
{
|
||||
ID: "git-server",
|
||||
Title: "Git Server",
|
||||
Username: "joejulian",
|
||||
Password: "token-1",
|
||||
URL: "https://git.julianfamily.org",
|
||||
ID: "vault-console",
|
||||
Title: "Vault Console",
|
||||
Username: "dannyocean",
|
||||
Password: "bellagio-pass-1",
|
||||
URL: "https://vault.crew.example.invalid",
|
||||
Path: []string{"Root", "Internet"},
|
||||
Attachments: map[string][]byte{
|
||||
"token.txt": []byte("secret attachment contents"),
|
||||
@@ -610,10 +654,10 @@ func TestKDBXReopenCyclesPreserveStableIDsAndCrossFeatureState(t *testing.T) {
|
||||
Entries: []Entry{
|
||||
{
|
||||
ID: "entry-1",
|
||||
Title: "Git Server",
|
||||
Username: "joejulian",
|
||||
Password: "token-2",
|
||||
URL: "https://git.julianfamily.org",
|
||||
Title: "Vault Console",
|
||||
Username: "dannyocean",
|
||||
Password: "bellagio-pass-2",
|
||||
URL: "https://vault.crew.example.invalid",
|
||||
Notes: "Current credential",
|
||||
Path: []string{"Root", "Internet"},
|
||||
Attachments: map[string][]byte{
|
||||
@@ -622,10 +666,10 @@ func TestKDBXReopenCyclesPreserveStableIDsAndCrossFeatureState(t *testing.T) {
|
||||
History: []Entry{
|
||||
{
|
||||
ID: "entry-1-history-1",
|
||||
Title: "Git Server",
|
||||
Username: "joejulian",
|
||||
Password: "token-1",
|
||||
URL: "https://git.julianfamily.org",
|
||||
Title: "Vault Console",
|
||||
Username: "dannyocean",
|
||||
Password: "bellagio-pass-1",
|
||||
URL: "https://vault.crew.example.invalid",
|
||||
Notes: "Original credential",
|
||||
Path: []string{"Root", "Internet"},
|
||||
},
|
||||
@@ -8,6 +8,21 @@ import (
|
||||
|
||||
var ErrEntryNotFound = errors.New("entry not found")
|
||||
var ErrGroupNotEmpty = errors.New("group is not empty")
|
||||
var ErrRemoteProfileNotFound = errors.New("remote profile not found")
|
||||
|
||||
type RemoteBackend string
|
||||
|
||||
const (
|
||||
RemoteBackendWebDAV RemoteBackend = "webdav"
|
||||
)
|
||||
|
||||
type RemoteProfile struct {
|
||||
ID string
|
||||
Name string
|
||||
Backend RemoteBackend
|
||||
BaseURL string
|
||||
Path string
|
||||
}
|
||||
|
||||
type Entry struct {
|
||||
ID string
|
||||
@@ -29,10 +44,11 @@ type SearchResult struct {
|
||||
}
|
||||
|
||||
type Model struct {
|
||||
Entries []Entry
|
||||
Templates []Entry
|
||||
RecycleBin []Entry
|
||||
Groups [][]string
|
||||
Entries []Entry
|
||||
Templates []Entry
|
||||
RecycleBin []Entry
|
||||
Groups [][]string
|
||||
RemoteProfiles []RemoteProfile
|
||||
}
|
||||
|
||||
func (m Model) ChildGroups(path []string) []string {
|
||||
@@ -96,6 +112,27 @@ func (m Model) EntriesInPath(path []string) []Entry {
|
||||
return entries
|
||||
}
|
||||
|
||||
func (m Model) EntriesUnderPath(path []string) []Entry {
|
||||
var entries []Entry
|
||||
for _, entry := range m.Entries {
|
||||
if !hasPathPrefix(entry.Path, path) {
|
||||
continue
|
||||
}
|
||||
entries = append(entries, entry)
|
||||
}
|
||||
slices.SortFunc(entries, func(a, b Entry) int {
|
||||
switch {
|
||||
case a.Title < b.Title:
|
||||
return -1
|
||||
case a.Title > b.Title:
|
||||
return 1
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
})
|
||||
return entries
|
||||
}
|
||||
|
||||
func (m Model) Search(query string) []SearchResult {
|
||||
query = strings.TrimSpace(strings.ToLower(query))
|
||||
if query == "" {
|
||||
@@ -147,6 +184,57 @@ func (m *Model) UpsertEntry(entry Entry) {
|
||||
m.Entries = append(m.Entries, cloneEntry(entry))
|
||||
}
|
||||
|
||||
func (m *Model) RemoveEntryByID(id string) bool {
|
||||
for i := range m.Entries {
|
||||
if m.Entries[i].ID != id {
|
||||
continue
|
||||
}
|
||||
m.Entries = append(m.Entries[:i], m.Entries[i+1:]...)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (m *Model) EntryByID(id string) (Entry, error) {
|
||||
for _, entry := range m.Entries {
|
||||
if entry.ID == id {
|
||||
return cloneEntry(entry), nil
|
||||
}
|
||||
}
|
||||
return Entry{}, ErrEntryNotFound
|
||||
}
|
||||
|
||||
func (m *Model) UpsertRemoteProfile(profile RemoteProfile) {
|
||||
for i := range m.RemoteProfiles {
|
||||
if m.RemoteProfiles[i].ID != profile.ID {
|
||||
continue
|
||||
}
|
||||
m.RemoteProfiles[i] = profile
|
||||
return
|
||||
}
|
||||
m.RemoteProfiles = append(m.RemoteProfiles, profile)
|
||||
}
|
||||
|
||||
func (m *Model) RemoveRemoteProfileByID(id string) bool {
|
||||
for i := range m.RemoteProfiles {
|
||||
if m.RemoteProfiles[i].ID != id {
|
||||
continue
|
||||
}
|
||||
m.RemoteProfiles = append(m.RemoteProfiles[:i], m.RemoteProfiles[i+1:]...)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (m Model) RemoteProfileByID(id string) (RemoteProfile, error) {
|
||||
for _, profile := range m.RemoteProfiles {
|
||||
if profile.ID == id {
|
||||
return profile, nil
|
||||
}
|
||||
}
|
||||
return RemoteProfile{}, ErrRemoteProfileNotFound
|
||||
}
|
||||
|
||||
func (m *Model) UpsertTemplate(entry Entry) {
|
||||
for i := range m.Templates {
|
||||
if m.Templates[i].ID != entry.ID {
|
||||
@@ -256,13 +344,14 @@ func (m *Model) RestoreEntryVersion(id string, historyIndex int) error {
|
||||
}
|
||||
|
||||
func (m *Model) CreateGroup(parent []string, name string) {
|
||||
groupPath := append(append([]string(nil), parent...), name)
|
||||
for _, existing := range m.Groups {
|
||||
if slices.Equal(existing, groupPath) {
|
||||
return
|
||||
groupPath := append([]string(nil), parent...)
|
||||
for _, part := range splitGroupPath(name) {
|
||||
groupPath = append(groupPath, part)
|
||||
if groupPathExists(m.Groups, groupPath) {
|
||||
continue
|
||||
}
|
||||
m.Groups = append(m.Groups, append([]string(nil), groupPath...))
|
||||
}
|
||||
m.Groups = append(m.Groups, groupPath)
|
||||
}
|
||||
|
||||
func (m *Model) RenameGroup(path []string, newName string) error {
|
||||
@@ -310,6 +399,47 @@ func (m *Model) MoveEntry(id string, path []string) error {
|
||||
return ErrEntryNotFound
|
||||
}
|
||||
|
||||
func (m *Model) MoveGroup(path, parent []string) error {
|
||||
if len(path) == 0 {
|
||||
return ErrEntryNotFound
|
||||
}
|
||||
if hasPathPrefix(parent, path) {
|
||||
return ErrEntryNotFound
|
||||
}
|
||||
|
||||
groupName := path[len(path)-1]
|
||||
newPath := append(append([]string(nil), parent...), groupName)
|
||||
moved := false
|
||||
for i := range m.Entries {
|
||||
if !hasPathPrefix(m.Entries[i].Path, path) {
|
||||
continue
|
||||
}
|
||||
m.Entries[i].Path = append(append([]string(nil), newPath...), m.Entries[i].Path[len(path):]...)
|
||||
moved = true
|
||||
}
|
||||
for i := range m.Templates {
|
||||
if !hasPathPrefix(m.Templates[i].Path, path) {
|
||||
continue
|
||||
}
|
||||
m.Templates[i].Path = append(append([]string(nil), newPath...), m.Templates[i].Path[len(path):]...)
|
||||
moved = true
|
||||
}
|
||||
for i := range m.Groups {
|
||||
if !hasPathPrefix(m.Groups[i], path) {
|
||||
continue
|
||||
}
|
||||
m.Groups[i] = append(append([]string(nil), newPath...), m.Groups[i][len(path):]...)
|
||||
moved = true
|
||||
}
|
||||
if !moved {
|
||||
return ErrEntryNotFound
|
||||
}
|
||||
if !groupPathExists(m.Groups, newPath) {
|
||||
m.Groups = append(m.Groups, append([]string(nil), newPath...))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Model) MoveTemplate(id string, path []string) error {
|
||||
for i := range m.Templates {
|
||||
if m.Templates[i].ID != id {
|
||||
@@ -349,6 +479,27 @@ func hasPathPrefix(path, prefix []string) bool {
|
||||
return slices.Equal(path[:len(prefix)], prefix)
|
||||
}
|
||||
|
||||
func splitGroupPath(name string) []string {
|
||||
var parts []string
|
||||
for _, part := range strings.Split(name, "/") {
|
||||
part = strings.TrimSpace(part)
|
||||
if part == "" {
|
||||
continue
|
||||
}
|
||||
parts = append(parts, part)
|
||||
}
|
||||
return parts
|
||||
}
|
||||
|
||||
func groupPathExists(groups [][]string, path []string) bool {
|
||||
for _, existing := range groups {
|
||||
if slices.Equal(existing, path) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func mergeEntryTemplate(template, overrides Entry) Entry {
|
||||
entry := cloneEntry(template)
|
||||
|
||||
@@ -9,9 +9,9 @@ import (
|
||||
func testModel() Model {
|
||||
return Model{
|
||||
Entries: []Entry{
|
||||
{ID: "1", Title: "Dynadot", Username: "jjulian", URL: "https://www.dynadot.com", Path: []string{"Joe", "Internet"}},
|
||||
{ID: "2", Title: "Git Server", Username: "joejulian", URL: "https://git.julianfamily.org", Path: []string{"Joe", "Internet"}},
|
||||
{ID: "3", Title: "Home Assistant (Codex)", Username: "codex", URL: "https://lights.julianfamily.org", Path: []string{"Joe", "Home Assistant"}},
|
||||
{ID: "1", Title: "Bellagio", Username: "rustyryan", URL: "https://bellagio.example.invalid", Path: []string{"Crew", "Internet"}},
|
||||
{ID: "2", Title: "Vault Console", Username: "dannyocean", URL: "https://vault.crew.example.invalid", Path: []string{"Crew", "Internet"}},
|
||||
{ID: "3", Title: "Surveillance Console", Username: "codex", URL: "https://surveillance.crew.example.invalid", Path: []string{"Crew", "Home Assistant"}},
|
||||
{ID: "4", Title: "Alma (WA Prep)", Username: "christina.julian", URL: "https://waprep.getalma.com", Path: []string{"Tricia", "School"}},
|
||||
},
|
||||
}
|
||||
@@ -20,7 +20,7 @@ func testModel() Model {
|
||||
func TestChildGroupsReturnsImmediateGroupsOnly(t *testing.T) {
|
||||
model := testModel()
|
||||
|
||||
got := model.ChildGroups([]string{"Joe"})
|
||||
got := model.ChildGroups([]string{"Crew"})
|
||||
want := []string{"Home Assistant", "Internet"}
|
||||
|
||||
if !slices.Equal(got, want) {
|
||||
@@ -31,30 +31,43 @@ func TestChildGroupsReturnsImmediateGroupsOnly(t *testing.T) {
|
||||
func TestEntriesInPathReturnsOnlyDirectEntries(t *testing.T) {
|
||||
model := testModel()
|
||||
|
||||
got := model.EntriesInPath([]string{"Joe", "Internet"})
|
||||
got := model.EntriesInPath([]string{"Crew", "Internet"})
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("len(EntriesInPath()) = %d, want 2", len(got))
|
||||
}
|
||||
|
||||
if got[0].Title != "Dynadot" || got[1].Title != "Git Server" {
|
||||
if got[0].Title != "Bellagio" || got[1].Title != "Vault Console" {
|
||||
t.Fatalf("EntriesInPath() titles = %q, %q", got[0].Title, got[1].Title)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEntriesUnderPathReturnsDescendantEntries(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
model := testModel()
|
||||
got := model.EntriesUnderPath([]string{"Crew"})
|
||||
if len(got) != 3 {
|
||||
t.Fatalf("len(EntriesUnderPath(Crew)) = %d, want 3", len(got))
|
||||
}
|
||||
if got[0].Title != "Bellagio" || got[1].Title != "Surveillance Console" || got[2].Title != "Vault Console" {
|
||||
t.Fatalf("EntriesUnderPath(Crew) titles = %q, %q, %q", got[0].Title, got[1].Title, got[2].Title)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSearchReturnsMatchesWithFullPathContext(t *testing.T) {
|
||||
model := testModel()
|
||||
|
||||
got := model.Search("git")
|
||||
got := model.Search("vault")
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("len(Search()) = %d, want 1", len(got))
|
||||
}
|
||||
|
||||
if got[0].Entry.Title != "Git Server" {
|
||||
t.Fatalf("Search() title = %q, want %q", got[0].Entry.Title, "Git Server")
|
||||
if got[0].Entry.Title != "Vault Console" {
|
||||
t.Fatalf("Search() title = %q, want %q", got[0].Entry.Title, "Vault Console")
|
||||
}
|
||||
|
||||
if got[0].Path != "Joe / Internet" {
|
||||
t.Fatalf("Search() path = %q, want %q", got[0].Path, "Joe / Internet")
|
||||
if got[0].Path != "Crew / Internet" {
|
||||
t.Fatalf("Search() path = %q, want %q", got[0].Path, "Crew / Internet")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -106,11 +119,11 @@ func TestInstantiateTemplateCreatesNormalEntryWithOverrides(t *testing.T) {
|
||||
|
||||
entry, err := model.InstantiateTemplate("tpl-1", Entry{
|
||||
ID: "entry-1",
|
||||
Title: "Dynadot",
|
||||
Username: "jjulian",
|
||||
Title: "Bellagio",
|
||||
Username: "rustyryan",
|
||||
Password: "hunter2",
|
||||
URL: "https://www.dynadot.com",
|
||||
Path: []string{"Joe", "Internet"},
|
||||
URL: "https://bellagio.example.invalid",
|
||||
Path: []string{"Crew", "Internet"},
|
||||
Tags: []string{"dns"},
|
||||
})
|
||||
if err != nil {
|
||||
@@ -121,11 +134,11 @@ func TestInstantiateTemplateCreatesNormalEntryWithOverrides(t *testing.T) {
|
||||
t.Fatalf("entry.ID = %q, want %q", entry.ID, "entry-1")
|
||||
}
|
||||
|
||||
if entry.Title != "Dynadot" {
|
||||
t.Fatalf("entry.Title = %q, want %q", entry.Title, "Dynadot")
|
||||
if entry.Title != "Bellagio" {
|
||||
t.Fatalf("entry.Title = %q, want %q", entry.Title, "Bellagio")
|
||||
}
|
||||
|
||||
if entry.Username != "jjulian" || entry.Password != "hunter2" || entry.URL != "https://www.dynadot.com" {
|
||||
if entry.Username != "rustyryan" || entry.Password != "hunter2" || entry.URL != "https://bellagio.example.invalid" {
|
||||
t.Fatalf("entry credentials = %#v, want override values", entry)
|
||||
}
|
||||
|
||||
@@ -141,9 +154,9 @@ func TestInstantiateTemplateCreatesNormalEntryWithOverrides(t *testing.T) {
|
||||
t.Fatalf("entry.Fields[Environment] = %q, want %q", entry.Fields["Environment"], "prod")
|
||||
}
|
||||
|
||||
got := model.EntriesInPath([]string{"Joe", "Internet"})
|
||||
if len(got) != 1 || got[0].Title != "Dynadot" {
|
||||
t.Fatalf("EntriesInPath() = %#v, want instantiated Dynadot entry", got)
|
||||
got := model.EntriesInPath([]string{"Crew", "Internet"})
|
||||
if len(got) != 1 || got[0].Title != "Bellagio" {
|
||||
t.Fatalf("EntriesInPath() = %#v, want instantiated Bellagio entry", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -165,7 +178,7 @@ func TestDeleteTemplateRemovesTemplateWithoutTouchingEntries(t *testing.T) {
|
||||
|
||||
model := Model{
|
||||
Entries: []Entry{
|
||||
{ID: "entry-1", Title: "Git Server", Path: []string{"Root", "Internet"}},
|
||||
{ID: "entry-1", Title: "Vault Console", Path: []string{"Root", "Internet"}},
|
||||
},
|
||||
Templates: []Entry{
|
||||
{ID: "tpl-1", Title: "Website Login", Path: []string{"Templates"}},
|
||||
@@ -209,8 +222,8 @@ func TestDuplicateEntryCopiesEntryWithNewIDAndTitle(t *testing.T) {
|
||||
Entries: []Entry{
|
||||
{
|
||||
ID: "entry-1",
|
||||
Title: "Git Server",
|
||||
Username: "joejulian",
|
||||
Title: "Vault Console",
|
||||
Username: "dannyocean",
|
||||
Password: "token-1",
|
||||
Path: []string{"Root", "Internet"},
|
||||
},
|
||||
@@ -225,8 +238,8 @@ func TestDuplicateEntryCopiesEntryWithNewIDAndTitle(t *testing.T) {
|
||||
if duplicate.ID != "entry-2" {
|
||||
t.Fatalf("duplicate.ID = %q, want %q", duplicate.ID, "entry-2")
|
||||
}
|
||||
if duplicate.Title != "Git Server (Copy)" {
|
||||
t.Fatalf("duplicate.Title = %q, want %q", duplicate.Title, "Git Server (Copy)")
|
||||
if duplicate.Title != "Vault Console (Copy)" {
|
||||
t.Fatalf("duplicate.Title = %q, want %q", duplicate.Title, "Vault Console (Copy)")
|
||||
}
|
||||
got := model.EntriesInPath([]string{"Root", "Internet"})
|
||||
if len(got) != 2 {
|
||||
@@ -237,29 +250,45 @@ func TestDuplicateEntryCopiesEntryWithNewIDAndTitle(t *testing.T) {
|
||||
func TestCreateGroupMakesItVisibleAsChildGroup(t *testing.T) {
|
||||
model := testModel()
|
||||
|
||||
model.CreateGroup([]string{"Joe"}, "Finance")
|
||||
model.CreateGroup([]string{"Crew"}, "Finance")
|
||||
|
||||
got := model.ChildGroups([]string{"Joe"})
|
||||
got := model.ChildGroups([]string{"Crew"})
|
||||
want := []string{"Finance", "Home Assistant", "Internet"}
|
||||
if !slices.Equal(got, want) {
|
||||
t.Fatalf("ChildGroups() = %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateGroupSupportsNestedRelativePath(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
model := testModel()
|
||||
model.CreateGroup([]string{"Crew"}, "Infrastructure / Prod")
|
||||
|
||||
got := model.ChildGroups([]string{"Crew"})
|
||||
if !slices.Equal(got, []string{"Home Assistant", "Infrastructure", "Internet"}) {
|
||||
t.Fatalf("ChildGroups(Crew) = %v, want [Home Assistant Infrastructure Internet]", got)
|
||||
}
|
||||
got = model.ChildGroups([]string{"Crew", "Infrastructure"})
|
||||
if !slices.Equal(got, []string{"Prod"}) {
|
||||
t.Fatalf("ChildGroups(Crew/Infrastructure) = %v, want [Prod]", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenameGroupMovesEntriesAndKeepsHierarchy(t *testing.T) {
|
||||
model := testModel()
|
||||
|
||||
if err := model.RenameGroup([]string{"Joe", "Internet"}, "Infra"); err != nil {
|
||||
if err := model.RenameGroup([]string{"Crew", "Internet"}, "Infra"); err != nil {
|
||||
t.Fatalf("RenameGroup() error = %v", err)
|
||||
}
|
||||
|
||||
got := model.EntriesInPath([]string{"Joe", "Infra"})
|
||||
got := model.EntriesInPath([]string{"Crew", "Infra"})
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("len(EntriesInPath(Joe/Infra)) = %d, want 2", len(got))
|
||||
t.Fatalf("len(EntriesInPath(Crew/Infra)) = %d, want 2", len(got))
|
||||
}
|
||||
|
||||
if len(model.EntriesInPath([]string{"Joe", "Internet"})) != 0 {
|
||||
t.Fatal("EntriesInPath(Joe/Internet) should be empty after rename")
|
||||
if len(model.EntriesInPath([]string{"Crew", "Internet"})) != 0 {
|
||||
t.Fatal("EntriesInPath(Crew/Internet) should be empty after rename")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -276,15 +305,37 @@ func TestMoveEntryChangesItsPath(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestMoveGroupMovesEntriesAndNestedGroups(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
model := testModel()
|
||||
model.CreateGroup([]string{"Crew", "Internet"}, "Infrastructure")
|
||||
if err := model.MoveGroup([]string{"Crew", "Internet"}, []string{"Tricia"}); err != nil {
|
||||
t.Fatalf("MoveGroup() error = %v", err)
|
||||
}
|
||||
|
||||
got := model.EntriesInPath([]string{"Tricia", "Internet"})
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("len(EntriesInPath(Tricia/Internet)) = %d, want 2", len(got))
|
||||
}
|
||||
if len(model.EntriesInPath([]string{"Crew", "Internet"})) != 0 {
|
||||
t.Fatal("EntriesInPath(Crew/Internet) should be empty after move")
|
||||
}
|
||||
gotGroups := model.ChildGroups([]string{"Tricia", "Internet"})
|
||||
if !slices.Equal(gotGroups, []string{"Infrastructure"}) {
|
||||
t.Fatalf("ChildGroups(Tricia/Internet) = %v, want [Infrastructure]", gotGroups)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteEmptyGroupRemovesItFromNavigation(t *testing.T) {
|
||||
model := testModel()
|
||||
|
||||
model.CreateGroup([]string{"Joe"}, "Finance")
|
||||
if err := model.DeleteGroup([]string{"Joe", "Finance"}); err != nil {
|
||||
model.CreateGroup([]string{"Crew"}, "Finance")
|
||||
if err := model.DeleteGroup([]string{"Crew", "Finance"}); err != nil {
|
||||
t.Fatalf("DeleteGroup() error = %v", err)
|
||||
}
|
||||
|
||||
got := model.ChildGroups([]string{"Joe"})
|
||||
got := model.ChildGroups([]string{"Crew"})
|
||||
want := []string{"Home Assistant", "Internet"}
|
||||
if !slices.Equal(got, want) {
|
||||
t.Fatalf("ChildGroups() = %v, want %v", got, want)
|
||||
@@ -0,0 +1,100 @@
|
||||
package vault
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"slices"
|
||||
|
||||
"github.com/tobischo/gokeepasslib/v3"
|
||||
)
|
||||
|
||||
type SecuritySettings struct {
|
||||
Cipher string
|
||||
KDF string
|
||||
}
|
||||
|
||||
const (
|
||||
CipherAES256 = "aes256"
|
||||
CipherChaCha20 = "chacha20"
|
||||
KDFAES = "aes-kdf"
|
||||
KDFArgon2 = "argon2"
|
||||
)
|
||||
|
||||
func SupportedSecuritySettings() (ciphers []string, kdfs []string) {
|
||||
return []string{CipherAES256, CipherChaCha20}, []string{KDFAES, KDFArgon2}
|
||||
}
|
||||
|
||||
func DetectSecuritySettings(config *KDBXConfig) SecuritySettings {
|
||||
settings := SecuritySettings{
|
||||
Cipher: CipherChaCha20,
|
||||
KDF: KDFArgon2,
|
||||
}
|
||||
if config == nil || config.Header == nil || config.Header.FileHeaders == nil {
|
||||
return settings
|
||||
}
|
||||
if slices.Equal(config.Header.FileHeaders.CipherID, gokeepasslib.CipherAES) {
|
||||
settings.Cipher = CipherAES256
|
||||
}
|
||||
if config.Header.FileHeaders.KdfParameters != nil && slices.Equal(config.Header.FileHeaders.KdfParameters.UUID, gokeepasslib.KdfAES4) {
|
||||
settings.KDF = KDFAES
|
||||
}
|
||||
return settings
|
||||
}
|
||||
|
||||
func NewSecurityConfig(settings SecuritySettings) (*KDBXConfig, error) {
|
||||
db := gokeepasslib.NewDatabase(gokeepasslib.WithDatabaseKDBXVersion4())
|
||||
config := &KDBXConfig{
|
||||
Header: cloneHeader(db.Header),
|
||||
InnerHeader: cloneInnerHeader(db.Content.InnerHeader),
|
||||
}
|
||||
return ApplySecuritySettings(config, settings)
|
||||
}
|
||||
|
||||
func ApplySecuritySettings(config *KDBXConfig, settings SecuritySettings) (*KDBXConfig, error) {
|
||||
if config == nil || config.Header == nil || config.Header.FileHeaders == nil {
|
||||
return NewSecurityConfig(settings)
|
||||
}
|
||||
out := &KDBXConfig{
|
||||
Header: cloneHeader(config.Header),
|
||||
InnerHeader: cloneInnerHeader(config.InnerHeader),
|
||||
}
|
||||
if out.Header.FileHeaders.KdfParameters == nil {
|
||||
defaults := gokeepasslib.NewDatabase(gokeepasslib.WithDatabaseKDBXVersion4())
|
||||
out.Header.FileHeaders.KdfParameters = cloneHeader(defaults.Header).FileHeaders.KdfParameters
|
||||
}
|
||||
switch settings.Cipher {
|
||||
case "", CipherChaCha20:
|
||||
out.Header.FileHeaders.CipherID = slices.Clone(gokeepasslib.CipherChaCha20)
|
||||
out.Header.FileHeaders.EncryptionIV = randomBytes(12)
|
||||
case CipherAES256:
|
||||
out.Header.FileHeaders.CipherID = slices.Clone(gokeepasslib.CipherAES)
|
||||
out.Header.FileHeaders.EncryptionIV = randomBytes(16)
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported cipher %q", settings.Cipher)
|
||||
}
|
||||
|
||||
var salt [32]byte
|
||||
copy(salt[:], randomBytes(32))
|
||||
switch settings.KDF {
|
||||
case "", KDFArgon2:
|
||||
defaults := gokeepasslib.NewDatabase(gokeepasslib.WithDatabaseKDBXVersion4())
|
||||
kdf := defaults.Header.FileHeaders.KdfParameters
|
||||
out.Header.FileHeaders.KdfParameters = &gokeepasslib.KdfParameters{
|
||||
UUID: slices.Clone(gokeepasslib.KdfArgon2),
|
||||
Rounds: kdf.Rounds,
|
||||
Salt: salt,
|
||||
Parallelism: kdf.Parallelism,
|
||||
Memory: kdf.Memory,
|
||||
Iterations: kdf.Iterations,
|
||||
Version: kdf.Version,
|
||||
}
|
||||
case KDFAES:
|
||||
out.Header.FileHeaders.KdfParameters = &gokeepasslib.KdfParameters{
|
||||
UUID: slices.Clone(gokeepasslib.KdfAES4),
|
||||
Rounds: 6000,
|
||||
Salt: salt,
|
||||
}
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported KDF %q", settings.KDF)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package vault
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"slices"
|
||||
"testing"
|
||||
|
||||
"github.com/tobischo/gokeepasslib/v3"
|
||||
)
|
||||
|
||||
func TestNewSecurityConfigCreatesRequestedCipherAndKDF(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
config, err := NewSecurityConfig(SecuritySettings{Cipher: CipherAES256, KDF: KDFAES})
|
||||
if err != nil {
|
||||
t.Fatalf("NewSecurityConfig() error = %v", err)
|
||||
}
|
||||
if !slices.Equal(config.Header.FileHeaders.CipherID, gokeepasslib.CipherAES) {
|
||||
t.Fatalf("CipherID = %x, want %x", config.Header.FileHeaders.CipherID, gokeepasslib.CipherAES)
|
||||
}
|
||||
if !slices.Equal(config.Header.FileHeaders.KdfParameters.UUID, gokeepasslib.KdfAES4) {
|
||||
t.Fatalf("KDF UUID = %x, want %x", config.Header.FileHeaders.KdfParameters.UUID, gokeepasslib.KdfAES4)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplySecuritySettingsPreservesRequestedChoicesAcrossSave(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
config, err := NewSecurityConfig(SecuritySettings{Cipher: CipherChaCha20, KDF: KDFArgon2})
|
||||
if err != nil {
|
||||
t.Fatalf("NewSecurityConfig() error = %v", err)
|
||||
}
|
||||
config, err = ApplySecuritySettings(config, SecuritySettings{Cipher: CipherAES256, KDF: KDFAES})
|
||||
if err != nil {
|
||||
t.Fatalf("ApplySecuritySettings() error = %v", err)
|
||||
}
|
||||
|
||||
var encoded bytes.Buffer
|
||||
if err := SaveKDBXWithConfigAndKey(&encoded, Model{}, MasterKey{Password: "correct horse battery staple"}, config); err != nil {
|
||||
t.Fatalf("SaveKDBXWithConfigAndKey() error = %v", err)
|
||||
}
|
||||
_, reloadedConfig, err := LoadKDBXWithConfig(bytes.NewReader(encoded.Bytes()), MasterKey{Password: "correct horse battery staple"})
|
||||
if err != nil {
|
||||
t.Fatalf("LoadKDBXWithConfig() error = %v", err)
|
||||
}
|
||||
got := DetectSecuritySettings(reloadedConfig)
|
||||
if got.Cipher != CipherAES256 || got.KDF != KDFAES {
|
||||
t.Fatalf("DetectSecuritySettings() = %#v, want aes256/aes-kdf", got)
|
||||
}
|
||||
}
|
||||
@@ -15,8 +15,8 @@ func TestClientOpenDownloadsRemoteVault(t *testing.T) {
|
||||
if r.Method != http.MethodGet {
|
||||
t.Fatalf("method = %s, want GET", r.Method)
|
||||
}
|
||||
if user, pass, ok := r.BasicAuth(); !ok || user != "jjulian" || pass != "secret" {
|
||||
t.Fatalf("basic auth = %q/%q ok=%v, want jjulian/secret true", user, pass, ok)
|
||||
if user, pass, ok := r.BasicAuth(); !ok || user != "rustyryan" || pass != "secret" {
|
||||
t.Fatalf("basic auth = %q/%q ok=%v, want rustyryan/secret true", user, pass, ok)
|
||||
}
|
||||
w.Header().Set("ETag", `"etag-1"`)
|
||||
_, _ = io.WriteString(w, "vault-bytes")
|
||||
@@ -26,7 +26,7 @@ func TestClientOpenDownloadsRemoteVault(t *testing.T) {
|
||||
client := Client{
|
||||
HTTPClient: server.Client(),
|
||||
BaseURL: server.URL,
|
||||
Username: "jjulian",
|
||||
Username: "rustyryan",
|
||||
Password: "secret",
|
||||
}
|
||||
|
||||
@@ -69,7 +69,7 @@ func TestClientSaveUploadsVaultWithIfMatch(t *testing.T) {
|
||||
client := Client{
|
||||
HTTPClient: server.Client(),
|
||||
BaseURL: server.URL,
|
||||
Username: "jjulian",
|
||||
Username: "rustyryan",
|
||||
Password: "secret",
|
||||
}
|
||||
|
||||
@@ -94,7 +94,7 @@ func TestClientSaveReturnsConflictOnVersionMismatch(t *testing.T) {
|
||||
client := Client{
|
||||
HTTPClient: server.Client(),
|
||||
BaseURL: server.URL,
|
||||
Username: "jjulian",
|
||||
Username: "rustyryan",
|
||||
Password: "secret",
|
||||
}
|
||||
|
||||