Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ab9214af99 | |||
| d1f30f5936 | |||
| 2269944702 | |||
| 2ccd5bc337 | |||
| 9a9d9e7447 | |||
| 2c065a04a4 | |||
| f82ddf7435 | |||
| 14c9bc72f6 | |||
| 515eb730f0 | |||
| d60a8d2fbf | |||
| 4afbc3c933 | |||
| c7d35927f3 | |||
| a6340f5c9e |
+40
-1
@@ -39,6 +39,11 @@ jobs:
|
|||||||
distribution: temurin
|
distribution: temurin
|
||||||
java-version: "25"
|
java-version: "25"
|
||||||
|
|
||||||
|
- name: Setup Node
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: "22"
|
||||||
|
|
||||||
- name: Install native build dependencies
|
- name: Install native build dependencies
|
||||||
shell: bash
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
@@ -47,6 +52,7 @@ jobs:
|
|||||||
apt-get update
|
apt-get update
|
||||||
apt-get install -y --no-install-recommends \
|
apt-get install -y --no-install-recommends \
|
||||||
zsh \
|
zsh \
|
||||||
|
python3 \
|
||||||
pkg-config \
|
pkg-config \
|
||||||
libx11-dev \
|
libx11-dev \
|
||||||
libx11-xcb-dev \
|
libx11-xcb-dev \
|
||||||
@@ -58,6 +64,12 @@ jobs:
|
|||||||
libxcursor-dev \
|
libxcursor-dev \
|
||||||
libxfixes-dev
|
libxfixes-dev
|
||||||
|
|
||||||
|
- name: Install web-ext
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
npm install -g web-ext
|
||||||
|
|
||||||
- name: Lint
|
- name: Lint
|
||||||
shell: bash
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
@@ -74,6 +86,12 @@ jobs:
|
|||||||
trap 'rm -rf -- "$state_dir"' EXIT
|
trap 'rm -rf -- "$state_dir"' EXIT
|
||||||
KEEPASSGO_STATE_DIR="$state_dir" go test -tags nox11,nowayland,novulkan ./...
|
KEEPASSGO_STATE_DIR="$state_dir" go test -tags nox11,nowayland,novulkan ./...
|
||||||
|
|
||||||
|
- name: Firefox extension lint
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
make browser-extension-firefox-lint
|
||||||
|
|
||||||
build:
|
build:
|
||||||
needs: lint-test
|
needs: lint-test
|
||||||
runs-on: keepassgo-android
|
runs-on: keepassgo-android
|
||||||
@@ -92,6 +110,11 @@ jobs:
|
|||||||
distribution: temurin
|
distribution: temurin
|
||||||
java-version: "25"
|
java-version: "25"
|
||||||
|
|
||||||
|
- name: Setup Node
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: "22"
|
||||||
|
|
||||||
- name: Install native build dependencies
|
- name: Install native build dependencies
|
||||||
shell: bash
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
@@ -100,6 +123,7 @@ jobs:
|
|||||||
apt-get update
|
apt-get update
|
||||||
apt-get install -y --no-install-recommends \
|
apt-get install -y --no-install-recommends \
|
||||||
zsh \
|
zsh \
|
||||||
|
python3 \
|
||||||
pkg-config \
|
pkg-config \
|
||||||
libx11-dev \
|
libx11-dev \
|
||||||
libx11-xcb-dev \
|
libx11-xcb-dev \
|
||||||
@@ -111,6 +135,12 @@ jobs:
|
|||||||
libxcursor-dev \
|
libxcursor-dev \
|
||||||
libxfixes-dev
|
libxfixes-dev
|
||||||
|
|
||||||
|
- name: Install web-ext
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
npm install -g web-ext
|
||||||
|
|
||||||
- name: Prepare dist directory
|
- name: Prepare dist directory
|
||||||
shell: bash
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
@@ -159,6 +189,13 @@ jobs:
|
|||||||
make apk-release RELEASE_SIGNKEY="$signkey_path" RELEASE_SIGNPASS_FILE="$signpass_path"
|
make apk-release RELEASE_SIGNKEY="$signkey_path" RELEASE_SIGNPASS_FILE="$signpass_path"
|
||||||
cp build/keepassgo.apk "${DIST_DIR}/keepassgo.apk"
|
cp build/keepassgo.apk "${DIST_DIR}/keepassgo.apk"
|
||||||
|
|
||||||
|
- name: Build Firefox extension
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
make browser-extension-firefox-build
|
||||||
|
cp build/browser-extension/*.zip "${DIST_DIR}/"
|
||||||
|
|
||||||
- name: Upload CI artifacts
|
- name: Upload CI artifacts
|
||||||
uses: christopherhx/gitea-upload-artifact@v4
|
uses: christopherhx/gitea-upload-artifact@v4
|
||||||
env:
|
env:
|
||||||
@@ -171,6 +208,7 @@ jobs:
|
|||||||
dist/keepassgo-windows-amd64.exe
|
dist/keepassgo-windows-amd64.exe
|
||||||
dist/keepassgo-windows-arm64.exe
|
dist/keepassgo-windows-arm64.exe
|
||||||
dist/keepassgo.apk
|
dist/keepassgo.apk
|
||||||
|
dist/*.zip
|
||||||
retention-days: 30
|
retention-days: 30
|
||||||
|
|
||||||
- name: Publish release artifacts
|
- name: Publish release artifacts
|
||||||
@@ -193,4 +231,5 @@ jobs:
|
|||||||
"${DIST_DIR}/keepassgo-linux-amd64" \
|
"${DIST_DIR}/keepassgo-linux-amd64" \
|
||||||
"${DIST_DIR}/keepassgo-windows-amd64.exe" \
|
"${DIST_DIR}/keepassgo-windows-amd64.exe" \
|
||||||
"${DIST_DIR}/keepassgo-windows-arm64.exe" \
|
"${DIST_DIR}/keepassgo-windows-arm64.exe" \
|
||||||
"${DIST_DIR}/keepassgo.apk"
|
"${DIST_DIR}/keepassgo.apk" \
|
||||||
|
"${DIST_DIR}"/*.zip
|
||||||
|
|||||||
@@ -136,6 +136,10 @@ These features are product requirements, not “nice to have” ideas.
|
|||||||
## Delivery Discipline
|
## Delivery Discipline
|
||||||
|
|
||||||
- Treat bug fixes as the highest-priority items in `TODO.md`.
|
- Treat bug fixes as the highest-priority items in `TODO.md`.
|
||||||
|
- Do not start a new feature while unrelated tracked or untracked local changes
|
||||||
|
remain in the repo.
|
||||||
|
- If previous work leaves unrelated uncommitted changes behind, stop and ask
|
||||||
|
the user before continuing with the next feature.
|
||||||
- Do not treat this product as complete until the stated requirements in this file are actually satisfied.
|
- Do not treat this product as complete until the stated requirements in this file are actually satisfied.
|
||||||
- Do not stop at a “good checkpoint” or “meaningful tranche” when required product capabilities are still missing.
|
- Do not stop at a “good checkpoint” or “meaningful tranche” when required product capabilities are still missing.
|
||||||
- Continue iterating in test-first slices:
|
- Continue iterating in test-first slices:
|
||||||
|
|||||||
@@ -20,6 +20,9 @@ ARCH_PKG_TMPL ?= $(ARCH_PKG_DIR)/PKGBUILD.tmpl
|
|||||||
ARCH_PKGBUILD ?= $(ARCH_PKG_DIR)/PKGBUILD
|
ARCH_PKGBUILD ?= $(ARCH_PKG_DIR)/PKGBUILD
|
||||||
ARCH_PKGVER ?= $(shell printf 'r%s.%s' "$$(git rev-list --count HEAD 2>/dev/null || echo 0)" "$$(git rev-parse --short HEAD 2>/dev/null || echo dev)")
|
ARCH_PKGVER ?= $(shell printf 'r%s.%s' "$$(git rev-list --count HEAD 2>/dev/null || echo 0)" "$$(git rev-parse --short HEAD 2>/dev/null || echo dev)")
|
||||||
ARCH_REPO_DIR ?= $(CURDIR)
|
ARCH_REPO_DIR ?= $(CURDIR)
|
||||||
|
WEB_EXT ?= web-ext
|
||||||
|
FIREFOX_EXTENSION_DIR ?= build/firefox-extension
|
||||||
|
FIREFOX_EXTENSION_ARTIFACT_DIR ?= build/browser-extension
|
||||||
|
|
||||||
GOGIO_SIGN_FLAGS :=
|
GOGIO_SIGN_FLAGS :=
|
||||||
ifneq ($(strip $(SIGNKEY)),)
|
ifneq ($(strip $(SIGNKEY)),)
|
||||||
@@ -44,7 +47,7 @@ CONTAINER_SIGNPASSFILE_MOUNT += -v "$(dir $(abspath $(SIGNPASS_FILE))):$(dir $(a
|
|||||||
CONTAINER_SIGN_ARGS += SIGNPASS_FILE="$(abspath $(SIGNPASS_FILE))"
|
CONTAINER_SIGN_ARGS += SIGNPASS_FILE="$(abspath $(SIGNPASS_FILE))"
|
||||||
endif
|
endif
|
||||||
|
|
||||||
.PHONY: apk apk-local apk-release apk-container apk-container-image archlinux-pkgbuild browser-bridge browser-extension-validate
|
.PHONY: apk apk-local apk-release apk-container apk-container-image archlinux-pkgbuild browser-bridge browser-extension-validate browser-extension-firefox-dir browser-extension-firefox-lint browser-extension-firefox-build browser-extension-firefox-run browser-extension-firefox-sign
|
||||||
apk:
|
apk:
|
||||||
@if [ -x "$(JAVA_HOME)/bin/java" ] && "$(JAVA_HOME)/bin/java" -version 2>&1 | grep -q 'version "25'; then \
|
@if [ -x "$(JAVA_HOME)/bin/java" ] && "$(JAVA_HOME)/bin/java" -version 2>&1 | grep -q 'version "25'; then \
|
||||||
$(MAKE) apk-local JAVA_HOME="$(JAVA_HOME)"; \
|
$(MAKE) apk-local JAVA_HOME="$(JAVA_HOME)"; \
|
||||||
@@ -132,6 +135,23 @@ archlinux-pkgbuild: $(ARCH_PKG_TMPL) Makefile
|
|||||||
browser-bridge:
|
browser-bridge:
|
||||||
go build ./cmd/keepassgo-browser-bridge
|
go build ./cmd/keepassgo-browser-bridge
|
||||||
|
|
||||||
|
browser-extension-firefox-dir:
|
||||||
|
@mkdir -p "$(dir $(FIREFOX_EXTENSION_DIR))"
|
||||||
|
@python3 scripts/prepare_firefox_extension.py "$(FIREFOX_EXTENSION_DIR)"
|
||||||
|
|
||||||
|
browser-extension-firefox-lint: browser-extension-firefox-dir
|
||||||
|
$(WEB_EXT) lint --source-dir "$(FIREFOX_EXTENSION_DIR)"
|
||||||
|
|
||||||
|
browser-extension-firefox-build: browser-extension-firefox-dir
|
||||||
|
@mkdir -p "$(FIREFOX_EXTENSION_ARTIFACT_DIR)"
|
||||||
|
$(WEB_EXT) build --source-dir "$(FIREFOX_EXTENSION_DIR)" --artifacts-dir "$(FIREFOX_EXTENSION_ARTIFACT_DIR)"
|
||||||
|
|
||||||
|
browser-extension-firefox-run: browser-extension-firefox-dir
|
||||||
|
$(WEB_EXT) run --source-dir "$(FIREFOX_EXTENSION_DIR)"
|
||||||
|
|
||||||
|
browser-extension-firefox-sign: browser-extension-firefox-dir
|
||||||
|
$(WEB_EXT) sign --source-dir "$(FIREFOX_EXTENSION_DIR)"
|
||||||
|
|
||||||
browser-extension-validate:
|
browser-extension-validate:
|
||||||
@command -v xvfb-run >/dev/null 2>&1 || { echo "xvfb-run is required"; exit 1; }
|
@command -v xvfb-run >/dev/null 2>&1 || { echo "xvfb-run is required"; exit 1; }
|
||||||
@command -v firefox >/dev/null 2>&1 || { echo "firefox is required"; exit 1; }
|
@command -v firefox >/dev/null 2>&1 || { echo "firefox is required"; exit 1; }
|
||||||
|
|||||||
@@ -130,6 +130,98 @@ These are important, but they should likely move behind a dedicated settings gea
|
|||||||
- Accessibility preferences:
|
- Accessibility preferences:
|
||||||
future display-density, contrast, reduced-motion, or keyboard-focus tuning should live under settings.
|
future display-density, contrast, reduced-motion, or keyboard-focus tuning should live under settings.
|
||||||
|
|
||||||
|
## Upstream Gap Review
|
||||||
|
|
||||||
|
This section tracks explicit feature gaps against the source-level behavior of:
|
||||||
|
|
||||||
|
- KeePass 2.57.1
|
||||||
|
- KeePassHttp
|
||||||
|
- Keepass2Android
|
||||||
|
|
||||||
|
These are not speculative enhancements. They are parity gaps relative to the
|
||||||
|
stated product requirement to cover the practical feature surface of those
|
||||||
|
upstream tools where it fits KeePassGO's security model.
|
||||||
|
|
||||||
|
### Stage 1
|
||||||
|
|
||||||
|
- Android autofill parity/completeness:
|
||||||
|
close the remaining gaps in Android autofill behavior, including broader
|
||||||
|
page/app detection coverage, stronger approval and visibility UX, and more
|
||||||
|
reliable fill behavior across real-world apps and browsers.
|
||||||
|
- Android fallback fill workflows:
|
||||||
|
provide a non-autofill fallback comparable in usefulness to KP2A's keyboard
|
||||||
|
and share-driven workflows for apps and browsers that do not cooperate with
|
||||||
|
platform autofill.
|
||||||
|
- Browser extension save/update:
|
||||||
|
add the browser-side save/update-credential workflow after successful form
|
||||||
|
submission, not only lookup and fill.
|
||||||
|
- Search and matching controls:
|
||||||
|
browser/API result behavior does not yet expose KeePassHttp-style controls
|
||||||
|
such as best-match-only, scheme matching, and sort preferences as a finished
|
||||||
|
product surface.
|
||||||
|
- Unlock-request workflow:
|
||||||
|
KeePassHttp has an explicit locked-database browser flow; KeePassGO still
|
||||||
|
needs a polished browser-visible locked/unlock request experience.
|
||||||
|
- Android share/intents:
|
||||||
|
browser/app share-driven lookup and open flows comparable to KP2A are not
|
||||||
|
implemented as a full user workflow.
|
||||||
|
|
||||||
|
### Stage 2
|
||||||
|
|
||||||
|
- OTP/TOTP:
|
||||||
|
implement real OTP/TOTP support, including storage conventions compatible
|
||||||
|
with common KeePass ecosystems and usable display/copy/fill workflows.
|
||||||
|
- TOTP product surface:
|
||||||
|
KP2A exposes TOTP directly in entry and list UX; KeePassGO does not.
|
||||||
|
- Browser-returned field breadth:
|
||||||
|
KeePassHttp can return string fields for browser consumers; KeePassGO does
|
||||||
|
not yet have a finished policy and browser UX for rich field return.
|
||||||
|
- Placeholder and field-reference parity:
|
||||||
|
KeePass-style placeholder expansion, field references, and related command
|
||||||
|
and URL override behavior are not implemented as a product surface.
|
||||||
|
- Offline/work-offline flow:
|
||||||
|
KP2A has explicit offline/cache-oriented remote-file workflows that are more
|
||||||
|
mature than KeePassGO's current user-facing remote behavior.
|
||||||
|
|
||||||
|
### Stage 3
|
||||||
|
|
||||||
|
- Desktop automation:
|
||||||
|
implement desktop login automation comparable in practical capability to
|
||||||
|
KeePass auto-type, or replace it with a demonstrably superior workflow that
|
||||||
|
covers global invocation, selected-entry invocation, window targeting, and
|
||||||
|
field sequencing.
|
||||||
|
- Trigger system:
|
||||||
|
KeePass-style event/condition/action triggers are not implemented.
|
||||||
|
- Import/export breadth:
|
||||||
|
KDBX load/save exists, but KeePass-style breadth for CSV/XML/HTML and other
|
||||||
|
exchange formats is still missing.
|
||||||
|
- Multi-database lookup:
|
||||||
|
KeePassHttp can search across all opened databases when configured; KeePassGO
|
||||||
|
does not yet have an equivalent multi-vault lookup model.
|
||||||
|
- Remote backend breadth:
|
||||||
|
KP2A supports far more remote/file backends than KeePassGO currently does;
|
||||||
|
KeePassGO is still effectively WebDAV-first.
|
||||||
|
- Plugin/extensibility model:
|
||||||
|
KeePassGO has integrations, but not a first-class plugin model comparable to
|
||||||
|
KeePass.
|
||||||
|
- Plugin ecosystem replacements:
|
||||||
|
QR transfer, keyboard transport, and related mobile integration equivalents
|
||||||
|
do not exist in KeePassGO.
|
||||||
|
- Emergency and recovery utilities:
|
||||||
|
emergency-sheet/key-file-backup style flows are not implemented.
|
||||||
|
|
||||||
|
### Immediate Product Questions
|
||||||
|
|
||||||
|
- Desktop automation:
|
||||||
|
decide whether KeePassGO will implement true auto-type, or a different
|
||||||
|
security model that still satisfies the practical workflows KeePass users
|
||||||
|
expect.
|
||||||
|
- OTP model:
|
||||||
|
decide the canonical KeePassGO representation for TOTP/HOTP so desktop,
|
||||||
|
Android, gRPC, and browser workflows can all target the same semantics.
|
||||||
|
- Remote breadth:
|
||||||
|
decide which non-WebDAV backends are in product scope after WebDAV.
|
||||||
|
|
||||||
### Exit Criteria
|
### Exit Criteria
|
||||||
|
|
||||||
- The main workflow screens prioritize opening, browsing, copying, editing, and synchronizing credentials.
|
- The main workflow screens prioritize opening, browsing, copying, editing, and synchronizing credentials.
|
||||||
@@ -269,7 +361,7 @@ Exit criteria:
|
|||||||
- Tests cover clear/reset transitions.
|
- Tests cover clear/reset transitions.
|
||||||
- `go test ./...` passes.
|
- `go test ./...` passes.
|
||||||
|
|
||||||
### Segment 10: Template CRUD UI
|
### Segment 10 (stage 3): Template CRUD UI
|
||||||
|
|
||||||
Scope:
|
Scope:
|
||||||
- Create template.
|
- Create template.
|
||||||
|
|||||||
@@ -35,6 +35,11 @@
|
|||||||
android:name="org.julianfamily.keepassgo.SharedVaultImportActivity"
|
android:name="org.julianfamily.keepassgo.SharedVaultImportActivity"
|
||||||
android:exported="true"
|
android:exported="true"
|
||||||
android:theme="@android:style/Theme.Translucent.NoTitleBar">
|
android:theme="@android:style/Theme.Translucent.NoTitleBar">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.SEND" />
|
||||||
|
<category android:name="android.intent.category.DEFAULT" />
|
||||||
|
<data android:mimeType="text/plain" />
|
||||||
|
</intent-filter>
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.intent.action.SEND" />
|
<action android:name="android.intent.action.SEND" />
|
||||||
<category android:name="android.intent.category.DEFAULT" />
|
<category android:name="android.intent.category.DEFAULT" />
|
||||||
@@ -42,6 +47,13 @@
|
|||||||
<data android:mimeType="application/x-keepass2" />
|
<data android:mimeType="application/x-keepass2" />
|
||||||
<data android:mimeType="application/vnd.keepass" />
|
<data android:mimeType="application/vnd.keepass" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.VIEW" />
|
||||||
|
<category android:name="android.intent.category.DEFAULT" />
|
||||||
|
<category android:name="android.intent.category.BROWSABLE" />
|
||||||
|
<data android:scheme="http" />
|
||||||
|
<data android:scheme="https" />
|
||||||
|
</intent-filter>
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.intent.action.VIEW" />
|
<action android:name="android.intent.action.VIEW" />
|
||||||
<category android:name="android.intent.category.DEFAULT" />
|
<category android:name="android.intent.category.DEFAULT" />
|
||||||
|
|||||||
@@ -25,26 +25,7 @@ final class AutofillCacheStore {
|
|||||||
if (entries.isEmpty()) {
|
if (entries.isEmpty()) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
NormalizedTarget target = normalizeURL(webDomain);
|
return fromMatcherEntry(AutofillTargetMatcher.findBestMatch(toMatcherEntries(entries), webDomain));
|
||||||
if (target.host.isEmpty()) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
List<Entry> exactHost = new ArrayList<>();
|
|
||||||
List<Entry> parentHost = new ArrayList<>();
|
|
||||||
for (Entry entry : entries) {
|
|
||||||
if (entryMatchesHost(entry, target.host)) {
|
|
||||||
exactHost.add(entry);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (entryMatchesParentHost(entry, target.host)) {
|
|
||||||
parentHost.add(entry);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Entry matched = chooseEntry(target, exactHost);
|
|
||||||
if (matched != null) {
|
|
||||||
return matched;
|
|
||||||
}
|
|
||||||
return chooseEntry(target, parentHost);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
static Entry findByID(Context context, String entryID) {
|
static Entry findByID(Context context, String entryID) {
|
||||||
@@ -64,7 +45,7 @@ final class AutofillCacheStore {
|
|||||||
if (entries.isEmpty()) {
|
if (entries.isEmpty()) {
|
||||||
return entries;
|
return entries;
|
||||||
}
|
}
|
||||||
Entry direct = findBestMatch(context, rawTarget);
|
Entry direct = fromMatcherEntry(AutofillTargetMatcher.findBestMatch(toMatcherEntries(entries), rawTarget));
|
||||||
if (direct != null) {
|
if (direct != null) {
|
||||||
List<Entry> resolved = new ArrayList<>();
|
List<Entry> resolved = new ArrayList<>();
|
||||||
resolved.add(direct);
|
resolved.add(direct);
|
||||||
@@ -77,6 +58,30 @@ final class AutofillCacheStore {
|
|||||||
return entries;
|
return entries;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static List<AutofillTargetMatcher.Entry> toMatcherEntries(List<Entry> entries) {
|
||||||
|
List<AutofillTargetMatcher.Entry> converted = new ArrayList<>(entries.size());
|
||||||
|
for (Entry entry : entries) {
|
||||||
|
converted.add(new AutofillTargetMatcher.Entry(
|
||||||
|
entry.id,
|
||||||
|
entry.title,
|
||||||
|
entry.username,
|
||||||
|
entry.password,
|
||||||
|
entry.host,
|
||||||
|
entry.url,
|
||||||
|
entry.targets,
|
||||||
|
entry.path
|
||||||
|
));
|
||||||
|
}
|
||||||
|
return converted;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Entry fromMatcherEntry(AutofillTargetMatcher.Entry entry) {
|
||||||
|
if (entry == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return new Entry(entry.id, entry.title, entry.username, entry.password, entry.host, entry.url, entry.targets, entry.path);
|
||||||
|
}
|
||||||
|
|
||||||
private static List<Entry> readEntries(Context context) {
|
private static List<Entry> readEntries(Context context) {
|
||||||
File cacheFile = findCacheFile(context);
|
File cacheFile = findCacheFile(context);
|
||||||
if (cacheFile == null) {
|
if (cacheFile == null) {
|
||||||
@@ -199,143 +204,7 @@ final class AutofillCacheStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private static String normalizeHost(String raw) {
|
private static String normalizeHost(String raw) {
|
||||||
return normalizeURL(raw).host;
|
return AutofillTargetMatcher.normalize(raw).host;
|
||||||
}
|
|
||||||
|
|
||||||
private static NormalizedTarget normalizeURL(String raw) {
|
|
||||||
if (raw == null) {
|
|
||||||
return new NormalizedTarget("", "", "");
|
|
||||||
}
|
|
||||||
String value = raw.trim().toLowerCase(Locale.US);
|
|
||||||
if (value.startsWith("http://")) {
|
|
||||||
value = value.substring("http://".length());
|
|
||||||
} else if (value.startsWith("https://")) {
|
|
||||||
value = value.substring("https://".length());
|
|
||||||
}
|
|
||||||
int slash = value.indexOf('/');
|
|
||||||
if (slash >= 0) {
|
|
||||||
value = value.substring(0, slash);
|
|
||||||
}
|
|
||||||
int colon = value.indexOf(':');
|
|
||||||
if (colon >= 0) {
|
|
||||||
value = value.substring(0, colon);
|
|
||||||
}
|
|
||||||
String host = value;
|
|
||||||
String path = "/";
|
|
||||||
int schemeSep = raw.indexOf("://");
|
|
||||||
String original = raw.trim();
|
|
||||||
if (schemeSep < 0) {
|
|
||||||
original = "https://" + original;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
java.net.URI uri = java.net.URI.create(original);
|
|
||||||
if (uri.getHost() != null) {
|
|
||||||
host = uri.getHost().toLowerCase(Locale.US);
|
|
||||||
}
|
|
||||||
path = cleanPath(uri.getPath());
|
|
||||||
} catch (IllegalArgumentException ignored) {
|
|
||||||
path = "/";
|
|
||||||
}
|
|
||||||
return new NormalizedTarget(host, path, host + path);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static String cleanPath(String raw) {
|
|
||||||
if (raw == null || raw.trim().isEmpty() || "/".equals(raw.trim())) {
|
|
||||||
return "/";
|
|
||||||
}
|
|
||||||
String value = raw.trim();
|
|
||||||
while (value.endsWith("/") && value.length() > 1) {
|
|
||||||
value = value.substring(0, value.length() - 1);
|
|
||||||
}
|
|
||||||
if (!value.startsWith("/")) {
|
|
||||||
value = "/" + value;
|
|
||||||
}
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static Entry chooseEntry(NormalizedTarget target, List<Entry> entries) {
|
|
||||||
if (entries.isEmpty()) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
if (entries.size() == 1) {
|
|
||||||
return entries.get(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
List<Entry> exact = new ArrayList<>();
|
|
||||||
List<Entry> prefix = new ArrayList<>();
|
|
||||||
int bestPrefixLen = -1;
|
|
||||||
for (Entry entry : entries) {
|
|
||||||
MatchQuality quality = bestTargetMatch(entry, target);
|
|
||||||
if (quality.exact) {
|
|
||||||
exact.add(entry);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (quality.prefixLength <= 0) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (quality.prefixLength > bestPrefixLen) {
|
|
||||||
prefix.clear();
|
|
||||||
prefix.add(entry);
|
|
||||||
bestPrefixLen = quality.prefixLength;
|
|
||||||
} else if (quality.prefixLength == bestPrefixLen) {
|
|
||||||
prefix.add(entry);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (exact.size() == 1) {
|
|
||||||
return exact.get(0);
|
|
||||||
}
|
|
||||||
if (exact.size() > 1 || prefix.isEmpty()) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return prefix.size() == 1 ? prefix.get(0) : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static boolean entryMatchesHost(Entry entry, String host) {
|
|
||||||
for (NormalizedTarget target : entryTargets(entry)) {
|
|
||||||
if (target.host.equals(host)) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static boolean entryMatchesParentHost(Entry entry, String host) {
|
|
||||||
for (NormalizedTarget target : entryTargets(entry)) {
|
|
||||||
if (!target.host.isEmpty() && host.endsWith("." + target.host)) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static List<NormalizedTarget> entryTargets(Entry entry) {
|
|
||||||
List<String> rawTargets = entry.targets;
|
|
||||||
if (rawTargets.isEmpty()) {
|
|
||||||
rawTargets = new ArrayList<>();
|
|
||||||
rawTargets.add(entry.url);
|
|
||||||
}
|
|
||||||
List<NormalizedTarget> targets = new ArrayList<>();
|
|
||||||
for (String rawTarget : rawTargets) {
|
|
||||||
NormalizedTarget target = normalizeURL(rawTarget);
|
|
||||||
if (!target.host.isEmpty()) {
|
|
||||||
targets.add(target);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return targets;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static MatchQuality bestTargetMatch(Entry entry, NormalizedTarget target) {
|
|
||||||
int bestPrefixLen = -1;
|
|
||||||
for (NormalizedTarget entryTarget : entryTargets(entry)) {
|
|
||||||
if (entryTarget.url.equals(target.url)) {
|
|
||||||
return new MatchQuality(true, 0);
|
|
||||||
}
|
|
||||||
if (!"/".equals(entryTarget.path) && target.path.startsWith(entryTarget.path)) {
|
|
||||||
bestPrefixLen = Math.max(bestPrefixLen, entryTarget.path.length());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return new MatchQuality(false, bestPrefixLen);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
static final class Entry {
|
static final class Entry {
|
||||||
@@ -359,26 +228,4 @@ final class AutofillCacheStore {
|
|||||||
this.path = new ArrayList<>(path);
|
this.path = new ArrayList<>(path);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static final class MatchQuality {
|
|
||||||
final boolean exact;
|
|
||||||
final int prefixLength;
|
|
||||||
|
|
||||||
MatchQuality(boolean exact, int prefixLength) {
|
|
||||||
this.exact = exact;
|
|
||||||
this.prefixLength = prefixLength;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static final class NormalizedTarget {
|
|
||||||
final String host;
|
|
||||||
final String path;
|
|
||||||
final String url;
|
|
||||||
|
|
||||||
NormalizedTarget(String host, String path, String url) {
|
|
||||||
this.host = host;
|
|
||||||
this.path = path;
|
|
||||||
this.url = url;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
package org.julianfamily.keepassgo;
|
||||||
|
|
||||||
|
final class AutofillFallbackTarget {
|
||||||
|
private static final String APP_SCHEME = "androidapp://";
|
||||||
|
|
||||||
|
private AutofillFallbackTarget() {
|
||||||
|
}
|
||||||
|
|
||||||
|
static String resolve(String packageName, String webDomain) {
|
||||||
|
String domain = trim(webDomain);
|
||||||
|
if (!domain.isEmpty()) {
|
||||||
|
return domain;
|
||||||
|
}
|
||||||
|
String pkg = trim(packageName);
|
||||||
|
if (pkg.isEmpty()) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
return APP_SCHEME + pkg;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String trim(String value) {
|
||||||
|
return value == null ? "" : value.trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,260 @@
|
|||||||
|
package org.julianfamily.keepassgo;
|
||||||
|
|
||||||
|
import java.net.URI;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Comparator;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Locale;
|
||||||
|
|
||||||
|
final class AutofillTargetMatcher {
|
||||||
|
private AutofillTargetMatcher() {
|
||||||
|
}
|
||||||
|
|
||||||
|
static Entry findBestMatch(List<Entry> entries, String rawTarget) {
|
||||||
|
if (entries == null || entries.isEmpty()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
NormalizedTarget target = normalize(rawTarget);
|
||||||
|
if (target.host.isEmpty()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
List<Entry> exactHost = new ArrayList<>();
|
||||||
|
List<Entry> parentHost = new ArrayList<>();
|
||||||
|
for (Entry entry : entries) {
|
||||||
|
if (entryMatchesHost(entry, target.host)) {
|
||||||
|
exactHost.add(entry);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (entryMatchesParentHost(entry, target.host)) {
|
||||||
|
parentHost.add(entry);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Entry matched = chooseEntry(target, exactHost);
|
||||||
|
if (matched != null) {
|
||||||
|
return matched;
|
||||||
|
}
|
||||||
|
return chooseEntry(target, parentHost);
|
||||||
|
}
|
||||||
|
|
||||||
|
static List<Entry> chooserCandidates(List<Entry> entries, String rawTarget) {
|
||||||
|
if (entries == null || entries.isEmpty()) {
|
||||||
|
return new ArrayList<>();
|
||||||
|
}
|
||||||
|
Entry direct = findBestMatch(entries, rawTarget);
|
||||||
|
if (direct != null) {
|
||||||
|
List<Entry> resolved = new ArrayList<>();
|
||||||
|
resolved.add(direct);
|
||||||
|
return resolved;
|
||||||
|
}
|
||||||
|
NormalizedTarget target = normalize(rawTarget);
|
||||||
|
List<Entry> exactHost = new ArrayList<>();
|
||||||
|
List<Entry> parentHost = new ArrayList<>();
|
||||||
|
for (Entry entry : entries) {
|
||||||
|
if (entryMatchesHost(entry, target.host)) {
|
||||||
|
exactHost.add(entry);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (entryMatchesParentHost(entry, target.host)) {
|
||||||
|
parentHost.add(entry);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!exactHost.isEmpty()) {
|
||||||
|
return sortEntries(exactHost);
|
||||||
|
}
|
||||||
|
if (!parentHost.isEmpty()) {
|
||||||
|
return sortEntries(parentHost);
|
||||||
|
}
|
||||||
|
return sortEntries(entries);
|
||||||
|
}
|
||||||
|
|
||||||
|
static NormalizedTarget normalize(String raw) {
|
||||||
|
if (raw == null) {
|
||||||
|
return new NormalizedTarget("", "", "");
|
||||||
|
}
|
||||||
|
String trimmed = raw.trim();
|
||||||
|
if (trimmed.isEmpty()) {
|
||||||
|
return new NormalizedTarget("", "", "");
|
||||||
|
}
|
||||||
|
String original = trimmed;
|
||||||
|
String value = trimmed.toLowerCase(Locale.US);
|
||||||
|
if (value.startsWith("http://")) {
|
||||||
|
value = value.substring("http://".length());
|
||||||
|
} else if (value.startsWith("https://")) {
|
||||||
|
value = value.substring("https://".length());
|
||||||
|
}
|
||||||
|
int slash = value.indexOf('/');
|
||||||
|
if (slash >= 0) {
|
||||||
|
value = value.substring(0, slash);
|
||||||
|
}
|
||||||
|
int colon = value.indexOf(':');
|
||||||
|
if (colon >= 0) {
|
||||||
|
value = value.substring(0, colon);
|
||||||
|
}
|
||||||
|
String host = value;
|
||||||
|
String path = "/";
|
||||||
|
int schemeSep = original.indexOf("://");
|
||||||
|
if (schemeSep < 0) {
|
||||||
|
original = "https://" + original;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
URI uri = URI.create(original);
|
||||||
|
if (uri.getHost() != null) {
|
||||||
|
host = uri.getHost().toLowerCase(Locale.US);
|
||||||
|
}
|
||||||
|
path = cleanPath(uri.getPath());
|
||||||
|
} catch (IllegalArgumentException ignored) {
|
||||||
|
path = "/";
|
||||||
|
}
|
||||||
|
return new NormalizedTarget(host, path, host + path);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String cleanPath(String raw) {
|
||||||
|
if (raw == null || raw.trim().isEmpty() || "/".equals(raw.trim())) {
|
||||||
|
return "/";
|
||||||
|
}
|
||||||
|
String value = raw.trim();
|
||||||
|
while (value.endsWith("/") && value.length() > 1) {
|
||||||
|
value = value.substring(0, value.length() - 1);
|
||||||
|
}
|
||||||
|
if (!value.startsWith("/")) {
|
||||||
|
value = "/" + value;
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Entry chooseEntry(NormalizedTarget target, List<Entry> entries) {
|
||||||
|
if (entries.isEmpty()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (entries.size() == 1) {
|
||||||
|
return entries.get(0);
|
||||||
|
}
|
||||||
|
List<Entry> exact = new ArrayList<>();
|
||||||
|
List<Entry> prefix = new ArrayList<>();
|
||||||
|
int bestPrefixLen = -1;
|
||||||
|
for (Entry entry : entries) {
|
||||||
|
MatchQuality quality = bestTargetMatch(entry, target);
|
||||||
|
if (quality.exact) {
|
||||||
|
exact.add(entry);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (quality.prefixLength <= 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (quality.prefixLength > bestPrefixLen) {
|
||||||
|
prefix.clear();
|
||||||
|
prefix.add(entry);
|
||||||
|
bestPrefixLen = quality.prefixLength;
|
||||||
|
} else if (quality.prefixLength == bestPrefixLen) {
|
||||||
|
prefix.add(entry);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (exact.size() == 1) {
|
||||||
|
return exact.get(0);
|
||||||
|
}
|
||||||
|
if (exact.size() > 1 || prefix.isEmpty()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return prefix.size() == 1 ? prefix.get(0) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean entryMatchesHost(Entry entry, String host) {
|
||||||
|
for (NormalizedTarget target : entryTargets(entry)) {
|
||||||
|
if (target.host.equals(host)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean entryMatchesParentHost(Entry entry, String host) {
|
||||||
|
for (NormalizedTarget target : entryTargets(entry)) {
|
||||||
|
if (!target.host.isEmpty() && host.endsWith("." + target.host)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<NormalizedTarget> entryTargets(Entry entry) {
|
||||||
|
List<String> rawTargets = entry.targets;
|
||||||
|
if (rawTargets.isEmpty()) {
|
||||||
|
rawTargets = new ArrayList<>();
|
||||||
|
rawTargets.add(entry.url);
|
||||||
|
}
|
||||||
|
List<NormalizedTarget> targets = new ArrayList<>();
|
||||||
|
for (String rawTarget : rawTargets) {
|
||||||
|
NormalizedTarget target = normalize(rawTarget);
|
||||||
|
if (!target.host.isEmpty()) {
|
||||||
|
targets.add(target);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return targets;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static MatchQuality bestTargetMatch(Entry entry, NormalizedTarget target) {
|
||||||
|
int bestPrefixLen = -1;
|
||||||
|
for (NormalizedTarget entryTarget : entryTargets(entry)) {
|
||||||
|
if (entryTarget.url.equals(target.url)) {
|
||||||
|
return new MatchQuality(true, 0);
|
||||||
|
}
|
||||||
|
if (!"/".equals(entryTarget.path) && target.path.startsWith(entryTarget.path)) {
|
||||||
|
bestPrefixLen = Math.max(bestPrefixLen, entryTarget.path.length());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return new MatchQuality(false, bestPrefixLen);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<Entry> sortEntries(List<Entry> entries) {
|
||||||
|
List<Entry> sorted = new ArrayList<>(entries);
|
||||||
|
sorted.sort(Comparator
|
||||||
|
.comparing((Entry entry) -> entry.title.toLowerCase(Locale.US))
|
||||||
|
.thenComparing(entry -> String.join("/", entry.path).toLowerCase(Locale.US))
|
||||||
|
.thenComparing(entry -> entry.id));
|
||||||
|
return sorted;
|
||||||
|
}
|
||||||
|
|
||||||
|
static final class NormalizedTarget {
|
||||||
|
final String host;
|
||||||
|
final String path;
|
||||||
|
final String url;
|
||||||
|
|
||||||
|
NormalizedTarget(String host, String path, String url) {
|
||||||
|
this.host = host;
|
||||||
|
this.path = path;
|
||||||
|
this.url = url;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static final class Entry {
|
||||||
|
final String id;
|
||||||
|
final String title;
|
||||||
|
final String username;
|
||||||
|
final String password;
|
||||||
|
final String host;
|
||||||
|
final String url;
|
||||||
|
final List<String> targets;
|
||||||
|
final List<String> path;
|
||||||
|
|
||||||
|
Entry(String id, String title, String username, String password, String host, String url, List<String> targets, List<String> path) {
|
||||||
|
this.id = id;
|
||||||
|
this.title = title;
|
||||||
|
this.username = username;
|
||||||
|
this.password = password;
|
||||||
|
this.host = host;
|
||||||
|
this.url = url;
|
||||||
|
this.targets = new ArrayList<>(targets);
|
||||||
|
this.path = new ArrayList<>(path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static final class MatchQuality {
|
||||||
|
final boolean exact;
|
||||||
|
final int prefixLength;
|
||||||
|
|
||||||
|
MatchQuality(boolean exact, int prefixLength) {
|
||||||
|
this.exact = exact;
|
||||||
|
this.prefixLength = prefixLength;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,7 +12,6 @@ import java.util.List;
|
|||||||
|
|
||||||
public final class KeePassGOAccessibilityService extends AccessibilityService {
|
public final class KeePassGOAccessibilityService extends AccessibilityService {
|
||||||
private static final String TAG = "KeePassGOA11y";
|
private static final String TAG = "KeePassGOA11y";
|
||||||
|
|
||||||
private String lastFilledSignature = "";
|
private String lastFilledSignature = "";
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@@ -22,22 +21,17 @@ public final class KeePassGOAccessibilityService extends AccessibilityService {
|
|||||||
if (root == null) {
|
if (root == null) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
CharSequence packageName = root.getPackageName();
|
ChromeForm form = inspectWindow(root);
|
||||||
if (packageName == null || !"com.android.chrome".contentEquals(packageName)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
ChromeForm form = inspectChrome(root);
|
|
||||||
if (form == null || form.passwordField == null) {
|
if (form == null || form.passwordField == null) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
AutofillCacheStore.Entry entry = AutofillCacheStore.findBestMatch(this, form.url);
|
AutofillCacheStore.Entry entry = AutofillCacheStore.findBestMatch(this, form.matchTarget);
|
||||||
if (entry == null) {
|
if (entry == null) {
|
||||||
Log.i(TAG, "no accessibility-fill match for " + form.url);
|
Log.i(TAG, "no accessibility-fill match for " + form.matchTarget);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
String signature = form.url + "|" + entry.username + "|" + nodeKey(form.passwordField);
|
String signature = form.matchTarget + "|" + entry.username + "|" + nodeKey(form.passwordField);
|
||||||
if (signature.equals(lastFilledSignature)) {
|
if (signature.equals(lastFilledSignature)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -49,7 +43,7 @@ public final class KeePassGOAccessibilityService extends AccessibilityService {
|
|||||||
filled |= setNodeText(form.passwordField, entry.password);
|
filled |= setNodeText(form.passwordField, entry.password);
|
||||||
if (filled) {
|
if (filled) {
|
||||||
lastFilledSignature = signature;
|
lastFilledSignature = signature;
|
||||||
Log.i(TAG, "filled login form for " + form.url + " with " + entry.username);
|
Log.i(TAG, "filled login form for " + form.matchTarget + " with " + entry.username);
|
||||||
}
|
}
|
||||||
} catch (Exception err) {
|
} catch (Exception err) {
|
||||||
Log.e(TAG, "accessibility fill failed", err);
|
Log.e(TAG, "accessibility fill failed", err);
|
||||||
@@ -61,11 +55,11 @@ public final class KeePassGOAccessibilityService extends AccessibilityService {
|
|||||||
Log.i(TAG, "accessibility service interrupted");
|
Log.i(TAG, "accessibility service interrupted");
|
||||||
}
|
}
|
||||||
|
|
||||||
private static ChromeForm inspectChrome(AccessibilityNodeInfo root) {
|
private static ChromeForm inspectWindow(AccessibilityNodeInfo root) {
|
||||||
List<AccessibilityNodeInfo> editables = new ArrayList<>();
|
List<AccessibilityNodeInfo> editables = new ArrayList<>();
|
||||||
ChromeForm form = new ChromeForm();
|
ChromeForm form = new ChromeForm();
|
||||||
walk(root, editables, form);
|
walk(root, editables, form);
|
||||||
if (form.url.isEmpty()) {
|
if (form.matchTarget.isEmpty()) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
if (form.passwordField == null) {
|
if (form.passwordField == null) {
|
||||||
@@ -111,7 +105,13 @@ public final class KeePassGOAccessibilityService extends AccessibilityService {
|
|||||||
if (viewID != null && viewID.toString().endsWith(":id/url_bar")) {
|
if (viewID != null && viewID.toString().endsWith(":id/url_bar")) {
|
||||||
CharSequence text = node.getText();
|
CharSequence text = node.getText();
|
||||||
if (text != null) {
|
if (text != null) {
|
||||||
form.url = text.toString().trim();
|
form.webDomain = text.toString().trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (form.packageName.isEmpty()) {
|
||||||
|
CharSequence packageName = node.getPackageName();
|
||||||
|
if (packageName != null) {
|
||||||
|
form.packageName = packageName.toString().trim();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (node.isEditable()) {
|
if (node.isEditable()) {
|
||||||
@@ -128,6 +128,7 @@ public final class KeePassGOAccessibilityService extends AccessibilityService {
|
|||||||
walk(child, editables, form);
|
walk(child, editables, form);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
form.matchTarget = AutofillFallbackTarget.resolve(form.packageName, form.webDomain);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static boolean isPasswordNode(AccessibilityNodeInfo node) {
|
private static boolean isPasswordNode(AccessibilityNodeInfo node) {
|
||||||
@@ -188,7 +189,9 @@ public final class KeePassGOAccessibilityService extends AccessibilityService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private static final class ChromeForm {
|
private static final class ChromeForm {
|
||||||
String url = "";
|
String packageName = "";
|
||||||
|
String webDomain = "";
|
||||||
|
String matchTarget = "";
|
||||||
AccessibilityNodeInfo usernameField;
|
AccessibilityNodeInfo usernameField;
|
||||||
AccessibilityNodeInfo passwordField;
|
AccessibilityNodeInfo passwordField;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,6 +20,9 @@ import java.util.ArrayList;
|
|||||||
public final class SharedVaultImportActivity extends Activity {
|
public final class SharedVaultImportActivity extends Activity {
|
||||||
private static final String TAG = "KeePassGOImport";
|
private static final String TAG = "KeePassGOImport";
|
||||||
private static final String DEFAULT_NAME = "shared-vault.kdbx";
|
private static final String DEFAULT_NAME = "shared-vault.kdbx";
|
||||||
|
private static final String PENDING_SHARED_VAULT = "pending-shared-vault.kdbx";
|
||||||
|
private static final String PENDING_SHARED_VAULT_NAME = "pending-shared-vault-name.txt";
|
||||||
|
private static final String PENDING_SHARED_LOOKUP = "pending-shared-lookup.txt";
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
protected void onCreate(Bundle state) {
|
protected void onCreate(Bundle state) {
|
||||||
@@ -40,6 +43,16 @@ public final class SharedVaultImportActivity extends Activity {
|
|||||||
|
|
||||||
private void handleIntent(Intent intent) {
|
private void handleIntent(Intent intent) {
|
||||||
logIntent(intent);
|
logIntent(intent);
|
||||||
|
String sharedLookup = resolveSharedLookup(intent);
|
||||||
|
if (!sharedLookup.isEmpty()) {
|
||||||
|
try {
|
||||||
|
persistPendingLookup(sharedLookup);
|
||||||
|
Log.i(TAG, "queued shared lookup target");
|
||||||
|
} catch (IOException | RuntimeException err) {
|
||||||
|
Log.e(TAG, "failed to queue shared lookup target", err);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
Uri uri = resolveSharedUri(intent);
|
Uri uri = resolveSharedUri(intent);
|
||||||
if (uri == null) {
|
if (uri == null) {
|
||||||
Log.i(TAG, "no shared vault URI on intent");
|
Log.i(TAG, "no shared vault URI on intent");
|
||||||
@@ -86,12 +99,35 @@ public final class SharedVaultImportActivity extends Activity {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private String resolveSharedLookup(Intent intent) {
|
||||||
|
if (intent == null) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
String action = intent.getAction();
|
||||||
|
if (Intent.ACTION_SEND.equals(action) && "text/plain".equalsIgnoreCase(intent.getType())) {
|
||||||
|
CharSequence extraText = intent.getCharSequenceExtra(Intent.EXTRA_TEXT);
|
||||||
|
if (extraText != null) {
|
||||||
|
return extraText.toString().trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (Intent.ACTION_VIEW.equals(action)) {
|
||||||
|
Uri data = intent.getData();
|
||||||
|
if (data != null) {
|
||||||
|
String scheme = data.getScheme();
|
||||||
|
if ("http".equalsIgnoreCase(scheme) || "https".equalsIgnoreCase(scheme)) {
|
||||||
|
return data.toString();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
private void persistPendingImport(Uri uri) throws IOException {
|
private void persistPendingImport(Uri uri) throws IOException {
|
||||||
File dir = new File(getFilesDir(), "keepassgo");
|
File dir = new File(getFilesDir(), "keepassgo");
|
||||||
if (!dir.exists() && !dir.mkdirs()) {
|
if (!dir.exists() && !dir.mkdirs()) {
|
||||||
throw new IOException("failed to create " + dir.getAbsolutePath());
|
throw new IOException("failed to create " + dir.getAbsolutePath());
|
||||||
}
|
}
|
||||||
File pendingFile = new File(dir, "pending-shared-vault.kdbx");
|
File pendingFile = new File(dir, PENDING_SHARED_VAULT);
|
||||||
try (InputStream in = openSharedInputStream(uri)) {
|
try (InputStream in = openSharedInputStream(uri)) {
|
||||||
if (in == null) {
|
if (in == null) {
|
||||||
throw new IOException("failed to open shared vault stream");
|
throw new IOException("failed to open shared vault stream");
|
||||||
@@ -105,12 +141,23 @@ public final class SharedVaultImportActivity extends Activity {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
File nameFile = new File(dir, "pending-shared-vault-name.txt");
|
File nameFile = new File(dir, PENDING_SHARED_VAULT_NAME);
|
||||||
try (FileOutputStream out = new FileOutputStream(nameFile, false)) {
|
try (FileOutputStream out = new FileOutputStream(nameFile, false)) {
|
||||||
out.write(resolveDisplayName(uri).getBytes(StandardCharsets.UTF_8));
|
out.write(resolveDisplayName(uri).getBytes(StandardCharsets.UTF_8));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void persistPendingLookup(String lookup) throws IOException {
|
||||||
|
File dir = new File(getFilesDir(), "keepassgo");
|
||||||
|
if (!dir.exists() && !dir.mkdirs()) {
|
||||||
|
throw new IOException("failed to create " + dir.getAbsolutePath());
|
||||||
|
}
|
||||||
|
File pendingFile = new File(dir, PENDING_SHARED_LOOKUP);
|
||||||
|
try (FileOutputStream out = new FileOutputStream(pendingFile, false)) {
|
||||||
|
out.write(lookup.getBytes(StandardCharsets.UTF_8));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private InputStream openSharedInputStream(Uri uri) throws IOException {
|
private InputStream openSharedInputStream(Uri uri) throws IOException {
|
||||||
if ("file".equalsIgnoreCase(uri.getScheme())) {
|
if ("file".equalsIgnoreCase(uri.getScheme())) {
|
||||||
String path = uri.getPath();
|
String path = uri.getPath();
|
||||||
|
|||||||
@@ -0,0 +1,153 @@
|
|||||||
|
package org.julianfamily.keepassgo;
|
||||||
|
|
||||||
|
import java.util.Arrays;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
public final class AutofillCacheStoreBehaviorTest {
|
||||||
|
public static void main(String[] args) {
|
||||||
|
testFindBestMatchUsesAndroidAppTargets();
|
||||||
|
testChooserCandidatesCollapseToExactAndroidAppMatch();
|
||||||
|
testChooserCandidatesStayScopedToExactHostMatches();
|
||||||
|
testChooserCandidatesStayScopedToParentHostMatches();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void testFindBestMatchUsesAndroidAppTargets() {
|
||||||
|
List<AutofillTargetMatcher.Entry> entries = new ArrayList<>();
|
||||||
|
entries.add(new AutofillTargetMatcher.Entry(
|
||||||
|
"blink-entry",
|
||||||
|
"Blink",
|
||||||
|
"linuscaldwell",
|
||||||
|
"bellagio-stack",
|
||||||
|
"account.blinknetwork.com",
|
||||||
|
"https://account.blinknetwork.com",
|
||||||
|
Arrays.asList("https://account.blinknetwork.com", "androidapp://com.blinknetwork.mobile2"),
|
||||||
|
Arrays.asList("Crew", "Apps")
|
||||||
|
));
|
||||||
|
|
||||||
|
AutofillTargetMatcher.Entry got = AutofillTargetMatcher.findBestMatch(entries, "androidapp://com.blinknetwork.mobile2");
|
||||||
|
if (got == null || !"blink-entry".equals(got.id)) {
|
||||||
|
throw new AssertionError("findBestMatch(entries, androidapp target) = " + describe(got) + ", want blink-entry");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void testChooserCandidatesCollapseToExactAndroidAppMatch() {
|
||||||
|
List<AutofillTargetMatcher.Entry> entries = new ArrayList<>();
|
||||||
|
entries.add(new AutofillTargetMatcher.Entry(
|
||||||
|
"blink-entry",
|
||||||
|
"Blink",
|
||||||
|
"linuscaldwell",
|
||||||
|
"bellagio-stack",
|
||||||
|
"account.blinknetwork.com",
|
||||||
|
"https://account.blinknetwork.com",
|
||||||
|
Arrays.asList("https://account.blinknetwork.com", "androidapp://com.blinknetwork.mobile2"),
|
||||||
|
Arrays.asList("Crew", "Apps")
|
||||||
|
));
|
||||||
|
entries.add(new AutofillTargetMatcher.Entry(
|
||||||
|
"night-fox-entry",
|
||||||
|
"Night Fox",
|
||||||
|
"nightfox",
|
||||||
|
"vault-code",
|
||||||
|
"gitlab.com",
|
||||||
|
"https://gitlab.com",
|
||||||
|
Arrays.asList("https://gitlab.com"),
|
||||||
|
Arrays.asList("Crew", "Internet")
|
||||||
|
));
|
||||||
|
|
||||||
|
List<AutofillTargetMatcher.Entry> got = AutofillTargetMatcher.chooserCandidates(entries, "androidapp://com.blinknetwork.mobile2");
|
||||||
|
if (got.size() != 1 || !"blink-entry".equals(got.get(0).id)) {
|
||||||
|
throw new AssertionError("chooserCandidates(entries, androidapp target) = " + describe(got) + ", want [blink-entry]");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void testChooserCandidatesStayScopedToExactHostMatches() {
|
||||||
|
List<AutofillTargetMatcher.Entry> entries = new ArrayList<>();
|
||||||
|
entries.add(new AutofillTargetMatcher.Entry(
|
||||||
|
"bellagio-primary",
|
||||||
|
"Bellagio Primary",
|
||||||
|
"dannyocean",
|
||||||
|
"vault-code",
|
||||||
|
"bellagio.example.invalid",
|
||||||
|
"https://bellagio.example.invalid/login",
|
||||||
|
Arrays.asList("https://bellagio.example.invalid/login"),
|
||||||
|
Arrays.asList("Crew", "Internet")
|
||||||
|
));
|
||||||
|
entries.add(new AutofillTargetMatcher.Entry(
|
||||||
|
"bellagio-backup",
|
||||||
|
"Bellagio Backup",
|
||||||
|
"rustyryan",
|
||||||
|
"backup-code",
|
||||||
|
"bellagio.example.invalid",
|
||||||
|
"https://bellagio.example.invalid/admin",
|
||||||
|
Arrays.asList("https://bellagio.example.invalid/admin"),
|
||||||
|
Arrays.asList("Crew", "Internet")
|
||||||
|
));
|
||||||
|
entries.add(new AutofillTargetMatcher.Entry(
|
||||||
|
"night-fox-entry",
|
||||||
|
"Night Fox",
|
||||||
|
"nightfox",
|
||||||
|
"vault-code",
|
||||||
|
"gitlab.com",
|
||||||
|
"https://gitlab.com",
|
||||||
|
Arrays.asList("https://gitlab.com"),
|
||||||
|
Arrays.asList("Crew", "Internet")
|
||||||
|
));
|
||||||
|
|
||||||
|
List<AutofillTargetMatcher.Entry> got = AutofillTargetMatcher.chooserCandidates(entries, "https://bellagio.example.invalid/security");
|
||||||
|
if (got.size() != 2 || !containsIDs(got, "bellagio-primary", "bellagio-backup")) {
|
||||||
|
throw new AssertionError("chooserCandidates(entries, exact host) = " + describe(got) + ", want only Bellagio entries");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void testChooserCandidatesStayScopedToParentHostMatches() {
|
||||||
|
List<AutofillTargetMatcher.Entry> entries = new ArrayList<>();
|
||||||
|
entries.add(new AutofillTargetMatcher.Entry(
|
||||||
|
"bellagio-parent",
|
||||||
|
"Bellagio Parent",
|
||||||
|
"linuscaldwell",
|
||||||
|
"chip-stack",
|
||||||
|
"example.invalid",
|
||||||
|
"https://example.invalid/login",
|
||||||
|
Arrays.asList("https://example.invalid/login"),
|
||||||
|
Arrays.asList("Crew", "Internet")
|
||||||
|
));
|
||||||
|
entries.add(new AutofillTargetMatcher.Entry(
|
||||||
|
"night-fox-entry",
|
||||||
|
"Night Fox",
|
||||||
|
"nightfox",
|
||||||
|
"vault-code",
|
||||||
|
"gitlab.com",
|
||||||
|
"https://gitlab.com",
|
||||||
|
Arrays.asList("https://gitlab.com"),
|
||||||
|
Arrays.asList("Crew", "Internet")
|
||||||
|
));
|
||||||
|
|
||||||
|
List<AutofillTargetMatcher.Entry> got = AutofillTargetMatcher.chooserCandidates(entries, "https://bellagio.example.invalid/security");
|
||||||
|
if (got.size() != 1 || !"bellagio-parent".equals(got.get(0).id)) {
|
||||||
|
throw new AssertionError("chooserCandidates(entries, parent host) = " + describe(got) + ", want [bellagio-parent]");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String describe(AutofillTargetMatcher.Entry entry) {
|
||||||
|
if (entry == null) {
|
||||||
|
return "null";
|
||||||
|
}
|
||||||
|
return entry.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String describe(List<AutofillTargetMatcher.Entry> entries) {
|
||||||
|
List<String> ids = new ArrayList<>();
|
||||||
|
for (AutofillTargetMatcher.Entry entry : entries) {
|
||||||
|
ids.add(entry.id);
|
||||||
|
}
|
||||||
|
return ids.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean containsIDs(List<AutofillTargetMatcher.Entry> entries, String... wantIDs) {
|
||||||
|
List<String> ids = new ArrayList<>();
|
||||||
|
for (AutofillTargetMatcher.Entry entry : entries) {
|
||||||
|
ids.add(entry.id);
|
||||||
|
}
|
||||||
|
return ids.containsAll(Arrays.asList(wantIDs)) && ids.size() == wantIDs.length;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
package org.julianfamily.keepassgo;
|
||||||
|
|
||||||
|
public final class AutofillFallbackTargetBehaviorTest {
|
||||||
|
public static void main(String[] args) {
|
||||||
|
testPrefersWebDomainWhenPresent();
|
||||||
|
testFallsBackToAndroidAppPackage();
|
||||||
|
testEmptyWhenNeitherSignalExists();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void testPrefersWebDomainWhenPresent() {
|
||||||
|
String got = AutofillFallbackTarget.resolve("com.android.chrome", "gitlab.com");
|
||||||
|
if (!"gitlab.com".equals(got)) {
|
||||||
|
throw new AssertionError("resolve(chrome, gitlab.com) = " + got + ", want gitlab.com");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void testFallsBackToAndroidAppPackage() {
|
||||||
|
String got = AutofillFallbackTarget.resolve("com.blinknetwork.mobile2", "");
|
||||||
|
if (!"androidapp://com.blinknetwork.mobile2".equals(got)) {
|
||||||
|
throw new AssertionError("resolve(package-only) = " + got + ", want androidapp://com.blinknetwork.mobile2");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void testEmptyWhenNeitherSignalExists() {
|
||||||
|
String got = AutofillFallbackTarget.resolve("", "");
|
||||||
|
if (!"".equals(got)) {
|
||||||
|
throw new AssertionError("resolve(empty) = " + got + ", want empty");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,7 +3,8 @@ const nativeHost = "com.keepassgo.browser";
|
|||||||
const isNodeTestEnv = typeof module !== "undefined" && module.exports;
|
const isNodeTestEnv = typeof module !== "undefined" && module.exports;
|
||||||
const usePromiseAPI = typeof globalThis.browser !== "undefined";
|
const usePromiseAPI = typeof globalThis.browser !== "undefined";
|
||||||
const defaultSettings = {
|
const defaultSettings = {
|
||||||
bearerToken: ""
|
bearerToken: "",
|
||||||
|
bestMatchOnly: false
|
||||||
};
|
};
|
||||||
const pageStatePrefix = "keepassgo-page-state:";
|
const pageStatePrefix = "keepassgo-page-state:";
|
||||||
const matchCacheTTL = 30 * 1000;
|
const matchCacheTTL = 30 * 1000;
|
||||||
@@ -173,9 +174,10 @@ function connectNative(message) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function loadSettings() {
|
async function loadSettings() {
|
||||||
const stored = await storageGet(["bearerToken"]);
|
const stored = await storageGet(["bearerToken", "bestMatchOnly"]);
|
||||||
return {
|
return {
|
||||||
bearerToken: (stored.bearerToken || defaultSettings.bearerToken).trim()
|
bearerToken: (stored.bearerToken || defaultSettings.bearerToken).trim(),
|
||||||
|
bestMatchOnly: Boolean(stored.bestMatchOnly ?? defaultSettings.bestMatchOnly)
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -191,6 +193,120 @@ function cloneTarget(target) {
|
|||||||
return target && typeof target === "object" ? { ...target } : null;
|
return target && typeof target === "object" ? { ...target } : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function cloneSavePlan(plan) {
|
||||||
|
if (!plan || typeof plan !== "object") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
mode: plan.mode === "update" ? "update" : "save",
|
||||||
|
entryId: typeof plan.entryId === "string" ? plan.entryId : "",
|
||||||
|
title: typeof plan.title === "string" ? plan.title : "",
|
||||||
|
path: Array.isArray(plan.path) ? [...plan.path] : [],
|
||||||
|
username: typeof plan.username === "string" ? plan.username : "",
|
||||||
|
password: typeof plan.password === "string" ? plan.password : "",
|
||||||
|
url: typeof plan.url === "string" ? plan.url : ""
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeObservedCredential(observed) {
|
||||||
|
if (!observed || typeof observed !== "object") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const password = typeof observed.password === "string" ? observed.password.trim() : "";
|
||||||
|
const url = typeof observed.url === "string" ? observed.url.trim() : "";
|
||||||
|
if (!password || !url) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
title: typeof observed.title === "string" ? observed.title.trim() : "",
|
||||||
|
username: typeof observed.username === "string" ? observed.username.trim() : "",
|
||||||
|
password,
|
||||||
|
url
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function matchHost(rawURL) {
|
||||||
|
if (typeof rawURL !== "string") {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
const trimmed = rawURL.trim();
|
||||||
|
if (!trimmed) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return new URL(trimmed).hostname.toLowerCase();
|
||||||
|
} catch (_error) {
|
||||||
|
return trimmed.replace(/^https?:\/\//i, "").replace(/\/.*$/, "").toLowerCase();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function qualityRank(quality) {
|
||||||
|
switch (String(quality || "").trim().toLowerCase()) {
|
||||||
|
case "exact":
|
||||||
|
return 0;
|
||||||
|
case "scheme":
|
||||||
|
return 1;
|
||||||
|
case "host":
|
||||||
|
return 2;
|
||||||
|
default:
|
||||||
|
return 3;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyBestMatchOnly(matches, enabled) {
|
||||||
|
if (!Array.isArray(matches) || !enabled) {
|
||||||
|
return Array.isArray(matches) ? [...matches] : [];
|
||||||
|
}
|
||||||
|
if (matches.length === 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
const bestRank = Math.min(...matches.map((match) => qualityRank(match?.quality)));
|
||||||
|
return matches.filter((match) => qualityRank(match?.quality) === bestRank);
|
||||||
|
}
|
||||||
|
|
||||||
|
function defaultObservedTitle(observed) {
|
||||||
|
if (observed?.title) {
|
||||||
|
return observed.title;
|
||||||
|
}
|
||||||
|
return matchHost(observed?.url) || "Browser Login";
|
||||||
|
}
|
||||||
|
|
||||||
|
function savePlanForObservedLogin(observed, matches) {
|
||||||
|
const normalized = normalizeObservedCredential(observed);
|
||||||
|
if (!normalized) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const targetHost = matchHost(normalized.url);
|
||||||
|
const exact = Array.isArray(matches) ? matches.find((match) =>
|
||||||
|
typeof match?.id === "string" &&
|
||||||
|
String(match?.username || "").trim().toLowerCase() === normalized.username.toLowerCase() &&
|
||||||
|
matchHost(match?.url || "") === targetHost
|
||||||
|
) : null;
|
||||||
|
if (exact) {
|
||||||
|
return {
|
||||||
|
mode: "update",
|
||||||
|
entryId: exact.id,
|
||||||
|
title: exact.title || defaultObservedTitle(normalized),
|
||||||
|
path: Array.isArray(exact.path) ? [...exact.path] : [],
|
||||||
|
username: normalized.username,
|
||||||
|
password: normalized.password,
|
||||||
|
url: normalized.url
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const fallbackPath = Array.isArray(matches) && matches.length > 0 && Array.isArray(matches[0]?.path)
|
||||||
|
? [...matches[0].path]
|
||||||
|
: [];
|
||||||
|
return {
|
||||||
|
mode: "save",
|
||||||
|
entryId: "",
|
||||||
|
title: defaultObservedTitle(normalized),
|
||||||
|
path: fallbackPath,
|
||||||
|
username: normalized.username,
|
||||||
|
password: normalized.password,
|
||||||
|
url: normalized.url
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function normalizePageState(state) {
|
function normalizePageState(state) {
|
||||||
return {
|
return {
|
||||||
tabId: Number.isInteger(state?.tabId) ? state.tabId : null,
|
tabId: Number.isInteger(state?.tabId) ? state.tabId : null,
|
||||||
@@ -207,6 +323,7 @@ function normalizePageState(state) {
|
|||||||
pendingEntryId: typeof state?.pendingEntryId === "string" ? state.pendingEntryId : "",
|
pendingEntryId: typeof state?.pendingEntryId === "string" ? state.pendingEntryId : "",
|
||||||
pendingTarget: cloneTarget(state?.pendingTarget),
|
pendingTarget: cloneTarget(state?.pendingTarget),
|
||||||
pendingMessage: typeof state?.pendingMessage === "string" ? state.pendingMessage : "",
|
pendingMessage: typeof state?.pendingMessage === "string" ? state.pendingMessage : "",
|
||||||
|
pendingSave: cloneSavePlan(state?.pendingSave),
|
||||||
lastFilledEntryId: typeof state?.lastFilledEntryId === "string" ? state.lastFilledEntryId : "",
|
lastFilledEntryId: typeof state?.lastFilledEntryId === "string" ? state.lastFilledEntryId : "",
|
||||||
updatedAt: Number.isFinite(state?.updatedAt) ? state.updatedAt : 0
|
updatedAt: Number.isFinite(state?.updatedAt) ? state.updatedAt : 0
|
||||||
};
|
};
|
||||||
@@ -228,6 +345,7 @@ function defaultPageState(tabId, pageUrl) {
|
|||||||
pendingEntryId: "",
|
pendingEntryId: "",
|
||||||
pendingTarget: null,
|
pendingTarget: null,
|
||||||
pendingMessage: "",
|
pendingMessage: "",
|
||||||
|
pendingSave: null,
|
||||||
lastFilledEntryId: "",
|
lastFilledEntryId: "",
|
||||||
updatedAt: 0
|
updatedAt: 0
|
||||||
});
|
});
|
||||||
@@ -292,6 +410,16 @@ function approvalHintForState(state) {
|
|||||||
return state.pendingMessage || "Approve or deny the fill request in KeePassGO.";
|
return state.pendingMessage || "Approve or deny the fill request in KeePassGO.";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function shouldContinueWatchingState(state) {
|
||||||
|
if (!state?.pageHasLoginForm) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (state?.pendingFill) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return Boolean(state?.status?.locked);
|
||||||
|
}
|
||||||
|
|
||||||
function schedulePendingPoll(tabId, pageUrl) {
|
function schedulePendingPoll(tabId, pageUrl) {
|
||||||
if (!Number.isInteger(tabId)) {
|
if (!Number.isInteger(tabId)) {
|
||||||
return;
|
return;
|
||||||
@@ -337,6 +465,12 @@ function actionPresentationForState(state) {
|
|||||||
badgeText = "!";
|
badgeText = "!";
|
||||||
color = "#9f5f0e";
|
color = "#9f5f0e";
|
||||||
title = approvalHintForState(state) || "KeePassGO approval needed for this page";
|
title = approvalHintForState(state) || "KeePassGO approval needed for this page";
|
||||||
|
} else if (state.pendingSave) {
|
||||||
|
badgeText = "S";
|
||||||
|
color = "#255f4a";
|
||||||
|
title = state.pendingSave.mode === "update"
|
||||||
|
? `KeePassGO can update ${state.pendingSave.title || "this login"}`
|
||||||
|
: "KeePassGO can save the submitted login";
|
||||||
} else if (!state.configured) {
|
} else if (!state.configured) {
|
||||||
title = "Configure KeePassGO Browser in extension settings";
|
title = "Configure KeePassGO Browser in extension settings";
|
||||||
} else if (!state.success) {
|
} else if (!state.success) {
|
||||||
@@ -492,7 +626,7 @@ async function refreshPageState(tabId, pageUrl, options = {}) {
|
|||||||
state.matches = [];
|
state.matches = [];
|
||||||
state.updatedAt = Date.now();
|
state.updatedAt = Date.now();
|
||||||
const saved = await setPageState(tabId, state);
|
const saved = await setPageState(tabId, state);
|
||||||
if (saved.pendingFill) {
|
if (shouldContinueWatchingState(saved)) {
|
||||||
schedulePendingPoll(tabId, resolvedURL);
|
schedulePendingPoll(tabId, resolvedURL);
|
||||||
} else {
|
} else {
|
||||||
clearPendingPoll(tabId);
|
clearPendingPoll(tabId);
|
||||||
@@ -502,7 +636,7 @@ async function refreshPageState(tabId, pageUrl, options = {}) {
|
|||||||
|
|
||||||
if (shouldReuseMatches(state, force)) {
|
if (shouldReuseMatches(state, force)) {
|
||||||
const saved = await setPageState(tabId, state);
|
const saved = await setPageState(tabId, state);
|
||||||
if (saved.pendingFill) {
|
if (shouldContinueWatchingState(saved)) {
|
||||||
schedulePendingPoll(tabId, resolvedURL);
|
schedulePendingPoll(tabId, resolvedURL);
|
||||||
} else {
|
} else {
|
||||||
clearPendingPoll(tabId);
|
clearPendingPoll(tabId);
|
||||||
@@ -524,12 +658,12 @@ async function refreshPageState(tabId, pageUrl, options = {}) {
|
|||||||
pendingMessage: tokenPendingApprovalCount(matches?.status ?? state.status) > 0
|
pendingMessage: tokenPendingApprovalCount(matches?.status ?? state.status) > 0
|
||||||
? approvalHintForState(state) || "Approve or deny the browser fill request in KeePassGO."
|
? approvalHintForState(state) || "Approve or deny the browser fill request in KeePassGO."
|
||||||
: "",
|
: "",
|
||||||
matches: Array.isArray(matches?.matches) ? matches.matches : [],
|
matches: applyBestMatchOnly(matches?.matches, settings.bestMatchOnly),
|
||||||
error: matches?.error ?? "",
|
error: matches?.error ?? "",
|
||||||
updatedAt: Date.now()
|
updatedAt: Date.now()
|
||||||
};
|
};
|
||||||
const saved = await setPageState(tabId, state);
|
const saved = await setPageState(tabId, state);
|
||||||
if (saved.pendingFill) {
|
if (shouldContinueWatchingState(saved)) {
|
||||||
schedulePendingPoll(tabId, resolvedURL);
|
schedulePendingPoll(tabId, resolvedURL);
|
||||||
} else {
|
} else {
|
||||||
clearPendingPoll(tabId);
|
clearPendingPoll(tabId);
|
||||||
@@ -653,11 +787,65 @@ async function refreshActivePage(options = {}) {
|
|||||||
return refreshPageState(page.tabId, page.url, options);
|
return refreshPageState(page.tabId, page.url, options);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function saveObservedLogin(tabId, selectedMatch = null) {
|
||||||
|
if (!Number.isInteger(tabId)) {
|
||||||
|
throw new Error("No active tab is available.");
|
||||||
|
}
|
||||||
|
const tab = await tabsGet(tabId);
|
||||||
|
const pageUrl = typeof tab?.url === "string" ? tab.url : "";
|
||||||
|
let state = await getPageState(tabId, pageUrl);
|
||||||
|
const pendingSave = cloneSavePlan(state.pendingSave);
|
||||||
|
if (!pendingSave) {
|
||||||
|
throw new Error("There is no pending login to save.");
|
||||||
|
}
|
||||||
|
const request = {
|
||||||
|
action: "save-login",
|
||||||
|
title: pendingSave.title,
|
||||||
|
username: pendingSave.username,
|
||||||
|
password: pendingSave.password,
|
||||||
|
url: pendingSave.url
|
||||||
|
};
|
||||||
|
if (selectedMatch && typeof selectedMatch === "object") {
|
||||||
|
if (pendingSave.mode === "update" && typeof selectedMatch.id === "string" && selectedMatch.id.trim()) {
|
||||||
|
request.entryId = selectedMatch.id.trim();
|
||||||
|
request.title = String(selectedMatch.title || pendingSave.title || "").trim();
|
||||||
|
} else if (Array.isArray(selectedMatch.path) && selectedMatch.path.length > 0) {
|
||||||
|
request.path = [...selectedMatch.path];
|
||||||
|
}
|
||||||
|
} else if (pendingSave.mode === "update" && pendingSave.entryId) {
|
||||||
|
request.entryId = pendingSave.entryId;
|
||||||
|
} else if (pendingSave.path.length > 0) {
|
||||||
|
request.path = [...pendingSave.path];
|
||||||
|
}
|
||||||
|
const settings = await loadSettings();
|
||||||
|
if (!settings.bearerToken) {
|
||||||
|
throw new Error("API token is not configured.");
|
||||||
|
}
|
||||||
|
const response = await connectNative({
|
||||||
|
...request,
|
||||||
|
bearerToken: settings.bearerToken
|
||||||
|
});
|
||||||
|
if (!response?.success) {
|
||||||
|
throw new Error(response?.error || "KeePassGO did not save the submitted login.");
|
||||||
|
}
|
||||||
|
state = await setPageState(tabId, {
|
||||||
|
...state,
|
||||||
|
pendingSave: null,
|
||||||
|
error: "",
|
||||||
|
updatedAt: Date.now()
|
||||||
|
});
|
||||||
|
await refreshPageState(tabId, pageUrl, { force: true });
|
||||||
|
return { state };
|
||||||
|
}
|
||||||
|
|
||||||
const backgroundTestExports = {
|
const backgroundTestExports = {
|
||||||
|
applyBestMatchOnly,
|
||||||
normalizePageState,
|
normalizePageState,
|
||||||
actionPresentationForState,
|
actionPresentationForState,
|
||||||
shouldReuseMatches,
|
shouldReuseMatches,
|
||||||
|
shouldContinueWatchingState,
|
||||||
tokenPendingApprovalCount,
|
tokenPendingApprovalCount,
|
||||||
|
savePlanForObservedLogin,
|
||||||
defaultSettings
|
defaultSettings
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -692,11 +880,54 @@ if (isNodeTestEnv) {
|
|||||||
return;
|
return;
|
||||||
case "keepassgo-save-settings":
|
case "keepassgo-save-settings":
|
||||||
await storageSet({
|
await storageSet({
|
||||||
bearerToken: String(message.settings?.bearerToken || "").trim()
|
bearerToken: String(message.settings?.bearerToken || "").trim(),
|
||||||
|
bestMatchOnly: Boolean(message.settings?.bestMatchOnly)
|
||||||
});
|
});
|
||||||
await refreshActivePage({ force: true }).catch(() => null);
|
await refreshActivePage({ force: true }).catch(() => null);
|
||||||
sendResponse({ success: true });
|
sendResponse({ success: true });
|
||||||
return;
|
return;
|
||||||
|
case "keepassgo-search-logins": {
|
||||||
|
const settings = await loadSettings();
|
||||||
|
const response = await connectNative({
|
||||||
|
action: "search-logins",
|
||||||
|
bearerToken: settings.bearerToken,
|
||||||
|
query: String(message?.query || "").trim()
|
||||||
|
});
|
||||||
|
sendResponse({
|
||||||
|
success: Boolean(response?.success),
|
||||||
|
error: response?.error || "",
|
||||||
|
results: applyBestMatchOnly(response?.searchResults, settings.bestMatchOnly),
|
||||||
|
status: response?.status ?? null
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
case "keepassgo-observed-login":
|
||||||
|
if (Number.isInteger(sender?.tab?.id)) {
|
||||||
|
const targetState = await getPageState(sender.tab.id, sender.tab.url || "");
|
||||||
|
const nextSave = savePlanForObservedLogin(message.observed, targetState.matches);
|
||||||
|
sendResponse(await setPageState(sender.tab.id, {
|
||||||
|
...targetState,
|
||||||
|
pendingSave: nextSave,
|
||||||
|
updatedAt: Date.now()
|
||||||
|
}));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
sendResponse({ success: false, error: "No active tab is available." });
|
||||||
|
return;
|
||||||
|
case "keepassgo-save-login": {
|
||||||
|
const targetTabID = Number.isInteger(message?.tabId)
|
||||||
|
? message.tabId
|
||||||
|
: (Number.isInteger(sender?.tab?.id) ? sender.tab.id : (await activePageContext()).tabId);
|
||||||
|
const selectedMatch = message?.selectedMatch && typeof message.selectedMatch === "object"
|
||||||
|
? {
|
||||||
|
id: String(message.selectedMatch.id || "").trim(),
|
||||||
|
title: String(message.selectedMatch.title || "").trim(),
|
||||||
|
path: Array.isArray(message.selectedMatch.path) ? message.selectedMatch.path : []
|
||||||
|
}
|
||||||
|
: null;
|
||||||
|
sendResponse({ success: true, ...(await saveObservedLogin(targetTabID, selectedMatch)) });
|
||||||
|
return;
|
||||||
|
}
|
||||||
case "keepassgo-page-ready":
|
case "keepassgo-page-ready":
|
||||||
if (Number.isInteger(sender?.tab?.id)) {
|
if (Number.isInteger(sender?.tab?.id)) {
|
||||||
sendResponse(await refreshPageState(sender.tab.id, sender.tab.url, {
|
sendResponse(await refreshPageState(sender.tab.id, sender.tab.url, {
|
||||||
|
|||||||
@@ -49,6 +49,103 @@ test("tokenPendingApprovalCount reads token-scoped approval state", () => {
|
|||||||
assert.equal(background.tokenPendingApprovalCount({}), 0);
|
assert.equal(background.tokenPendingApprovalCount({}), 0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("shouldContinueWatchingState keeps polling locked login pages", () => {
|
||||||
|
assert.equal(background.shouldContinueWatchingState({
|
||||||
|
pageHasLoginForm: true,
|
||||||
|
pendingFill: false,
|
||||||
|
status: { locked: true }
|
||||||
|
}), true);
|
||||||
|
assert.equal(background.shouldContinueWatchingState({
|
||||||
|
pageHasLoginForm: true,
|
||||||
|
pendingFill: true,
|
||||||
|
status: { locked: false }
|
||||||
|
}), true);
|
||||||
|
assert.equal(background.shouldContinueWatchingState({
|
||||||
|
pageHasLoginForm: true,
|
||||||
|
pendingFill: false,
|
||||||
|
status: { locked: false }
|
||||||
|
}), false);
|
||||||
|
});
|
||||||
|
|
||||||
test("default settings include a blank bearer token that can be overridden by harness patching", () => {
|
test("default settings include a blank bearer token that can be overridden by harness patching", () => {
|
||||||
assert.equal(background.defaultSettings.bearerToken, "");
|
assert.equal(background.defaultSettings.bearerToken, "");
|
||||||
|
assert.equal(background.defaultSettings.bestMatchOnly, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("savePlanForObservedLogin prefers updating an exact username match", () => {
|
||||||
|
const plan = background.savePlanForObservedLogin({
|
||||||
|
username: "dannyocean",
|
||||||
|
password: "bellagio-safe",
|
||||||
|
url: "https://vault.example.invalid/login"
|
||||||
|
}, [
|
||||||
|
{
|
||||||
|
id: "vault-console",
|
||||||
|
title: "Vault Console",
|
||||||
|
username: "dannyocean",
|
||||||
|
url: "vault.example.invalid",
|
||||||
|
path: ["Crew", "Internet"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "bellagio-backup",
|
||||||
|
title: "Bellagio Backup",
|
||||||
|
username: "rustyryan",
|
||||||
|
url: "vault.example.invalid",
|
||||||
|
path: ["Crew", "Internet"]
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
|
||||||
|
assert.deepEqual(plan, {
|
||||||
|
mode: "update",
|
||||||
|
entryId: "vault-console",
|
||||||
|
title: "Vault Console",
|
||||||
|
path: ["Crew", "Internet"],
|
||||||
|
username: "dannyocean",
|
||||||
|
password: "bellagio-safe",
|
||||||
|
url: "https://vault.example.invalid/login"
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("savePlanForObservedLogin falls back to saving into the current page group", () => {
|
||||||
|
const plan = background.savePlanForObservedLogin({
|
||||||
|
username: "linuscaldwell",
|
||||||
|
password: "yellow-chip",
|
||||||
|
url: "https://vault.example.invalid/login"
|
||||||
|
}, [
|
||||||
|
{
|
||||||
|
id: "vault-console",
|
||||||
|
title: "Vault Console",
|
||||||
|
username: "dannyocean",
|
||||||
|
url: "vault.example.invalid",
|
||||||
|
path: ["Crew", "Internet"]
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
|
||||||
|
assert.deepEqual(plan, {
|
||||||
|
mode: "save",
|
||||||
|
entryId: "",
|
||||||
|
title: "vault.example.invalid",
|
||||||
|
path: ["Crew", "Internet"],
|
||||||
|
username: "linuscaldwell",
|
||||||
|
password: "yellow-chip",
|
||||||
|
url: "https://vault.example.invalid/login"
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("applyBestMatchOnly keeps only the strongest quality band when enabled", () => {
|
||||||
|
const filtered = background.applyBestMatchOnly([
|
||||||
|
{ id: "livingston", title: "Livingston Dell", quality: "exact" },
|
||||||
|
{ id: "rusty", title: "Rusty Ryan", quality: "host" },
|
||||||
|
{ id: "linus", title: "Linus Caldwell", quality: "scheme" }
|
||||||
|
], true);
|
||||||
|
|
||||||
|
assert.deepEqual(filtered.map((match) => match.id), ["livingston"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("applyBestMatchOnly preserves all matches when disabled", () => {
|
||||||
|
const filtered = background.applyBestMatchOnly([
|
||||||
|
{ id: "livingston", title: "Livingston Dell", quality: "exact" },
|
||||||
|
{ id: "rusty", title: "Rusty Ryan", quality: "host" }
|
||||||
|
], false);
|
||||||
|
|
||||||
|
assert.deepEqual(filtered.map((match) => match.id), ["livingston", "rusty"]);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -396,6 +396,22 @@ function fillCredential(credential, targetDescriptor) {
|
|||||||
return { ok: true };
|
return { ok: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function submittedCredential(candidate, rawURL) {
|
||||||
|
if (!candidate?.passwordInput) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const password = String(candidate.passwordInput.value || "").trim();
|
||||||
|
if (!password) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
title: domainLabel(rawURL),
|
||||||
|
username: String(candidate.usernameInput?.value || "").trim(),
|
||||||
|
password,
|
||||||
|
url: String(rawURL || "").trim()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function domainLabel(rawURL) {
|
function domainLabel(rawURL) {
|
||||||
try {
|
try {
|
||||||
return new URL(rawURL).host || "";
|
return new URL(rawURL).host || "";
|
||||||
@@ -429,6 +445,7 @@ function shouldShowInlineOverlay(state, hasTarget, suppressed, idleHidden) {
|
|||||||
state?.pageHasLoginForm &&
|
state?.pageHasLoginForm &&
|
||||||
(
|
(
|
||||||
state?.pendingFill ||
|
state?.pendingFill ||
|
||||||
|
(state?.configured && state?.success && state?.status?.locked) ||
|
||||||
(state?.configured && state?.success && !state?.status?.locked && Array.isArray(state?.matches) && state.matches.length > 0)
|
(state?.configured && state?.success && !state?.status?.locked && Array.isArray(state?.matches) && state.matches.length > 0)
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
@@ -446,7 +463,8 @@ const contentTestExports = {
|
|||||||
fieldHintText,
|
fieldHintText,
|
||||||
scopeHintText,
|
scopeHintText,
|
||||||
hasAuthFlowSignals,
|
hasAuthFlowSignals,
|
||||||
authFlowCandidate
|
authFlowCandidate,
|
||||||
|
submittedCredential
|
||||||
};
|
};
|
||||||
|
|
||||||
if (isNodeTestEnv) {
|
if (isNodeTestEnv) {
|
||||||
@@ -727,10 +745,13 @@ if (isNodeTestEnv) {
|
|||||||
|
|
||||||
ensureRootMounted();
|
ensureRootMounted();
|
||||||
dock.style.display = "block";
|
dock.style.display = "block";
|
||||||
trigger.dataset.tone = pageState.pendingFill ? "warning" : (pageState.error ? "error" : "ready");
|
trigger.dataset.tone = pageState.pendingFill || pageState.status?.locked ? "warning" : (pageState.error ? "error" : "ready");
|
||||||
if (pageState.pendingFill) {
|
if (pageState.pendingFill) {
|
||||||
meta.textContent = "Approval needed in KeePassGO";
|
meta.textContent = "Approval needed in KeePassGO";
|
||||||
panelCopy.textContent = pageState.pendingMessage || "Approve or deny the fill request in KeePassGO.";
|
panelCopy.textContent = pageState.pendingMessage || "Approve or deny the fill request in KeePassGO.";
|
||||||
|
} else if (pageState.status?.locked) {
|
||||||
|
meta.textContent = "Unlock KeePassGO";
|
||||||
|
panelCopy.textContent = "Unlock KeePassGO to turn this field back into live login suggestions.";
|
||||||
} else {
|
} else {
|
||||||
const count = Array.isArray(pageState.matches) ? pageState.matches.length : 0;
|
const count = Array.isArray(pageState.matches) ? pageState.matches.length : 0;
|
||||||
meta.textContent = count === 1 ? "1 login ready" : `${count} logins ready`;
|
meta.textContent = count === 1 ? "1 login ready" : `${count} logins ready`;
|
||||||
@@ -801,6 +822,23 @@ if (isNodeTestEnv) {
|
|||||||
scheduleRefresh(false);
|
scheduleRefresh(false);
|
||||||
}, true);
|
}, true);
|
||||||
|
|
||||||
|
document.addEventListener("submit", (event) => {
|
||||||
|
const form = event.target instanceof HTMLFormElement ? event.target : null;
|
||||||
|
if (!form) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const passwordInput = visibleInputs(form).find(isPasswordCandidate) || null;
|
||||||
|
const candidate = passwordInput ? authFlowCandidate(passwordInput) : null;
|
||||||
|
const observed = submittedCredential(candidate, window.location.href);
|
||||||
|
if (!observed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
void runtimeSend({
|
||||||
|
type: "keepassgo-observed-login",
|
||||||
|
observed
|
||||||
|
}).catch(() => null);
|
||||||
|
}, true);
|
||||||
|
|
||||||
document.addEventListener("click", (event) => {
|
document.addEventListener("click", (event) => {
|
||||||
if (!root.contains(event.target)) {
|
if (!root.contains(event.target)) {
|
||||||
chooserOpen = false;
|
chooserOpen = false;
|
||||||
|
|||||||
@@ -94,6 +94,19 @@ test("shouldShowInlineOverlay hides the page overlay after it is suppressed", ()
|
|||||||
assert.equal(content.shouldShowInlineOverlay(state, true, true, false), false);
|
assert.equal(content.shouldShowInlineOverlay(state, true, true, false), false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("shouldShowInlineOverlay stays visible for locked login pages", () => {
|
||||||
|
const state = {
|
||||||
|
pageHasLoginForm: true,
|
||||||
|
configured: true,
|
||||||
|
success: true,
|
||||||
|
status: { locked: true },
|
||||||
|
matches: [],
|
||||||
|
pendingFill: false
|
||||||
|
};
|
||||||
|
|
||||||
|
assert.equal(content.shouldShowInlineOverlay(state, true, false, false), true);
|
||||||
|
});
|
||||||
|
|
||||||
test("shouldShowInlineOverlay hides the page overlay after idle expiry", () => {
|
test("shouldShowInlineOverlay hides the page overlay after idle expiry", () => {
|
||||||
const state = {
|
const state = {
|
||||||
pageHasLoginForm: true,
|
pageHasLoginForm: true,
|
||||||
@@ -107,3 +120,17 @@ test("shouldShowInlineOverlay hides the page overlay after idle expiry", () => {
|
|||||||
assert.equal(content.shouldShowInlineOverlay(state, true, false, false), true);
|
assert.equal(content.shouldShowInlineOverlay(state, true, false, false), true);
|
||||||
assert.equal(content.shouldShowInlineOverlay(state, true, false, true), false);
|
assert.equal(content.shouldShowInlineOverlay(state, true, false, true), false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("submittedCredential captures the pending browser save payload from a login candidate", () => {
|
||||||
|
const candidate = {
|
||||||
|
usernameInput: { value: "linuscaldwell" },
|
||||||
|
passwordInput: { value: "yellow-chip" }
|
||||||
|
};
|
||||||
|
|
||||||
|
assert.deepEqual(content.submittedCredential(candidate, "https://bellagio.example.invalid/login"), {
|
||||||
|
title: "bellagio.example.invalid",
|
||||||
|
username: "linuscaldwell",
|
||||||
|
password: "yellow-chip",
|
||||||
|
url: "https://bellagio.example.invalid/login"
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 5.9 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.1 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.8 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 2.5 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 4.6 KiB |
@@ -3,6 +3,13 @@
|
|||||||
"name": "KeePassGO Browser",
|
"name": "KeePassGO Browser",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"description": "Fill credentials from KeePassGO on sign-in pages.",
|
"description": "Fill credentials from KeePassGO on sign-in pages.",
|
||||||
|
"icons": {
|
||||||
|
"16": "icons/icon-16.png",
|
||||||
|
"32": "icons/icon-32.png",
|
||||||
|
"48": "icons/icon-48.png",
|
||||||
|
"96": "icons/icon-96.png",
|
||||||
|
"128": "icons/icon-128.png"
|
||||||
|
},
|
||||||
"permissions": [
|
"permissions": [
|
||||||
"activeTab",
|
"activeTab",
|
||||||
"nativeMessaging",
|
"nativeMessaging",
|
||||||
@@ -16,6 +23,10 @@
|
|||||||
},
|
},
|
||||||
"browser_action": {
|
"browser_action": {
|
||||||
"default_title": "KeePassGO Browser",
|
"default_title": "KeePassGO Browser",
|
||||||
|
"default_icon": {
|
||||||
|
"16": "icons/icon-16.png",
|
||||||
|
"32": "icons/icon-32.png"
|
||||||
|
},
|
||||||
"default_popup": "popup.html"
|
"default_popup": "popup.html"
|
||||||
},
|
},
|
||||||
"options_ui": {
|
"options_ui": {
|
||||||
@@ -31,7 +42,14 @@
|
|||||||
],
|
],
|
||||||
"browser_specific_settings": {
|
"browser_specific_settings": {
|
||||||
"gecko": {
|
"gecko": {
|
||||||
"id": "browser@keepassgo.com"
|
"id": "browser@keepassgo.com",
|
||||||
|
"data_collection_permissions": {
|
||||||
|
"required": ["authenticationInfo", "websiteActivity"]
|
||||||
|
},
|
||||||
|
"strict_min_version": "140.0"
|
||||||
|
},
|
||||||
|
"gecko_android": {
|
||||||
|
"strict_min_version": "142.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,13 @@
|
|||||||
<span>API token</span>
|
<span>API token</span>
|
||||||
<textarea id="bearer-token" name="bearer-token" rows="6" spellcheck="false"></textarea>
|
<textarea id="bearer-token" name="bearer-token" rows="6" spellcheck="false"></textarea>
|
||||||
</label>
|
</label>
|
||||||
|
<fieldset>
|
||||||
|
<legend>Browser Matching</legend>
|
||||||
|
<label class="checkbox-row">
|
||||||
|
<input id="best-match-only" name="best-match-only" type="checkbox">
|
||||||
|
<span>Best match only</span>
|
||||||
|
</label>
|
||||||
|
</fieldset>
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
<button type="submit">Save</button>
|
<button type="submit">Save</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ async function loadSettings() {
|
|||||||
throw new Error(response?.error || "Could not load settings.");
|
throw new Error(response?.error || "Could not load settings.");
|
||||||
}
|
}
|
||||||
document.getElementById("bearer-token").value = response.settings.bearerToken || "";
|
document.getElementById("bearer-token").value = response.settings.bearerToken || "";
|
||||||
|
document.getElementById("best-match-only").checked = Boolean(response.settings.bestMatchOnly);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function saveSettings(event) {
|
async function saveSettings(event) {
|
||||||
@@ -33,7 +34,8 @@ async function saveSettings(event) {
|
|||||||
const response = await runtimeSend({
|
const response = await runtimeSend({
|
||||||
type: "keepassgo-save-settings",
|
type: "keepassgo-save-settings",
|
||||||
settings: {
|
settings: {
|
||||||
bearerToken: document.getElementById("bearer-token").value
|
bearerToken: document.getElementById("bearer-token").value,
|
||||||
|
bestMatchOnly: document.getElementById("best-match-only").checked
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
if (!response?.success) {
|
if (!response?.success) {
|
||||||
|
|||||||
@@ -20,10 +20,25 @@
|
|||||||
<p id="status-message" class="subtle">Checking KeePassGO.</p>
|
<p id="status-message" class="subtle">Checking KeePassGO.</p>
|
||||||
</section>
|
</section>
|
||||||
<p id="page-hint" class="inline-hint subtle">Loading page state.</p>
|
<p id="page-hint" class="inline-hint subtle">Loading page state.</p>
|
||||||
|
<section id="save-card" class="save-card" hidden>
|
||||||
|
<div>
|
||||||
|
<h2>Save Submitted Login</h2>
|
||||||
|
<p id="save-message" class="subtle">KeePassGO can save this login.</p>
|
||||||
|
</div>
|
||||||
|
<button id="save-action" type="button">Save Login</button>
|
||||||
|
</section>
|
||||||
<section>
|
<section>
|
||||||
<h2>Matches</h2>
|
<h2>Matches</h2>
|
||||||
<div id="matches" class="match-list"></div>
|
<div id="matches" class="match-list"></div>
|
||||||
</section>
|
</section>
|
||||||
|
<section class="search-section">
|
||||||
|
<h2>Search Vault</h2>
|
||||||
|
<form id="search-form" class="search-form">
|
||||||
|
<input id="search-query" type="search" placeholder="Search entries" autocomplete="off">
|
||||||
|
<button type="submit">Search</button>
|
||||||
|
</form>
|
||||||
|
<div id="search-results" class="match-list"></div>
|
||||||
|
</section>
|
||||||
</main>
|
</main>
|
||||||
<script src="popup.js"></script>
|
<script src="popup.js"></script>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
+143
-17
@@ -43,21 +43,25 @@ function matchSubtitle(match) {
|
|||||||
return parts.join(" · ") || "No username";
|
return parts.join(" · ") || "No username";
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderMatches(state) {
|
function saveCardLabel(pendingSave) {
|
||||||
const root = document.getElementById("matches");
|
return pendingSave?.mode === "update"
|
||||||
|
? `Update ${pendingSave.title || "Login"}`
|
||||||
|
: "Save Login";
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderMatchList(root, matches, options = {}) {
|
||||||
const targetTabID = popupTabID();
|
const targetTabID = popupTabID();
|
||||||
|
const emptyMessage = options.emptyMessage || "No matching entries.";
|
||||||
root.textContent = "";
|
root.textContent = "";
|
||||||
if (!Array.isArray(state.matches) || state.matches.length === 0) {
|
if (!Array.isArray(matches) || matches.length === 0) {
|
||||||
const empty = document.createElement("p");
|
const empty = document.createElement("p");
|
||||||
empty.className = "subtle";
|
empty.className = "subtle";
|
||||||
empty.textContent = state.pageHasLoginForm
|
empty.textContent = emptyMessage;
|
||||||
? "No matching entries for this page."
|
|
||||||
: "No login fields detected on this page.";
|
|
||||||
root.appendChild(empty);
|
root.appendChild(empty);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const match of state.matches) {
|
for (const match of matches) {
|
||||||
const row = document.createElement("button");
|
const row = document.createElement("button");
|
||||||
row.type = "button";
|
row.type = "button";
|
||||||
row.className = "match-row";
|
row.className = "match-row";
|
||||||
@@ -77,19 +81,23 @@ function renderMatches(state) {
|
|||||||
row.appendChild(quality);
|
row.appendChild(quality);
|
||||||
row.addEventListener("click", async () => {
|
row.addEventListener("click", async () => {
|
||||||
row.disabled = true;
|
row.disabled = true;
|
||||||
setStatus("Approval may be required", "KeePassGO will prompt if this token needs approval before fill.", "warning");
|
|
||||||
try {
|
try {
|
||||||
const result = await runtimeSend({
|
if (typeof options.onSelect === "function") {
|
||||||
type: "keepassgo-fill-entry",
|
await options.onSelect(match, targetTabID);
|
||||||
entryId: match.id,
|
} else {
|
||||||
tabId: targetTabID
|
setStatus("Approval may be required", "KeePassGO will prompt if this token needs approval before fill.", "warning");
|
||||||
});
|
const result = await runtimeSend({
|
||||||
if (!result?.success) {
|
type: "keepassgo-fill-entry",
|
||||||
throw new Error(result?.error || "Fill failed.");
|
entryId: match.id,
|
||||||
|
tabId: targetTabID
|
||||||
|
});
|
||||||
|
if (!result?.success) {
|
||||||
|
throw new Error(result?.error || "Fill failed.");
|
||||||
|
}
|
||||||
|
setStatus("Filled", `${match.title} was sent to the current page.`, "ready");
|
||||||
}
|
}
|
||||||
setStatus("Filled", `${match.title} was sent to the current page.`, "ready");
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setStatus("Fill failed", error instanceof Error ? error.message : String(error), "error");
|
setStatus(options.onSelect ? "Save failed" : "Fill failed", error instanceof Error ? error.message : String(error), "error");
|
||||||
} finally {
|
} finally {
|
||||||
row.disabled = false;
|
row.disabled = false;
|
||||||
}
|
}
|
||||||
@@ -98,6 +106,51 @@ function renderMatches(state) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function renderMatches(state) {
|
||||||
|
const emptyMessage = state.pageHasLoginForm
|
||||||
|
? "No matching entries for this page."
|
||||||
|
: "No login fields detected on this page.";
|
||||||
|
const root = document.getElementById("matches");
|
||||||
|
if (state.pendingSave) {
|
||||||
|
renderMatchList(root, state.matches, {
|
||||||
|
emptyMessage,
|
||||||
|
onSelect: async (match, targetTabID) => {
|
||||||
|
const result = await runtimeSend({
|
||||||
|
type: "keepassgo-save-login",
|
||||||
|
tabId: targetTabID,
|
||||||
|
selectedMatch: {
|
||||||
|
id: match.id,
|
||||||
|
title: match.title,
|
||||||
|
path: Array.isArray(match.path) ? match.path : []
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (!result?.success) {
|
||||||
|
throw new Error(result?.error || "Save failed.");
|
||||||
|
}
|
||||||
|
setStatus("Saved", `${state.pendingSave.title || "Login"} is now in KeePassGO.`, "ready");
|
||||||
|
document.getElementById("save-card").hidden = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
renderMatchList(root, state.matches, { emptyMessage });
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderSearchResults(results, query) {
|
||||||
|
const root = document.getElementById("search-results");
|
||||||
|
if (!query) {
|
||||||
|
root.textContent = "";
|
||||||
|
const hint = document.createElement("p");
|
||||||
|
hint.className = "subtle";
|
||||||
|
hint.textContent = "Search all entries you can access with this token.";
|
||||||
|
root.appendChild(hint);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
renderMatchList(root, results, {
|
||||||
|
emptyMessage: `No entries matched "${query}".`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function renderPageHint(state) {
|
function renderPageHint(state) {
|
||||||
const hint = document.getElementById("page-hint");
|
const hint = document.getElementById("page-hint");
|
||||||
if (state.pendingFill) {
|
if (state.pendingFill) {
|
||||||
@@ -115,6 +168,46 @@ function renderPageHint(state) {
|
|||||||
hint.textContent = "Open a sign-in page to see KeePassGO suggestions here.";
|
hint.textContent = "Open a sign-in page to see KeePassGO suggestions here.";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function renderPendingSave(state) {
|
||||||
|
const card = document.getElementById("save-card");
|
||||||
|
const message = document.getElementById("save-message");
|
||||||
|
const action = document.getElementById("save-action");
|
||||||
|
const pendingSave = state.pendingSave;
|
||||||
|
if (!pendingSave) {
|
||||||
|
card.hidden = true;
|
||||||
|
action.onclick = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
card.hidden = false;
|
||||||
|
action.textContent = saveCardLabel(pendingSave);
|
||||||
|
if (pendingSave.mode === "update") {
|
||||||
|
message.textContent = `KeePassGO can update ${pendingSave.title || "this login"} with the submitted password.`;
|
||||||
|
} else if (Array.isArray(pendingSave.path) && pendingSave.path.length > 0) {
|
||||||
|
message.textContent = `KeePassGO can save this login in ${pendingSave.path.join(" / ")}. Search the vault to choose a different group if needed.`;
|
||||||
|
} else {
|
||||||
|
message.textContent = "Search the vault below to choose a group for this submitted login.";
|
||||||
|
}
|
||||||
|
action.disabled = pendingSave.mode !== "update" && (!Array.isArray(pendingSave.path) || pendingSave.path.length === 0);
|
||||||
|
action.onclick = async () => {
|
||||||
|
action.disabled = true;
|
||||||
|
try {
|
||||||
|
const result = await runtimeSend({
|
||||||
|
type: "keepassgo-save-login",
|
||||||
|
tabId: popupTabID()
|
||||||
|
});
|
||||||
|
if (!result?.success) {
|
||||||
|
throw new Error(result?.error || "Save failed.");
|
||||||
|
}
|
||||||
|
setStatus("Saved", `${pendingSave.title || "Login"} is now in KeePassGO.`, "ready");
|
||||||
|
card.hidden = true;
|
||||||
|
} catch (error) {
|
||||||
|
setStatus("Save failed", error instanceof Error ? error.message : String(error), "error");
|
||||||
|
} finally {
|
||||||
|
action.disabled = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function popupTabID() {
|
function popupTabID() {
|
||||||
const rawValue = new URLSearchParams(window.location.search).get("tabId");
|
const rawValue = new URLSearchParams(window.location.search).get("tabId");
|
||||||
if (rawValue === null) {
|
if (rawValue === null) {
|
||||||
@@ -124,8 +217,38 @@ function popupTabID() {
|
|||||||
return Number.isInteger(parsed) ? parsed : null;
|
return Number.isInteger(parsed) ? parsed : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function searchVault(event) {
|
||||||
|
event.preventDefault();
|
||||||
|
const query = document.getElementById("search-query").value.trim();
|
||||||
|
const resultsRoot = document.getElementById("search-results");
|
||||||
|
if (!query) {
|
||||||
|
renderSearchResults([], "");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
resultsRoot.textContent = "";
|
||||||
|
const loading = document.createElement("p");
|
||||||
|
loading.className = "subtle";
|
||||||
|
loading.textContent = "Searching KeePassGO…";
|
||||||
|
resultsRoot.appendChild(loading);
|
||||||
|
try {
|
||||||
|
const response = await runtimeSend({
|
||||||
|
type: "keepassgo-search-logins",
|
||||||
|
query
|
||||||
|
});
|
||||||
|
if (!response?.success) {
|
||||||
|
throw new Error(response?.error || "Search failed.");
|
||||||
|
}
|
||||||
|
renderSearchResults(Array.isArray(response.results) ? response.results : [], query);
|
||||||
|
} catch (error) {
|
||||||
|
renderSearchResults([], query);
|
||||||
|
setStatus("Search failed", error instanceof Error ? error.message : String(error), "error");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
try {
|
try {
|
||||||
|
document.getElementById("search-form").addEventListener("submit", searchVault);
|
||||||
|
renderSearchResults([], "");
|
||||||
const state = await runtimeSend({
|
const state = await runtimeSend({
|
||||||
type: "keepassgo-popup-state",
|
type: "keepassgo-popup-state",
|
||||||
force: true,
|
force: true,
|
||||||
@@ -133,6 +256,7 @@ async function main() {
|
|||||||
});
|
});
|
||||||
document.getElementById("page-host").textContent = hostFromURL(state.pageUrl || "");
|
document.getElementById("page-host").textContent = hostFromURL(state.pageUrl || "");
|
||||||
renderPageHint(state);
|
renderPageHint(state);
|
||||||
|
renderPendingSave(state);
|
||||||
|
|
||||||
if (!state.configured) {
|
if (!state.configured) {
|
||||||
setStatus("Configure access", state.error || "Set the API token in extension settings.", "warning");
|
setStatus("Configure access", state.error || "Set the API token in extension settings.", "warning");
|
||||||
@@ -158,6 +282,8 @@ async function main() {
|
|||||||
const count = Array.isArray(state.matches) ? state.matches.length : 0;
|
const count = Array.isArray(state.matches) ? state.matches.length : 0;
|
||||||
if (!state.pageHasLoginForm) {
|
if (!state.pageHasLoginForm) {
|
||||||
setStatus("Ready", "KeePassGO is connected. Open a login form to check for matches.", "ready");
|
setStatus("Ready", "KeePassGO is connected. Open a login form to check for matches.", "ready");
|
||||||
|
} else if (state.pendingSave) {
|
||||||
|
setStatus("Save submitted login", state.pendingSave.mode === "update" ? `Update ${state.pendingSave.title || "this login"} or pick a different target below.` : "Save this submitted login or search below to choose a target entry.", "ready");
|
||||||
} else if (count === 0) {
|
} else if (count === 0) {
|
||||||
setStatus("Checked this page", "KeePassGO did not find a matching login for this form.", "ready");
|
setStatus("Checked this page", "KeePassGO did not find a matching login for this form.", "ready");
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -96,6 +96,29 @@ h2 {
|
|||||||
gap: 8px;
|
gap: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.search-section {
|
||||||
|
margin-top: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.save-card {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 12px 14px;
|
||||||
|
margin: 0 0 16px;
|
||||||
|
border: 1px solid #c5dccf;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: var(--accent-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-form {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) auto;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
.match-row,
|
.match-row,
|
||||||
button,
|
button,
|
||||||
.link-button {
|
.link-button {
|
||||||
@@ -164,6 +187,33 @@ textarea {
|
|||||||
font: inherit;
|
font: inherit;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fieldset {
|
||||||
|
margin: 0;
|
||||||
|
padding: 12px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 12px;
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
legend {
|
||||||
|
padding: 0 6px;
|
||||||
|
color: var(--ink-soft);
|
||||||
|
font-size: 12px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox-row input {
|
||||||
|
width: auto;
|
||||||
|
}
|
||||||
|
|
||||||
button,
|
button,
|
||||||
.link-button {
|
.link-button {
|
||||||
padding: 10px 14px;
|
padding: 10px 14px;
|
||||||
|
|||||||
@@ -0,0 +1,78 @@
|
|||||||
|
# Android Autofill
|
||||||
|
|
||||||
|
## App Target Matching
|
||||||
|
|
||||||
|
User story:
|
||||||
|
|
||||||
|
- When an entry carries an Android-specific target such as
|
||||||
|
`androidapp://com.blinknetwork.mobile2`, KeePassGO should treat that as a
|
||||||
|
first-class autofill target on Android.
|
||||||
|
- If an exact app target exists, Android autofill should resolve that entry
|
||||||
|
directly instead of falling back to a generic chooser for the whole cache.
|
||||||
|
|
||||||
|
Expected behavior:
|
||||||
|
|
||||||
|
- `AndroidApp*` custom fields exported into the autofill cache must match the
|
||||||
|
Android package target used by the autofill and accessibility services.
|
||||||
|
- The Android-side matcher must normalize `androidapp://...` targets the same
|
||||||
|
way the Go cache builder does.
|
||||||
|
- The chooser path should still collapse to a single direct result when there
|
||||||
|
is one exact app-target match.
|
||||||
|
|
||||||
|
## Accessibility Fallback
|
||||||
|
|
||||||
|
User story:
|
||||||
|
|
||||||
|
- When Android accessibility fallback is needed, KeePassGO should not be
|
||||||
|
limited to Chrome-only URL bar parsing.
|
||||||
|
- Apps with stable package identities should still be fillable when an entry
|
||||||
|
carries a matching `AndroidApp*` target.
|
||||||
|
|
||||||
|
Expected behavior:
|
||||||
|
|
||||||
|
- Accessibility fallback derives its match target from the web domain when one
|
||||||
|
is available.
|
||||||
|
- If no web domain is available, accessibility fallback uses the active app
|
||||||
|
package as `androidapp://<package>`.
|
||||||
|
- The fallback path can therefore fill supported apps that never expose a
|
||||||
|
browser-style URL bar.
|
||||||
|
|
||||||
|
## Share-Driven Lookup
|
||||||
|
|
||||||
|
User story:
|
||||||
|
|
||||||
|
- When Android shares a login URL or a text snippet containing a login URL into
|
||||||
|
KeePassGO, the app should open into a credential lookup flow instead of only
|
||||||
|
supporting shared `.kdbx` imports.
|
||||||
|
- If the vault is already open, the shared target should immediately narrow the
|
||||||
|
entries view.
|
||||||
|
- If the vault is not open yet, the shared target should survive startup and
|
||||||
|
apply as soon as the vault is unlocked.
|
||||||
|
|
||||||
|
Expected behavior:
|
||||||
|
|
||||||
|
- Android share intents can queue a pending lookup target in addition to shared
|
||||||
|
vault file imports.
|
||||||
|
- KeePassGO normalizes the shared value into a search query that users can
|
||||||
|
immediately act on.
|
||||||
|
- The pending lookup is consumed once and does not keep reappearing on later
|
||||||
|
launches.
|
||||||
|
|
||||||
|
## Chooser Relevance
|
||||||
|
|
||||||
|
User story:
|
||||||
|
|
||||||
|
- When Android autofill cannot resolve a single direct match, KeePassGO should
|
||||||
|
still keep the chooser focused on entries that are relevant to the current
|
||||||
|
site or app.
|
||||||
|
- The picker should not fall back to an alphabetized dump of unrelated vault
|
||||||
|
entries when KeePassGO already knows the current host or package target.
|
||||||
|
|
||||||
|
Expected behavior:
|
||||||
|
|
||||||
|
- If multiple entries exactly match the current web host or Android app target,
|
||||||
|
the chooser shows only those relevant entries.
|
||||||
|
- If there are no exact matches but there are parent-host matches, the chooser
|
||||||
|
shows only those related entries.
|
||||||
|
- KeePassGO falls back to the full chooser list only when it has no related
|
||||||
|
host or app-target candidates at all.
|
||||||
@@ -93,6 +93,77 @@ Chromium / Chrome:
|
|||||||
- Username and password fields get an inline KeePassGO affordance that opens a candidate chooser anchored to the focused field and keeps fills scoped to that field's form when possible.
|
- Username and password fields get an inline KeePassGO affordance that opens a candidate chooser anchored to the focused field and keeps fills scoped to that field's form when possible.
|
||||||
- If a fill request needs user approval, the extension keeps the pending state visible in both the page affordance and the popup until KeePassGO resolves it, using the token-scoped pending-approval count from the local gRPC API.
|
- If a fill request needs user approval, the extension keeps the pending state visible in both the page affordance and the popup until KeePassGO resolves it, using the token-scoped pending-approval count from the local gRPC API.
|
||||||
|
|
||||||
|
## Search And Matching
|
||||||
|
|
||||||
|
User story:
|
||||||
|
|
||||||
|
- When a page has no obvious match, the popup still lets the user search the
|
||||||
|
vault without leaving the browser.
|
||||||
|
- Search results must stay scoped to what the current API token can actually
|
||||||
|
access.
|
||||||
|
- Browser matching must treat common KeePass data conventions as real browser
|
||||||
|
targets, not just the primary `URL` field.
|
||||||
|
- Users who prefer narrow suggestions can ask the extension to show only the
|
||||||
|
strongest match quality returned by KeePassGO.
|
||||||
|
|
||||||
|
Expected behavior:
|
||||||
|
|
||||||
|
- The popup exposes a `Search Vault` field that queries KeePassGO directly.
|
||||||
|
- Search results use the same fill path as page matches.
|
||||||
|
- Search never leaks entries outside the token's authorized group scope.
|
||||||
|
- A browser match can come from:
|
||||||
|
- the primary `URL` field
|
||||||
|
- scheme-less host values such as `gitlab.com`
|
||||||
|
- custom URL fields such as `URL1`, `URL2`, and similar KeePass-style URL
|
||||||
|
slots
|
||||||
|
- The extension settings page exposes `Best match only`.
|
||||||
|
- When `Best match only` is enabled, page suggestions and popup search results
|
||||||
|
only show the strongest quality band returned by KeePassGO.
|
||||||
|
|
||||||
|
## Locked Vault Workflow
|
||||||
|
|
||||||
|
User story:
|
||||||
|
|
||||||
|
- When the current page has a login form but KeePassGO is locked, the browser
|
||||||
|
must still make that state visible on the page and in the popup.
|
||||||
|
- Unlocking KeePassGO should not require the user to reopen the popup multiple
|
||||||
|
times or reload the page before the extension becomes usable again.
|
||||||
|
|
||||||
|
Expected behavior:
|
||||||
|
|
||||||
|
- The popup shows a locked-state message instead of silently falling back to
|
||||||
|
"no matches."
|
||||||
|
- The inline page affordance stays visible on login forms while KeePassGO is
|
||||||
|
locked and tells the user to unlock the vault.
|
||||||
|
- After the vault is unlocked, the extension rechecks the page automatically
|
||||||
|
and turns the locked affordance back into live matches without requiring a
|
||||||
|
page reload.
|
||||||
|
|
||||||
|
## Save And Update Workflow
|
||||||
|
|
||||||
|
User story:
|
||||||
|
|
||||||
|
- After the user submits a login form, the browser extension should help store
|
||||||
|
that credential instead of forcing the user back into KeePassGO manually.
|
||||||
|
- If KeePassGO already has a matching entry for that site and username, the
|
||||||
|
popup should offer an update.
|
||||||
|
- If the user is creating a new login, the popup should let the user save it
|
||||||
|
into a relevant vault group without leaving the browser.
|
||||||
|
|
||||||
|
Expected behavior:
|
||||||
|
|
||||||
|
- Submitted login forms queue a pending browser save/update state for the
|
||||||
|
active tab.
|
||||||
|
- The popup shows that pending save/update state prominently instead of hiding
|
||||||
|
it behind page matches alone.
|
||||||
|
- When KeePassGO finds an exact browser match for the submitted username and
|
||||||
|
site, the popup offers an `Update` action for that entry.
|
||||||
|
- When there is no exact entry match, the popup offers a `Save` action using a
|
||||||
|
relevant group path from the current page matches or a user-selected search
|
||||||
|
result.
|
||||||
|
- The browser save/update action writes through KeePassGO's existing secure
|
||||||
|
gRPC mutation API and stays scoped to the browser token's allowed groups.
|
||||||
|
|
||||||
For extension-side regression checks, run:
|
For extension-side regression checks, run:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -0,0 +1,207 @@
|
|||||||
|
# GUI Test Plan
|
||||||
|
|
||||||
|
This document splits GUI validation into human-owned and agent-owned coverage.
|
||||||
|
The intent is that, together, both passes exercise every reachable GUI area
|
||||||
|
without relying on one side to judge both functional correctness and real
|
||||||
|
usability.
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
- Desktop GUI
|
||||||
|
- Android GUI
|
||||||
|
- Shared workflow surfaces:
|
||||||
|
lifecycle, unlock, list/detail/editor, settings, sync, API tokens, API audit,
|
||||||
|
templates, recycle bin, About, and platform-specific integrations
|
||||||
|
|
||||||
|
## Ownership Model
|
||||||
|
|
||||||
|
- Human testing covers visual correctness, ergonomics, discoverability, focus,
|
||||||
|
scrolling, tap targets, keyboard feel, and platform integration behavior.
|
||||||
|
- Agent testing covers deterministic automation:
|
||||||
|
build, install, launch, focus, test suite, lint, and release-path validation.
|
||||||
|
|
||||||
|
## Android Plan
|
||||||
|
|
||||||
|
### Coverage Matrix
|
||||||
|
|
||||||
|
| Area | Human | Agent |
|
||||||
|
|---|---|---|
|
||||||
|
| App launch and first render | Verify real rendering, layout, readability, no black screen | Verify emulator online, app installed, app focused |
|
||||||
|
| Lifecycle screen | Exercise create/open/open-remote forms visually | Cover lifecycle logic through Go tests and release APK build |
|
||||||
|
| Unlock flow | Verify password/key-file affordances, focus, error messaging | Covered by automated tests |
|
||||||
|
| Vault list and navigation | Verify scrolling, selection, section switching, phone layout reachability | Covered by automated tests for list state/search/section behavior |
|
||||||
|
| Entry detail | Verify readability, action placement, tap targets | Covered by automated tests for state transitions and copy/generate actions |
|
||||||
|
| Entry editor | Verify field editing usability and save affordance | Covered by automated tests for save/update behavior |
|
||||||
|
| Template views | Verify template list/editor reachability and copy | Covered by automated tests |
|
||||||
|
| Recycle bin | Verify deleted-entry browsing/search UX | Covered by automated tests |
|
||||||
|
| Attachments UI | Verify list/add/remove affordances visually | Covered by automated tests for attachment summaries and save paths |
|
||||||
|
| Search | Verify placeholder, live filtering feel, clear behavior | Covered by automated tests for search semantics |
|
||||||
|
| Settings | Verify toggles and summaries are understandable | Covered by automated tests for persistence |
|
||||||
|
| Remote sync setup/dialog | Verify sheet/dialog layout, field order, source/direction controls | Covered by automated tests and release build |
|
||||||
|
| Saved remote binding UI | Verify summaries are understandable and discoverable | Covered by automated tests |
|
||||||
|
| API tokens / API audit | Verify navigation and dense-detail readability | Covered by automated tests |
|
||||||
|
| About / informational screens | Verify copy, scroll, layout | Covered by automated tests for search disable state and section state |
|
||||||
|
| Android-only share/import/file picker | Verify system integration actually works | Agent only verifies package/build/focus, not picker UX |
|
||||||
|
| Android autofill entry point | Verify Android mechanism appears and is usable | Agent only verifies app/package/focus |
|
||||||
|
|
||||||
|
### Human Steps
|
||||||
|
|
||||||
|
1. Start from the currently installed `org.julianfamily.keepassgo` app on the
|
||||||
|
running emulator or device.
|
||||||
|
2. Verify launch lands on a rendered screen, not a black frame, blank frame, or
|
||||||
|
frozen frame.
|
||||||
|
3. On the lifecycle screen, inspect every visible action:
|
||||||
|
`Create vault`, `Open vault`, `Open remote vault`, unlock-related controls,
|
||||||
|
recent vaults, and any sync summaries.
|
||||||
|
4. Create a throwaway vault or open a demo vault and verify the full unlock path
|
||||||
|
is visually clear.
|
||||||
|
5. After unlock, visit each top-level section you can reach in the Android UI:
|
||||||
|
vault/group list, entry detail, entry editor, templates, recycle bin,
|
||||||
|
settings, API tokens, API audit, About.
|
||||||
|
6. In the main vault UI, verify:
|
||||||
|
group navigation, entry selection, search, back behavior, scrolling, and any
|
||||||
|
phone-only toggles or drawers.
|
||||||
|
7. Open an entry and exercise every visible entry action:
|
||||||
|
copy username, copy password, copy URL, reveal/hide password, generate
|
||||||
|
password, save.
|
||||||
|
8. Exercise one template flow:
|
||||||
|
open template list, inspect template detail/editor, save or cancel.
|
||||||
|
9. Exercise recycle-bin browsing and search.
|
||||||
|
10. Open settings and inspect every visible toggle or summary card. Change at
|
||||||
|
least one benign toggle and verify it sticks after leaving and returning.
|
||||||
|
11. Open the sync UI and inspect:
|
||||||
|
remote setup, saved-binding summary, direction/source choices,
|
||||||
|
confirm/cancel behavior, and text summaries.
|
||||||
|
12. Exercise one Android-native integration:
|
||||||
|
shared-vault import, file picker open, or current-vault share.
|
||||||
|
13. If Android autofill is in scope for current testing, open a target app/site
|
||||||
|
and confirm the Android autofill entry point is offered and usable.
|
||||||
|
14. Record failures with:
|
||||||
|
exact screen, exact control, expected behavior, actual behavior, and whether
|
||||||
|
it is Android-only or likely shared with desktop.
|
||||||
|
|
||||||
|
### Agent Steps Executed
|
||||||
|
|
||||||
|
1. Verified emulator availability with `adb devices -l`.
|
||||||
|
2. Verified emulator Android version with
|
||||||
|
`adb shell getprop ro.build.version.release`.
|
||||||
|
3. Verified app package installed with
|
||||||
|
`adb shell pm list packages org.julianfamily.keepassgo`.
|
||||||
|
4. Verified app focus with
|
||||||
|
`adb shell dumpsys window | rg 'mCurrentFocus|mFocusedApp'`.
|
||||||
|
5. Ran `go test ./...`.
|
||||||
|
6. Ran `go tool golangci-lint run ./...`.
|
||||||
|
7. Verified release APK path through release workflow `v0.6.0`:
|
||||||
|
PR run `60` succeeded,
|
||||||
|
post-merge `main` run `61` succeeded,
|
||||||
|
tag run `62` succeeded.
|
||||||
|
|
||||||
|
### Agent Results
|
||||||
|
|
||||||
|
- Emulator present: yes
|
||||||
|
- Android version: `15`
|
||||||
|
- App installed: yes
|
||||||
|
- App focused:
|
||||||
|
`org.julianfamily.keepassgo/org.gioui.GioActivity`
|
||||||
|
- APK release build path: passed in release CI
|
||||||
|
- Remaining Android risk:
|
||||||
|
real visual usability and system-integration behavior
|
||||||
|
|
||||||
|
## Desktop Plan
|
||||||
|
|
||||||
|
### Coverage Matrix
|
||||||
|
|
||||||
|
| Area | Human | Agent |
|
||||||
|
|---|---|---|
|
||||||
|
| App launch and window presentation | Verify startup polish, focus, resize behavior, DPI/readability | Verify desktop binaries built in release CI |
|
||||||
|
| Lifecycle screen | Verify desktop layout density, affordance clarity, recent vault usability | Covered by automated tests |
|
||||||
|
| Unlock flow | Verify keyboard-first behavior, focus order, errors, lock/unlock loop | Covered by automated tests plus human keyboard feel |
|
||||||
|
| Group browser and list pane | Verify density, selection clarity, scrolling, split behavior | Covered by automated tests for state/search |
|
||||||
|
| Entry detail pane | Verify copy actions, reveal/hide, path context, readability | Covered by automated tests |
|
||||||
|
| Entry editor | Verify field order, keyboard flow, save/cancel usability | Covered by automated tests |
|
||||||
|
| Templates | Verify templates section is reachable and understandable | Covered by automated tests |
|
||||||
|
| Recycle bin | Verify browsing, searching, deletion or recovery UX | Covered by automated tests |
|
||||||
|
| Search | Verify keyboard search flow, placeholder correctness, result context | Covered by automated tests |
|
||||||
|
| Vault save/save-as/lock | Verify menus, buttons, and shortcuts feel correct | Covered by automated tests and release builds |
|
||||||
|
| Settings | Verify desktop layout, summaries, toggles, persistence feel | Covered by automated tests |
|
||||||
|
| Remote sync UI | Verify dialog layout, wording, discoverability, advanced options | Covered by automated tests |
|
||||||
|
| API tokens | Verify dense-detail presentation and policy editor usability | Covered by automated tests |
|
||||||
|
| API audit | Verify search/filter/readability | Covered by automated tests |
|
||||||
|
| Browser-extension-adjacent desktop UX | Verify visible status/help text and extension workflow discoverability | Agent validated release/build path; human should judge usability |
|
||||||
|
| About and docs entry points | Verify copy and layout | Covered partly by tests; human judges presentation |
|
||||||
|
|
||||||
|
### Human Steps
|
||||||
|
|
||||||
|
1. Launch KeePassGO on desktop from the shipped binary or normal desktop entry.
|
||||||
|
2. Inspect the lifecycle/open screen at normal window size and at a narrower
|
||||||
|
width.
|
||||||
|
3. Exercise create, open, save-as, lock, unlock, and reopen on a throwaway
|
||||||
|
vault or demo vault.
|
||||||
|
4. Use keyboard-first operation for at least one complete pass:
|
||||||
|
tab order, enter or escape expectations, search focus, unlock focus, and
|
||||||
|
editor save flow.
|
||||||
|
5. After unlocking, visit every reachable primary section:
|
||||||
|
vault list, entry detail, editor, templates, recycle bin, settings,
|
||||||
|
API tokens, API audit, About.
|
||||||
|
6. In the vault browser, verify:
|
||||||
|
nested groups, path context, scrolling, selection state, and search results
|
||||||
|
with path context.
|
||||||
|
7. Open an entry and exercise all visible actions:
|
||||||
|
copy username, password, URL, reveal/hide password, password generation,
|
||||||
|
and save.
|
||||||
|
8. Exercise at least one mutation flow:
|
||||||
|
create entry, edit entry, move/delete entry, view recycle bin, and recover
|
||||||
|
if available.
|
||||||
|
9. Open settings and inspect all visible summaries and toggles.
|
||||||
|
10. Open remote sync UI and inspect every visible mode:
|
||||||
|
setup, saved binding, advanced sync, source or direction choices.
|
||||||
|
11. Open API Tokens and API Audit even if you do not issue a real token, just
|
||||||
|
to assess navigation and readability.
|
||||||
|
12. If you use browser integration, verify the desktop-side flow is still
|
||||||
|
understandable from the product UI and extension behavior.
|
||||||
|
13. Record failures with:
|
||||||
|
screen, control, expected behavior, actual behavior, and whether it is
|
||||||
|
presentation-only or functional.
|
||||||
|
|
||||||
|
### Agent Steps Executed
|
||||||
|
|
||||||
|
1. Verified clean repo state on `main`.
|
||||||
|
2. Ran `go test ./...`.
|
||||||
|
3. Ran `go tool golangci-lint run ./...`.
|
||||||
|
4. Verified post-merge `main` CI run `61` succeeded.
|
||||||
|
5. Verified release tag run `62` succeeded.
|
||||||
|
6. Verified release `v0.6.0` published.
|
||||||
|
7. Verified release artifacts include:
|
||||||
|
`keepassgo-linux-amd64`,
|
||||||
|
`keepassgo-windows-amd64.exe`,
|
||||||
|
`keepassgo-windows-arm64.exe`,
|
||||||
|
and `keepassgo.apk`.
|
||||||
|
|
||||||
|
### Agent Results
|
||||||
|
|
||||||
|
- Automated logic/state coverage: pass
|
||||||
|
- Desktop build coverage: pass
|
||||||
|
- Release publication: pass
|
||||||
|
- Remaining desktop risk:
|
||||||
|
interaction quality, keyboard feel, dense-layout readability, and workflow
|
||||||
|
discovery
|
||||||
|
|
||||||
|
## Reporting Template
|
||||||
|
|
||||||
|
Use this format when reporting findings from the human pass:
|
||||||
|
|
||||||
|
```md
|
||||||
|
## Android
|
||||||
|
- Screen:
|
||||||
|
- Action:
|
||||||
|
- Expected:
|
||||||
|
- Actual:
|
||||||
|
- Severity:
|
||||||
|
|
||||||
|
## Desktop
|
||||||
|
- Screen:
|
||||||
|
- Action:
|
||||||
|
- Expected:
|
||||||
|
- Actual:
|
||||||
|
- Severity:
|
||||||
|
```
|
||||||
+108
-9
@@ -275,7 +275,7 @@ func (s *Server) FindBrowserLogins(ctx context.Context, req *keepassgov1.FindBro
|
|||||||
|
|
||||||
var matches []rankedBrowserMatch
|
var matches []rankedBrowserMatch
|
||||||
for _, entry := range displayModel.Entries {
|
for _, entry := range displayModel.Entries {
|
||||||
quality, score := classifyBrowserEntryMatch(pageHost, entry.URL)
|
quality, score := classifyBrowserEntry(pageHost, entry)
|
||||||
if score == 0 {
|
if score == 0 {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -390,7 +390,7 @@ func (s *Server) GetBrowserCredential(ctx context.Context, req *keepassgov1.GetB
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, status.Error(codes.InvalidArgument, err.Error())
|
return nil, status.Error(codes.InvalidArgument, err.Error())
|
||||||
}
|
}
|
||||||
if _, score := classifyBrowserEntryMatch(pageHost, entry.URL); score == 0 {
|
if _, score := classifyBrowserEntry(pageHost, entry); score == 0 {
|
||||||
return nil, status.Error(codes.InvalidArgument, "entry url does not match requested page")
|
return nil, status.Error(codes.InvalidArgument, "entry url does not match requested page")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -446,19 +446,22 @@ func (s *Server) ListEntries(ctx context.Context, req *keepassgov1.ListEntriesRe
|
|||||||
}
|
}
|
||||||
displayModel := visibleModel(model)
|
displayModel := visibleModel(model)
|
||||||
internalPath := expandClientPath(displayModel, req.GetPath())
|
internalPath := expandClientPath(displayModel, req.GetPath())
|
||||||
if _, err := s.authorizePathRequest(ctx, apitokens.OperationListEntries, internalPath); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
model = displayModel
|
model = displayModel
|
||||||
var entries []vault.Entry
|
var entries []vault.Entry
|
||||||
if strings.TrimSpace(req.GetQuery()) != "" {
|
if strings.TrimSpace(req.GetQuery()) != "" {
|
||||||
|
token, err := s.authenticateRequest(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
results := model.Search(req.GetQuery())
|
results := model.Search(req.GetQuery())
|
||||||
entries = make([]vault.Entry, 0, len(results))
|
entries, err = s.authorizedSearchEntries(ctx, model, token, internalPath, results)
|
||||||
for _, result := range results {
|
if err != nil {
|
||||||
entries = append(entries, result.Entry)
|
return nil, err
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
if _, err := s.authorizePathRequest(ctx, apitokens.OperationListEntries, internalPath); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
entries = model.EntriesInPath(internalPath)
|
entries = model.EntriesInPath(internalPath)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -472,6 +475,49 @@ func (s *Server) ListEntries(ctx context.Context, req *keepassgov1.ListEntriesRe
|
|||||||
return resp, nil
|
return resp, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Server) authorizedSearchEntries(ctx context.Context, model vault.Model, token apitokens.Token, path []string, results []vault.SearchResult) ([]vault.Entry, error) {
|
||||||
|
entries := make([]vault.Entry, 0, len(results))
|
||||||
|
var promptResource *apitokens.Resource
|
||||||
|
for _, result := range results {
|
||||||
|
entry := result.Entry
|
||||||
|
if !hasPathPrefix(path, entry.Path) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
resource := apitokens.Resource{Kind: apitokens.ResourceGroup, Path: entry.Path}
|
||||||
|
switch evaluateAuthorization(model, token, apitokens.OperationListEntries, resource) {
|
||||||
|
case apitokens.DecisionAllow:
|
||||||
|
entries = append(entries, entry)
|
||||||
|
case apitokens.DecisionPrompt:
|
||||||
|
if promptResource == nil {
|
||||||
|
candidate := resource
|
||||||
|
promptResource = &candidate
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(entries) != 0 || promptResource == nil {
|
||||||
|
return entries, nil
|
||||||
|
}
|
||||||
|
if _, err := s.authorizeResourceRequest(ctx, token, apitokens.OperationListEntries, *promptResource); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return authorizedSearchEntriesWithinPath(path, promptResource.Path, results), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func authorizedSearchEntriesWithinPath(requestPath, approvedPath []string, results []vault.SearchResult) []vault.Entry {
|
||||||
|
entries := make([]vault.Entry, 0, len(results))
|
||||||
|
for _, result := range results {
|
||||||
|
entry := result.Entry
|
||||||
|
if !hasPathPrefix(requestPath, entry.Path) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !hasPathPrefix(approvedPath, entry.Path) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
entries = append(entries, entry)
|
||||||
|
}
|
||||||
|
return entries
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Server) ListGroups(ctx context.Context, req *keepassgov1.ListGroupsRequest) (*keepassgov1.ListGroupsResponse, error) {
|
func (s *Server) ListGroups(ctx context.Context, req *keepassgov1.ListGroupsRequest) (*keepassgov1.ListGroupsResponse, error) {
|
||||||
model, locked := s.snapshotModel()
|
model, locked := s.snapshotModel()
|
||||||
if locked {
|
if locked {
|
||||||
@@ -1063,6 +1109,52 @@ func normalizedBrowserEntryHost(raw string) string {
|
|||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func browserURLFieldKey(key string) bool {
|
||||||
|
if len(key) <= len("URL") || !strings.EqualFold(key[:len("URL")], "URL") {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, r := range key[len("URL"):] {
|
||||||
|
if r < '0' || r > '9' {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func browserEntryURLs(entry vault.Entry) []string {
|
||||||
|
urls := make([]string, 0, 1+len(entry.Fields))
|
||||||
|
if raw := strings.TrimSpace(entry.URL); raw != "" {
|
||||||
|
urls = append(urls, raw)
|
||||||
|
}
|
||||||
|
if len(entry.Fields) == 0 {
|
||||||
|
return urls
|
||||||
|
}
|
||||||
|
keys := slices.Collect(maps.Keys(entry.Fields))
|
||||||
|
slices.Sort(keys)
|
||||||
|
for _, key := range keys {
|
||||||
|
if !browserURLFieldKey(key) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if raw := strings.TrimSpace(entry.Fields[key]); raw != "" {
|
||||||
|
urls = append(urls, raw)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return urls
|
||||||
|
}
|
||||||
|
|
||||||
|
func classifyBrowserEntry(pageHost string, entry vault.Entry) (string, int) {
|
||||||
|
bestQuality := ""
|
||||||
|
bestScore := 0
|
||||||
|
for _, rawURL := range browserEntryURLs(entry) {
|
||||||
|
quality, score := classifyBrowserEntryMatch(pageHost, rawURL)
|
||||||
|
if score > bestScore {
|
||||||
|
bestQuality = quality
|
||||||
|
bestScore = score
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return bestQuality, bestScore
|
||||||
|
}
|
||||||
|
|
||||||
func classifyBrowserEntryMatch(pageHost, rawEntryURL string) (string, int) {
|
func classifyBrowserEntryMatch(pageHost, rawEntryURL string) (string, int) {
|
||||||
entryHost := normalizedBrowserEntryHost(rawEntryURL)
|
entryHost := normalizedBrowserEntryHost(rawEntryURL)
|
||||||
if entryHost == "" {
|
if entryHost == "" {
|
||||||
@@ -1078,6 +1170,13 @@ func classifyBrowserEntryMatch(pageHost, rawEntryURL string) (string, int) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func hasPathPrefix(prefix, path []string) bool {
|
||||||
|
if len(prefix) > len(path) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return slices.Equal(prefix, path[:len(prefix)])
|
||||||
|
}
|
||||||
|
|
||||||
func visibleModel(model vault.Model) vault.Model {
|
func visibleModel(model vault.Model) vault.Model {
|
||||||
out := model
|
out := model
|
||||||
out.Entries = nil
|
out.Entries = nil
|
||||||
|
|||||||
@@ -294,6 +294,55 @@ func TestVaultServiceFindsBrowserLoginsForSchemeLessEntryURLs(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestVaultServiceFindsBrowserLoginsForCustomURLFields(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
client, _, cleanup := newTestClientForModel(t, vault.Model{
|
||||||
|
Entries: []vault.Entry{
|
||||||
|
{
|
||||||
|
ID: "night-fox-gitlab",
|
||||||
|
Title: "Night Fox GitLab",
|
||||||
|
Username: "nightfox",
|
||||||
|
Password: "vault-code",
|
||||||
|
Path: []string{"Root", "Internet"},
|
||||||
|
Fields: map[string]string{
|
||||||
|
"URL1": "gitlab.com",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
testAPITokenEntry(t,
|
||||||
|
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationListEntries, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root"}}},
|
||||||
|
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationCopyUsername, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root"}}},
|
||||||
|
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationCopyPassword, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root"}}},
|
||||||
|
),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
resp, err := client.FindBrowserLogins(tokenContext(defaultTestTokenSecret), &keepassgov1.FindBrowserLoginsRequest{
|
||||||
|
PageUrl: "https://gitlab.com/users/sign_in",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("FindBrowserLogins() error = %v", err)
|
||||||
|
}
|
||||||
|
if len(resp.Matches) != 1 {
|
||||||
|
t.Fatalf("len(FindBrowserLogins().Matches) = %d, want 1", len(resp.Matches))
|
||||||
|
}
|
||||||
|
if resp.Matches[0].Id != "night-fox-gitlab" {
|
||||||
|
t.Fatalf("FindBrowserLogins().Matches[0].Id = %q, want night-fox-gitlab", resp.Matches[0].Id)
|
||||||
|
}
|
||||||
|
|
||||||
|
credential, err := client.GetBrowserCredential(tokenContext(defaultTestTokenSecret), &keepassgov1.GetBrowserCredentialRequest{
|
||||||
|
Id: "night-fox-gitlab",
|
||||||
|
PageUrl: "https://gitlab.com/users/sign_in",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetBrowserCredential() error = %v", err)
|
||||||
|
}
|
||||||
|
if credential.GetId() != "night-fox-gitlab" {
|
||||||
|
t.Fatalf("GetBrowserCredential().Id = %q, want night-fox-gitlab", credential.GetId())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestVaultServiceFindsBrowserLoginsWithinAuthorizedGroupScope(t *testing.T) {
|
func TestVaultServiceFindsBrowserLoginsWithinAuthorizedGroupScope(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -1203,6 +1252,51 @@ func TestVaultServiceListsEntriesForAuthorizedClients(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestVaultServiceSearchesEntriesWithinAuthorizedScope(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
client, _, cleanup := newTestClientForModel(t, vault.Model{
|
||||||
|
Entries: []vault.Entry{
|
||||||
|
{
|
||||||
|
ID: "turk-codex",
|
||||||
|
Title: "Turk Codex GitLab",
|
||||||
|
Username: "basher",
|
||||||
|
Password: "chip-stack",
|
||||||
|
URL: "https://gitlab.com",
|
||||||
|
Path: []string{"keepass", "Joe", "codex"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: "rusty-internet",
|
||||||
|
Title: "Rusty Internet GitLab",
|
||||||
|
Username: "rusty",
|
||||||
|
Password: "bellagio-stack",
|
||||||
|
URL: "https://gitlab.com",
|
||||||
|
Path: []string{"keepass", "Joe", "Internet"},
|
||||||
|
},
|
||||||
|
testAPITokenEntry(t,
|
||||||
|
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationListEntries, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root", "Joe", "codex"}}},
|
||||||
|
),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
resp, err := client.ListEntries(tokenContext(defaultTestTokenSecret), &keepassgov1.ListEntriesRequest{
|
||||||
|
Query: "GitLab",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ListEntries() error = %v", err)
|
||||||
|
}
|
||||||
|
if len(resp.Entries) != 1 {
|
||||||
|
t.Fatalf("len(ListEntries().Entries) = %d, want 1", len(resp.Entries))
|
||||||
|
}
|
||||||
|
if got := resp.Entries[0].Id; got != "turk-codex" {
|
||||||
|
t.Fatalf("ListEntries().Entries[0].Id = %q, want turk-codex", got)
|
||||||
|
}
|
||||||
|
if got := resp.Entries[0].Path; !slices.Equal(got, []string{"Joe", "codex"}) {
|
||||||
|
t.Fatalf("ListEntries().Entries[0].Path = %v, want [Joe codex]", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestVaultServiceListsCreatesAndRenamesGroupsForAuthorizedClients(t *testing.T) {
|
func TestVaultServiceListsCreatesAndRenamesGroupsForAuthorizedClients(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
|
|||||||
@@ -140,6 +140,7 @@ type statePaths struct {
|
|||||||
AutofillCachePath string
|
AutofillCachePath string
|
||||||
PendingSharedVaultPath string
|
PendingSharedVaultPath string
|
||||||
PendingSharedVaultNamePath string
|
PendingSharedVaultNamePath string
|
||||||
|
PendingSharedLookupPath string
|
||||||
}
|
}
|
||||||
|
|
||||||
type recentVaultRecord struct {
|
type recentVaultRecord struct {
|
||||||
@@ -474,6 +475,8 @@ type ui struct {
|
|||||||
autofillCachePath string
|
autofillCachePath string
|
||||||
pendingSharedVaultPath string
|
pendingSharedVaultPath string
|
||||||
pendingSharedVaultNamePath string
|
pendingSharedVaultNamePath string
|
||||||
|
pendingSharedLookupPath string
|
||||||
|
pendingSharedLookupQuery string
|
||||||
editingEntry bool
|
editingEntry bool
|
||||||
syncDefaultSourceMode syncSourceMode
|
syncDefaultSourceMode syncSourceMode
|
||||||
syncDefaultDirection syncDirection
|
syncDefaultDirection syncDirection
|
||||||
@@ -656,6 +659,7 @@ func newUIWithState(mode string, sess appstate.CurrentSession, paths statePaths)
|
|||||||
autofillCachePath: paths.AutofillCachePath,
|
autofillCachePath: paths.AutofillCachePath,
|
||||||
pendingSharedVaultPath: paths.PendingSharedVaultPath,
|
pendingSharedVaultPath: paths.PendingSharedVaultPath,
|
||||||
pendingSharedVaultNamePath: paths.PendingSharedVaultNamePath,
|
pendingSharedVaultNamePath: paths.PendingSharedVaultNamePath,
|
||||||
|
pendingSharedLookupPath: paths.PendingSharedLookupPath,
|
||||||
recentVaultGroups: map[string][]string{},
|
recentVaultGroups: map[string][]string{},
|
||||||
recentVaultUsedAt: map[string]time.Time{},
|
recentVaultUsedAt: map[string]time.Time{},
|
||||||
lifecycleAdvancedHidden: true,
|
lifecycleAdvancedHidden: true,
|
||||||
@@ -704,6 +708,7 @@ func newUIWithState(mode string, sess appstate.CurrentSession, paths statePaths)
|
|||||||
u.showStatusMessage("Some saved remote sign-ins came from an older KeePassGO build. Reopen those remotes and save them in the vault to migrate them.")
|
u.showStatusMessage("Some saved remote sign-ins came from an older KeePassGO build. Reopen those remotes and save them in the vault to migrate them.")
|
||||||
}
|
}
|
||||||
u.consumePendingSharedVaultImport()
|
u.consumePendingSharedVaultImport()
|
||||||
|
u.consumePendingSharedLookup()
|
||||||
u.restoreStartupLifecycleTarget()
|
u.restoreStartupLifecycleTarget()
|
||||||
u.requestMasterPassFocus = u.hasSelectedLifecycleTarget()
|
u.requestMasterPassFocus = u.hasSelectedLifecycleTarget()
|
||||||
u.loadUIPreferences()
|
u.loadUIPreferences()
|
||||||
@@ -785,6 +790,7 @@ func defaultStatePaths(stateDir string) statePaths {
|
|||||||
AutofillCachePath: filepath.Join(baseDir, "autofill-cache.json"),
|
AutofillCachePath: filepath.Join(baseDir, "autofill-cache.json"),
|
||||||
PendingSharedVaultPath: filepath.Join(baseDir, "pending-shared-vault.kdbx"),
|
PendingSharedVaultPath: filepath.Join(baseDir, "pending-shared-vault.kdbx"),
|
||||||
PendingSharedVaultNamePath: filepath.Join(baseDir, "pending-shared-vault-name.txt"),
|
PendingSharedVaultNamePath: filepath.Join(baseDir, "pending-shared-vault-name.txt"),
|
||||||
|
PendingSharedLookupPath: filepath.Join(baseDir, "pending-shared-lookup.txt"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,8 +4,10 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
"runtime"
|
"runtime"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
@@ -17,6 +19,8 @@ import (
|
|||||||
"git.julianfamily.org/keepassgo/internal/webdav"
|
"git.julianfamily.org/keepassgo/internal/webdav"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
var pendingSharedLookupURLPattern = regexp.MustCompile(`https?://[^\s<>"']+`)
|
||||||
|
|
||||||
func (u *ui) createVaultAction() error {
|
func (u *ui) createVaultAction() error {
|
||||||
key, err := u.currentMasterKey()
|
key, err := u.currentMasterKey()
|
||||||
defer u.clearMasterPassword()
|
defer u.clearMasterPassword()
|
||||||
@@ -78,6 +82,7 @@ func (u *ui) openVaultAction() error {
|
|||||||
u.loadSecuritySettingsFromSession()
|
u.loadSecuritySettingsFromSession()
|
||||||
u.editingEntry = false
|
u.editingEntry = false
|
||||||
u.filter()
|
u.filter()
|
||||||
|
u.applyPendingSharedLookup()
|
||||||
u.applyPendingLifecycleOpenIntent()
|
u.applyPendingLifecycleOpenIntent()
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -120,6 +125,7 @@ func (u *ui) startOpenVaultAction() {
|
|||||||
u.loadSecuritySettingsFromSession()
|
u.loadSecuritySettingsFromSession()
|
||||||
u.editingEntry = false
|
u.editingEntry = false
|
||||||
u.filter()
|
u.filter()
|
||||||
|
u.applyPendingSharedLookup()
|
||||||
u.applyPendingLifecycleOpenIntent()
|
u.applyPendingLifecycleOpenIntent()
|
||||||
return nil
|
return nil
|
||||||
}, nil
|
}, nil
|
||||||
@@ -741,6 +747,49 @@ func (u *ui) consumePendingSharedVaultImport() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func normalizePendingSharedLookupQuery(raw string) string {
|
||||||
|
value := strings.TrimSpace(raw)
|
||||||
|
if value == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if match := pendingSharedLookupURLPattern.FindString(value); match != "" {
|
||||||
|
value = match
|
||||||
|
}
|
||||||
|
if parsed, err := url.Parse(value); err == nil && strings.TrimSpace(parsed.Hostname()) != "" {
|
||||||
|
return strings.ToLower(strings.TrimSpace(parsed.Hostname()))
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *ui) consumePendingSharedLookup() {
|
||||||
|
path := strings.TrimSpace(u.pendingSharedLookupPath)
|
||||||
|
if path == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
if !errors.Is(err, os.ErrNotExist) {
|
||||||
|
u.state.ErrorMessage = fmt.Sprintf("shared lookup: %v", err)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_ = os.Remove(path)
|
||||||
|
u.pendingSharedLookupQuery = normalizePendingSharedLookupQuery(string(data))
|
||||||
|
u.applyPendingSharedLookup()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *ui) applyPendingSharedLookup() {
|
||||||
|
query := strings.TrimSpace(u.pendingSharedLookupQuery)
|
||||||
|
status, ok := u.state.Session.(sessionStatus)
|
||||||
|
if query == "" || !ok || !status.HasVault() || status.IsLocked() {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
u.pendingSharedLookupQuery = ""
|
||||||
|
u.state.Section = appstate.SectionEntries
|
||||||
|
u.search.SetText(query)
|
||||||
|
u.filter()
|
||||||
|
}
|
||||||
|
|
||||||
func (u *ui) importSharedVaultBytesAction(name string, content []byte) error {
|
func (u *ui) importSharedVaultBytesAction(name string, content []byte) error {
|
||||||
target := u.importedVaultDestination(name)
|
target := u.importedVaultDestination(name)
|
||||||
if err := os.MkdirAll(filepath.Dir(target), 0o700); err != nil {
|
if err := os.MkdirAll(filepath.Dir(target), 0o700); err != nil {
|
||||||
|
|||||||
@@ -8390,6 +8390,9 @@ func TestDefaultStatePathsUsesProvidedStateDir(t *testing.T) {
|
|||||||
if got := paths.PendingSharedVaultNamePath; got != filepath.Join(base, "pending-shared-vault-name.txt") {
|
if got := paths.PendingSharedVaultNamePath; got != filepath.Join(base, "pending-shared-vault-name.txt") {
|
||||||
t.Fatalf("PendingSharedVaultNamePath = %q, want %q", got, filepath.Join(base, "pending-shared-vault-name.txt"))
|
t.Fatalf("PendingSharedVaultNamePath = %q, want %q", got, filepath.Join(base, "pending-shared-vault-name.txt"))
|
||||||
}
|
}
|
||||||
|
if got := paths.PendingSharedLookupPath; got != filepath.Join(base, "pending-shared-lookup.txt") {
|
||||||
|
t.Fatalf("PendingSharedLookupPath = %q, want %q", got, filepath.Join(base, "pending-shared-lookup.txt"))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestImportedVaultDestinationUsesIncomingFilenameInsideDefaultDirectory(t *testing.T) {
|
func TestImportedVaultDestinationUsesIncomingFilenameInsideDefaultDirectory(t *testing.T) {
|
||||||
@@ -8520,6 +8523,95 @@ func TestUIConsumesPendingSharedVaultImportOnStartup(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestUIConsumesPendingSharedLookupOnStartupWhenVaultIsAlreadyOpen(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
dir := t.TempDir()
|
||||||
|
paths := statePaths{
|
||||||
|
DefaultSaveAsPath: filepath.Join(dir, "vault.kdbx"),
|
||||||
|
RecentVaultsPath: filepath.Join(dir, "recent-vaults.json"),
|
||||||
|
RecentRemotesPath: filepath.Join(dir, "recent-remotes.json"),
|
||||||
|
UIPreferencesPath: filepath.Join(dir, "ui-prefs.json"),
|
||||||
|
PendingSharedLookupPath: filepath.Join(dir, "pending-shared-lookup.txt"),
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(paths.PendingSharedLookupPath, []byte("https://bellagio.example.invalid/login\n"), 0o600); err != nil {
|
||||||
|
t.Fatalf("WriteFile(PendingSharedLookupPath) error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
u := newUIWithSession("phone", &uiSession{model: vault.Model{
|
||||||
|
Entries: []vault.Entry{
|
||||||
|
{ID: "bellagio-login", Title: "Bellagio", URL: "https://bellagio.example.invalid/login", Path: []string{"Crew", "Internet"}},
|
||||||
|
{ID: "vault-console", Title: "Vault Console", URL: "https://vault.example.invalid", Path: []string{"Crew", "Internet"}},
|
||||||
|
},
|
||||||
|
}}, paths)
|
||||||
|
|
||||||
|
if got := u.search.Text(); got != "bellagio.example.invalid" {
|
||||||
|
t.Fatalf("search after pending shared lookup = %q, want %q", got, "bellagio.example.invalid")
|
||||||
|
}
|
||||||
|
if got := u.filteredTitles(); !slices.Equal(got, []string{"Bellagio"}) {
|
||||||
|
t.Fatalf("filteredTitles() after pending shared lookup = %v, want [Bellagio]", got)
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(paths.PendingSharedLookupPath); !errors.Is(err, os.ErrNotExist) {
|
||||||
|
t.Fatalf("Stat(PendingSharedLookupPath) error = %v, want not exist", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNormalizePendingSharedLookupQueryExtractsURLFromTextSnippet(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
raw := "Meet the crew at https://bellagio.example.invalid/login before the vault opens."
|
||||||
|
if got := normalizePendingSharedLookupQuery(raw); got != "bellagio.example.invalid" {
|
||||||
|
t.Fatalf("normalizePendingSharedLookupQuery() = %q, want %q", got, "bellagio.example.invalid")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUIAppliesPendingSharedLookupAfterOpeningVault(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
dir := t.TempDir()
|
||||||
|
paths := statePaths{
|
||||||
|
DefaultSaveAsPath: filepath.Join(dir, "vault.kdbx"),
|
||||||
|
RecentVaultsPath: filepath.Join(dir, "recent-vaults.json"),
|
||||||
|
RecentRemotesPath: filepath.Join(dir, "recent-remotes.json"),
|
||||||
|
UIPreferencesPath: filepath.Join(dir, "ui-prefs.json"),
|
||||||
|
PendingSharedLookupPath: filepath.Join(dir, "pending-shared-lookup.txt"),
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(paths.PendingSharedLookupPath, []byte("https://bellagio.example.invalid/login\n"), 0o600); err != nil {
|
||||||
|
t.Fatalf("WriteFile(PendingSharedLookupPath) error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
key := vault.MasterKey{Password: "correct horse battery staple"}
|
||||||
|
vaultPath := filepath.Join(dir, "bellagio.kdbx")
|
||||||
|
var encoded bytes.Buffer
|
||||||
|
if err := vault.SaveKDBXWithKey(&encoded, vault.Model{
|
||||||
|
Entries: []vault.Entry{
|
||||||
|
{ID: "bellagio-login", Title: "Bellagio", URL: "https://bellagio.example.invalid/login", Path: []string{"Crew", "Internet"}},
|
||||||
|
{ID: "vault-console", Title: "Vault Console", URL: "https://vault.example.invalid", Path: []string{"Crew", "Internet"}},
|
||||||
|
},
|
||||||
|
}, key); err != nil {
|
||||||
|
t.Fatalf("SaveKDBXWithKey() error = %v", err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(vaultPath, encoded.Bytes(), 0o600); err != nil {
|
||||||
|
t.Fatalf("WriteFile(vaultPath) error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
u := newUIWithState("phone", &session.Manager{}, paths)
|
||||||
|
if got := u.search.Text(); got != "" {
|
||||||
|
t.Fatalf("search before open with pending shared lookup = %q, want empty", got)
|
||||||
|
}
|
||||||
|
u.vaultPath.SetText(vaultPath)
|
||||||
|
u.masterPassword.SetText(key.Password)
|
||||||
|
if err := u.openVaultAction(); err != nil {
|
||||||
|
t.Fatalf("openVaultAction() with pending shared lookup error = %v", err)
|
||||||
|
}
|
||||||
|
if got := u.search.Text(); got != "bellagio.example.invalid" {
|
||||||
|
t.Fatalf("search after open with pending shared lookup = %q, want %q", got, "bellagio.example.invalid")
|
||||||
|
}
|
||||||
|
if got := u.filteredTitles(); !slices.Equal(got, []string{"Bellagio"}) {
|
||||||
|
t.Fatalf("filteredTitles() after open with pending shared lookup = %v, want [Bellagio]", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestUICurrentShareableVaultPathUsesSelectedVaultPath(t *testing.T) {
|
func TestUICurrentShareableVaultPathUsesSelectedVaultPath(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
|
|||||||
@@ -2,12 +2,15 @@ package browserbridge
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"crypto/rand"
|
||||||
"crypto/sha256"
|
"crypto/sha256"
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"encoding/binary"
|
"encoding/binary"
|
||||||
|
"encoding/hex"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"runtime"
|
"runtime"
|
||||||
@@ -28,19 +31,25 @@ const (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type Request struct {
|
type Request struct {
|
||||||
Action string `json:"action"`
|
Action string `json:"action"`
|
||||||
BearerToken string `json:"bearerToken,omitempty"`
|
BearerToken string `json:"bearerToken,omitempty"`
|
||||||
URL string `json:"url,omitempty"`
|
URL string `json:"url,omitempty"`
|
||||||
EntryID string `json:"entryId,omitempty"`
|
EntryID string `json:"entryId,omitempty"`
|
||||||
|
Query string `json:"query,omitempty"`
|
||||||
|
Title string `json:"title,omitempty"`
|
||||||
|
Username string `json:"username,omitempty"`
|
||||||
|
Password string `json:"password,omitempty"`
|
||||||
|
Path []string `json:"path,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type Response struct {
|
type Response struct {
|
||||||
Success bool `json:"success"`
|
Success bool `json:"success"`
|
||||||
Error string `json:"error,omitempty"`
|
Error string `json:"error,omitempty"`
|
||||||
Status *Status `json:"status,omitempty"`
|
Status *Status `json:"status,omitempty"`
|
||||||
Matches []Match `json:"matches,omitempty"`
|
Matches []Match `json:"matches,omitempty"`
|
||||||
Credential *Credential `json:"credential,omitempty"`
|
SearchResults []Match `json:"searchResults,omitempty"`
|
||||||
Version string `json:"version,omitempty"`
|
Credential *Credential `json:"credential,omitempty"`
|
||||||
|
Version string `json:"version,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type Status struct {
|
type Status struct {
|
||||||
@@ -77,11 +86,15 @@ type Connection struct {
|
|||||||
type Client interface {
|
type Client interface {
|
||||||
Status(context.Context) (*keepassgov1.GetSessionStatusResponse, error)
|
Status(context.Context) (*keepassgov1.GetSessionStatusResponse, error)
|
||||||
FindBrowserLogins(context.Context, string) ([]*keepassgov1.BrowserLoginMatch, error)
|
FindBrowserLogins(context.Context, string) ([]*keepassgov1.BrowserLoginMatch, error)
|
||||||
|
ListEntries(context.Context, []string, string) ([]*keepassgov1.Entry, error)
|
||||||
GetBrowserCredential(context.Context, string, string) (*keepassgov1.GetBrowserCredentialResponse, error)
|
GetBrowserCredential(context.Context, string, string) (*keepassgov1.GetBrowserCredentialResponse, error)
|
||||||
|
UpsertEntry(context.Context, *keepassgov1.Entry) (*keepassgov1.Entry, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
type Browser string
|
type Browser string
|
||||||
|
|
||||||
|
type actionHandler func(context.Context, Client, Request, string) Response
|
||||||
|
|
||||||
const (
|
const (
|
||||||
BrowserFirefox Browser = "firefox"
|
BrowserFirefox Browser = "firefox"
|
||||||
BrowserChrome Browser = "chrome"
|
BrowserChrome Browser = "chrome"
|
||||||
@@ -163,34 +176,70 @@ func HandleRequest(ctx context.Context, req Request, grpcAddr string, client Cli
|
|||||||
return Response{Success: false, Error: err.Error()}
|
return Response{Success: false, Error: err.Error()}
|
||||||
}
|
}
|
||||||
action := strings.TrimSpace(req.Action)
|
action := strings.TrimSpace(req.Action)
|
||||||
switch action {
|
handler, ok := actionHandlers[action]
|
||||||
case "status":
|
if !ok {
|
||||||
status, err := statusResponse(ctx, client, conn.GRPCAddress)
|
|
||||||
if err != nil {
|
|
||||||
return Response{Success: false, Error: err.Error(), Status: disconnectedStatus(conn.GRPCAddress)}
|
|
||||||
}
|
|
||||||
return Response{Success: true, Status: status, Version: responseVersion}
|
|
||||||
case "find-logins":
|
|
||||||
matches, err := findMatches(ctx, client, req.URL)
|
|
||||||
if err != nil {
|
|
||||||
if status := inferredActionStatus(conn.GRPCAddress, err); status != nil {
|
|
||||||
return Response{Success: true, Status: status, Matches: nil, Version: responseVersion}
|
|
||||||
}
|
|
||||||
return Response{Success: false, Error: err.Error(), Status: disconnectedStatus(conn.GRPCAddress)}
|
|
||||||
}
|
|
||||||
return Response{Success: true, Status: availableStatus(conn.GRPCAddress), Matches: matches, Version: responseVersion}
|
|
||||||
case "get-login":
|
|
||||||
credential, err := loadCredential(ctx, client, req.EntryID, req.URL)
|
|
||||||
if err != nil {
|
|
||||||
if status := inferredActionStatus(conn.GRPCAddress, err); status != nil {
|
|
||||||
return Response{Success: false, Error: err.Error(), Status: status}
|
|
||||||
}
|
|
||||||
return Response{Success: false, Error: err.Error(), Status: disconnectedStatus(conn.GRPCAddress)}
|
|
||||||
}
|
|
||||||
return Response{Success: true, Status: availableStatus(conn.GRPCAddress), Credential: credential, Version: responseVersion}
|
|
||||||
default:
|
|
||||||
return Response{Success: false, Error: fmt.Sprintf("unsupported action %q", action)}
|
return Response{Success: false, Error: fmt.Sprintf("unsupported action %q", action)}
|
||||||
}
|
}
|
||||||
|
return handler(ctx, client, req, conn.GRPCAddress)
|
||||||
|
}
|
||||||
|
|
||||||
|
var actionHandlers = map[string]actionHandler{
|
||||||
|
"status": handleStatusAction,
|
||||||
|
"find-logins": handleFindLoginsAction,
|
||||||
|
"search-logins": handleSearchLoginsAction,
|
||||||
|
"get-login": handleGetLoginAction,
|
||||||
|
"save-login": handleSaveLoginAction,
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleStatusAction(ctx context.Context, client Client, _ Request, grpcAddress string) Response {
|
||||||
|
status, err := statusResponse(ctx, client, grpcAddress)
|
||||||
|
if err != nil {
|
||||||
|
return Response{Success: false, Error: err.Error(), Status: disconnectedStatus(grpcAddress)}
|
||||||
|
}
|
||||||
|
return Response{Success: true, Status: status, Version: responseVersion}
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleFindLoginsAction(ctx context.Context, client Client, req Request, grpcAddress string) Response {
|
||||||
|
matches, err := findMatches(ctx, client, req.URL)
|
||||||
|
if err != nil {
|
||||||
|
if status := inferredActionStatus(grpcAddress, err); status != nil {
|
||||||
|
return Response{Success: true, Status: status, Matches: nil, Version: responseVersion}
|
||||||
|
}
|
||||||
|
return Response{Success: false, Error: err.Error(), Status: disconnectedStatus(grpcAddress)}
|
||||||
|
}
|
||||||
|
return Response{Success: true, Status: availableStatus(grpcAddress), Matches: matches, Version: responseVersion}
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleSearchLoginsAction(ctx context.Context, client Client, req Request, grpcAddress string) Response {
|
||||||
|
results, err := searchEntries(ctx, client, req.Query)
|
||||||
|
if err != nil {
|
||||||
|
if status := inferredActionStatus(grpcAddress, err); status != nil {
|
||||||
|
return Response{Success: true, Status: status, SearchResults: nil, Version: responseVersion}
|
||||||
|
}
|
||||||
|
return Response{Success: false, Error: err.Error(), Status: disconnectedStatus(grpcAddress)}
|
||||||
|
}
|
||||||
|
return Response{Success: true, Status: availableStatus(grpcAddress), SearchResults: results, Version: responseVersion}
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleGetLoginAction(ctx context.Context, client Client, req Request, grpcAddress string) Response {
|
||||||
|
credential, err := loadCredential(ctx, client, req.EntryID, req.URL)
|
||||||
|
if err != nil {
|
||||||
|
if status := inferredActionStatus(grpcAddress, err); status != nil {
|
||||||
|
return Response{Success: false, Error: err.Error(), Status: status}
|
||||||
|
}
|
||||||
|
return Response{Success: false, Error: err.Error(), Status: disconnectedStatus(grpcAddress)}
|
||||||
|
}
|
||||||
|
return Response{Success: true, Status: availableStatus(grpcAddress), Credential: credential, Version: responseVersion}
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleSaveLoginAction(ctx context.Context, client Client, req Request, grpcAddress string) Response {
|
||||||
|
if err := saveLogin(ctx, client, req); err != nil {
|
||||||
|
if status := inferredActionStatus(grpcAddress, err); status != nil {
|
||||||
|
return Response{Success: false, Error: err.Error(), Status: status}
|
||||||
|
}
|
||||||
|
return Response{Success: false, Error: err.Error(), Status: disconnectedStatus(grpcAddress)}
|
||||||
|
}
|
||||||
|
return Response{Success: true, Status: availableStatus(grpcAddress), Version: responseVersion}
|
||||||
}
|
}
|
||||||
|
|
||||||
func disconnectedStatus(addr string) *Status {
|
func disconnectedStatus(addr string) *Status {
|
||||||
@@ -264,6 +313,113 @@ func loadCredential(ctx context.Context, client Client, entryID, rawURL string)
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func saveLogin(ctx context.Context, client Client, req Request) error {
|
||||||
|
if strings.TrimSpace(req.Password) == "" {
|
||||||
|
return fmt.Errorf("browser save requires a password")
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(req.EntryID) != "" {
|
||||||
|
entries, err := client.ListEntries(ctx, nil, "")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
existing := findEntry(entries, req.EntryID)
|
||||||
|
if existing == nil {
|
||||||
|
return fmt.Errorf("entry %q was not found", strings.TrimSpace(req.EntryID))
|
||||||
|
}
|
||||||
|
entry := cloneEntry(existing)
|
||||||
|
entry.Title = coalesceTitle(req.Title, existing.Title, req.URL)
|
||||||
|
entry.Username = strings.TrimSpace(req.Username)
|
||||||
|
entry.Password = strings.TrimSpace(req.Password)
|
||||||
|
entry.Url = strings.TrimSpace(req.URL)
|
||||||
|
_, err = client.UpsertEntry(ctx, entry)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
path := append([]string(nil), req.Path...)
|
||||||
|
if len(path) == 0 {
|
||||||
|
return fmt.Errorf("browser save requires a target group path")
|
||||||
|
}
|
||||||
|
entry := &keepassgov1.Entry{
|
||||||
|
Id: newBrowserEntryID(),
|
||||||
|
Title: coalesceTitle(req.Title, "", req.URL),
|
||||||
|
Username: strings.TrimSpace(req.Username),
|
||||||
|
Password: strings.TrimSpace(req.Password),
|
||||||
|
Url: strings.TrimSpace(req.URL),
|
||||||
|
Path: path,
|
||||||
|
Fields: map[string]string{},
|
||||||
|
}
|
||||||
|
_, err := client.UpsertEntry(ctx, entry)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func findEntry(entries []*keepassgov1.Entry, id string) *keepassgov1.Entry {
|
||||||
|
for _, entry := range entries {
|
||||||
|
if entry.GetId() == strings.TrimSpace(id) {
|
||||||
|
return entry
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func cloneEntry(entry *keepassgov1.Entry) *keepassgov1.Entry {
|
||||||
|
if entry == nil {
|
||||||
|
return &keepassgov1.Entry{Fields: map[string]string{}}
|
||||||
|
}
|
||||||
|
fields := make(map[string]string, len(entry.GetFields()))
|
||||||
|
for key, value := range entry.GetFields() {
|
||||||
|
fields[key] = value
|
||||||
|
}
|
||||||
|
return &keepassgov1.Entry{
|
||||||
|
Id: entry.GetId(),
|
||||||
|
Title: entry.GetTitle(),
|
||||||
|
Username: entry.GetUsername(),
|
||||||
|
Password: entry.GetPassword(),
|
||||||
|
Url: entry.GetUrl(),
|
||||||
|
Notes: entry.GetNotes(),
|
||||||
|
Tags: append([]string(nil), entry.GetTags()...),
|
||||||
|
Path: append([]string(nil), entry.GetPath()...),
|
||||||
|
Fields: fields,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func coalesceTitle(title, fallback, rawURL string) string {
|
||||||
|
if trimmed := strings.TrimSpace(title); trimmed != "" {
|
||||||
|
return trimmed
|
||||||
|
}
|
||||||
|
if trimmed := strings.TrimSpace(fallback); trimmed != "" {
|
||||||
|
return trimmed
|
||||||
|
}
|
||||||
|
if parsed, err := url.Parse(strings.TrimSpace(rawURL)); err == nil && strings.TrimSpace(parsed.Hostname()) != "" {
|
||||||
|
return strings.ToLower(strings.TrimSpace(parsed.Hostname()))
|
||||||
|
}
|
||||||
|
return "Browser Login"
|
||||||
|
}
|
||||||
|
|
||||||
|
func newBrowserEntryID() string {
|
||||||
|
var buf [16]byte
|
||||||
|
if _, err := rand.Read(buf[:]); err != nil {
|
||||||
|
return fmt.Sprintf("browser-%d", os.Getpid())
|
||||||
|
}
|
||||||
|
return hex.EncodeToString(buf[:])
|
||||||
|
}
|
||||||
|
|
||||||
|
func searchEntries(ctx context.Context, client Client, query string) ([]Match, error) {
|
||||||
|
resp, err := client.ListEntries(ctx, nil, strings.TrimSpace(query))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out := make([]Match, 0, len(resp))
|
||||||
|
for _, entry := range resp {
|
||||||
|
out = append(out, Match{
|
||||||
|
ID: entry.GetId(),
|
||||||
|
Title: entry.GetTitle(),
|
||||||
|
Username: entry.GetUsername(),
|
||||||
|
URL: entry.GetUrl(),
|
||||||
|
Path: append([]string(nil), entry.GetPath()...),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
func Manifest(browser Browser, binaryPath, extensionID string) (NativeHostManifest, error) {
|
func Manifest(browser Browser, binaryPath, extensionID string) (NativeHostManifest, error) {
|
||||||
path := strings.TrimSpace(binaryPath)
|
path := strings.TrimSpace(binaryPath)
|
||||||
if path == "" {
|
if path == "" {
|
||||||
|
|||||||
@@ -149,6 +149,110 @@ func TestHandleRequestGetLogin(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestHandleRequestSearchLogins(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
client := &fakeClient{
|
||||||
|
entries: []*keepassgov1.Entry{
|
||||||
|
{Id: "rusty-gitlab", Title: "Rusty GitLab", Username: "rustyryan", Url: "gitlab.com", Path: []string{"Joe", "Internet"}},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
resp := HandleRequest(context.Background(), Request{
|
||||||
|
Action: "search-logins",
|
||||||
|
BearerToken: "secret",
|
||||||
|
Query: "GitLab",
|
||||||
|
}, "", client)
|
||||||
|
if !resp.Success {
|
||||||
|
t.Fatalf("HandleRequest(search-logins) success = false, error = %q", resp.Error)
|
||||||
|
}
|
||||||
|
if len(resp.SearchResults) != 1 || resp.SearchResults[0].ID != "rusty-gitlab" {
|
||||||
|
t.Fatalf("HandleRequest(search-logins).SearchResults = %#v, want rusty-gitlab", resp.SearchResults)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleRequestSaveLoginUpdatesExistingEntry(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
client := &fakeClient{
|
||||||
|
entries: []*keepassgov1.Entry{
|
||||||
|
{
|
||||||
|
Id: "vault-console",
|
||||||
|
Title: "Vault Console",
|
||||||
|
Username: "dannyocean",
|
||||||
|
Password: "old-password",
|
||||||
|
Url: "https://vault.example.invalid/login",
|
||||||
|
Path: []string{"Crew", "Internet"},
|
||||||
|
Fields: map[string]string{
|
||||||
|
"URL1": "vault.example.invalid",
|
||||||
|
"X-Role": "inside-man",
|
||||||
|
},
|
||||||
|
Tags: []string{"vault"},
|
||||||
|
Notes: "Original notes stay intact.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
resp := HandleRequest(context.Background(), Request{
|
||||||
|
Action: "save-login",
|
||||||
|
BearerToken: "secret",
|
||||||
|
EntryID: "vault-console",
|
||||||
|
Username: "dannyocean",
|
||||||
|
Password: "new-password",
|
||||||
|
URL: "https://vault.example.invalid/login",
|
||||||
|
}, "", client)
|
||||||
|
if !resp.Success {
|
||||||
|
t.Fatalf("HandleRequest(save-login update) success = false, error = %q", resp.Error)
|
||||||
|
}
|
||||||
|
if client.upserted == nil {
|
||||||
|
t.Fatal("HandleRequest(save-login update) did not upsert an entry")
|
||||||
|
}
|
||||||
|
if got := client.upserted.Id; got != "vault-console" {
|
||||||
|
t.Fatalf("upserted.Id = %q, want vault-console", got)
|
||||||
|
}
|
||||||
|
if got := client.upserted.Password; got != "new-password" {
|
||||||
|
t.Fatalf("upserted.Password = %q, want new-password", got)
|
||||||
|
}
|
||||||
|
if got := client.upserted.Fields["X-Role"]; got != "inside-man" {
|
||||||
|
t.Fatalf("upserted.Fields[X-Role] = %q, want inside-man", got)
|
||||||
|
}
|
||||||
|
if got := client.upserted.Notes; got != "Original notes stay intact." {
|
||||||
|
t.Fatalf("upserted.Notes = %q, want original notes", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleRequestSaveLoginCreatesNewEntryInChosenPath(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
client := &fakeClient{}
|
||||||
|
resp := HandleRequest(context.Background(), Request{
|
||||||
|
Action: "save-login",
|
||||||
|
BearerToken: "secret",
|
||||||
|
Title: "Bellagio Login",
|
||||||
|
Username: "linuscaldwell",
|
||||||
|
Password: "yellow-chip",
|
||||||
|
URL: "https://bellagio.example.invalid/login",
|
||||||
|
Path: []string{"Crew", "Internet"},
|
||||||
|
}, "", client)
|
||||||
|
if !resp.Success {
|
||||||
|
t.Fatalf("HandleRequest(save-login create) success = false, error = %q", resp.Error)
|
||||||
|
}
|
||||||
|
if client.upserted == nil {
|
||||||
|
t.Fatal("HandleRequest(save-login create) did not upsert an entry")
|
||||||
|
}
|
||||||
|
if got := client.upserted.Title; got != "Bellagio Login" {
|
||||||
|
t.Fatalf("upserted.Title = %q, want Bellagio Login", got)
|
||||||
|
}
|
||||||
|
if got := client.upserted.Username; got != "linuscaldwell" {
|
||||||
|
t.Fatalf("upserted.Username = %q, want linuscaldwell", got)
|
||||||
|
}
|
||||||
|
if got := client.upserted.Path; !slices.Equal(got, []string{"Crew", "Internet"}) {
|
||||||
|
t.Fatalf("upserted.Path = %v, want [Crew Internet]", got)
|
||||||
|
}
|
||||||
|
if got := client.upserted.Id; got == "" {
|
||||||
|
t.Fatal("upserted.Id = empty, want generated id")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestHandleRequestFindLoginsInfersLockedStatusFromRPC(t *testing.T) {
|
func TestHandleRequestFindLoginsInfersLockedStatusFromRPC(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -309,10 +413,14 @@ func TestEnsureNativeHostManifestsInstallsFirefoxAndDiscoveredChromium(t *testin
|
|||||||
type fakeClient struct {
|
type fakeClient struct {
|
||||||
status *keepassgov1.GetSessionStatusResponse
|
status *keepassgov1.GetSessionStatusResponse
|
||||||
matches []*keepassgov1.BrowserLoginMatch
|
matches []*keepassgov1.BrowserLoginMatch
|
||||||
|
entries []*keepassgov1.Entry
|
||||||
credential *keepassgov1.GetBrowserCredentialResponse
|
credential *keepassgov1.GetBrowserCredentialResponse
|
||||||
|
upserted *keepassgov1.Entry
|
||||||
err error
|
err error
|
||||||
matchesErr error
|
matchesErr error
|
||||||
|
entriesErr error
|
||||||
credentialErr error
|
credentialErr error
|
||||||
|
upsertErr error
|
||||||
statusCalls int
|
statusCalls int
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -382,6 +490,16 @@ func (f *fakeClient) FindBrowserLogins(context.Context, string) ([]*keepassgov1.
|
|||||||
return f.matches, nil
|
return f.matches, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (f *fakeClient) ListEntries(context.Context, []string, string) ([]*keepassgov1.Entry, error) {
|
||||||
|
if f.entriesErr != nil {
|
||||||
|
return nil, f.entriesErr
|
||||||
|
}
|
||||||
|
if f.err != nil {
|
||||||
|
return nil, f.err
|
||||||
|
}
|
||||||
|
return f.entries, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (f *fakeClient) GetBrowserCredential(context.Context, string, string) (*keepassgov1.GetBrowserCredentialResponse, error) {
|
func (f *fakeClient) GetBrowserCredential(context.Context, string, string) (*keepassgov1.GetBrowserCredentialResponse, error) {
|
||||||
if f.credentialErr != nil {
|
if f.credentialErr != nil {
|
||||||
return nil, f.credentialErr
|
return nil, f.credentialErr
|
||||||
@@ -394,3 +512,11 @@ func (f *fakeClient) GetBrowserCredential(context.Context, string, string) (*kee
|
|||||||
}
|
}
|
||||||
return f.credential, nil
|
return f.credential, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (f *fakeClient) UpsertEntry(_ context.Context, entry *keepassgov1.Entry) (*keepassgov1.Entry, error) {
|
||||||
|
if f.upsertErr != nil {
|
||||||
|
return nil, f.upsertErr
|
||||||
|
}
|
||||||
|
f.upserted = entry
|
||||||
|
return entry, nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -65,9 +65,28 @@ func (c *GRPCClient) FindBrowserLogins(ctx context.Context, pageURL string) ([]*
|
|||||||
return resp.GetMatches(), nil
|
return resp.GetMatches(), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (c *GRPCClient) ListEntries(ctx context.Context, path []string, query string) ([]*keepassgov1.Entry, error) {
|
||||||
|
resp, err := c.client.ListEntries(ctx, &keepassgov1.ListEntriesRequest{
|
||||||
|
Path: append([]string(nil), path...),
|
||||||
|
Query: strings.TrimSpace(query),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return resp.GetEntries(), nil
|
||||||
|
}
|
||||||
|
|
||||||
func (c *GRPCClient) GetBrowserCredential(ctx context.Context, entryID, pageURL string) (*keepassgov1.GetBrowserCredentialResponse, error) {
|
func (c *GRPCClient) GetBrowserCredential(ctx context.Context, entryID, pageURL string) (*keepassgov1.GetBrowserCredentialResponse, error) {
|
||||||
return c.client.GetBrowserCredential(ctx, &keepassgov1.GetBrowserCredentialRequest{
|
return c.client.GetBrowserCredential(ctx, &keepassgov1.GetBrowserCredentialRequest{
|
||||||
Id: strings.TrimSpace(entryID),
|
Id: strings.TrimSpace(entryID),
|
||||||
PageUrl: strings.TrimSpace(pageURL),
|
PageUrl: strings.TrimSpace(pageURL),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (c *GRPCClient) UpsertEntry(ctx context.Context, entry *keepassgov1.Entry) (*keepassgov1.Entry, error) {
|
||||||
|
resp, err := c.client.UpsertEntry(ctx, &keepassgov1.UpsertEntryRequest{Entry: entry})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return resp.GetEntry(), nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,10 +5,13 @@ import "git.julianfamily.org/keepassgo/internal/vault"
|
|||||||
// HiddenRoot returns the single synthetic top-level vault group that should be
|
// HiddenRoot returns the single synthetic top-level vault group that should be
|
||||||
// treated as an internal storage root rather than as a user-visible group.
|
// treated as an internal storage root rather than as a user-visible group.
|
||||||
func HiddenRoot(model vault.Model) string {
|
func HiddenRoot(model vault.Model) string {
|
||||||
if !hasGroup(model.Groups, []string{KeepassRoot}) {
|
if hasGroup(model.Groups, []string{KeepassRoot}) {
|
||||||
return ""
|
return KeepassRoot
|
||||||
}
|
}
|
||||||
return KeepassRoot
|
if usesTopLevelRoot(model, KeepassRoot) {
|
||||||
|
return KeepassRoot
|
||||||
|
}
|
||||||
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
func hasGroup(groups [][]string, path []string) bool {
|
func hasGroup(groups [][]string, path []string) bool {
|
||||||
|
|||||||
@@ -24,3 +24,20 @@ func TestHiddenRootIgnoresRecycleBin(t *testing.T) {
|
|||||||
t.Fatalf("HiddenRoot() = %q, want %q", got, "keepass")
|
t.Fatalf("HiddenRoot() = %q, want %q", got, "keepass")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestHiddenRootFallsBackToEntryPathsWhenGroupsAreSparse(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
model := vault.Model{
|
||||||
|
Entries: []vault.Entry{
|
||||||
|
{ID: "rusty", Title: "Rusty GitLab", Path: []string{"keepass", "Joe", "Internet"}},
|
||||||
|
},
|
||||||
|
Groups: [][]string{
|
||||||
|
{"Recycle Bin"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
if got := HiddenRoot(model); got != "keepass" {
|
||||||
|
t.Fatalf("HiddenRoot() = %q, want %q", got, "keepass")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -56,6 +56,16 @@ package() {
|
|||||||
"${pkgdir}/usr/share/keepassgo/browser-extension/background.js"
|
"${pkgdir}/usr/share/keepassgo/browser-extension/background.js"
|
||||||
install -Dm644 browser/extension/content.js \
|
install -Dm644 browser/extension/content.js \
|
||||||
"${pkgdir}/usr/share/keepassgo/browser-extension/content.js"
|
"${pkgdir}/usr/share/keepassgo/browser-extension/content.js"
|
||||||
|
install -Dm644 browser/extension/icons/icon-16.png \
|
||||||
|
"${pkgdir}/usr/share/keepassgo/browser-extension/icons/icon-16.png"
|
||||||
|
install -Dm644 browser/extension/icons/icon-32.png \
|
||||||
|
"${pkgdir}/usr/share/keepassgo/browser-extension/icons/icon-32.png"
|
||||||
|
install -Dm644 browser/extension/icons/icon-48.png \
|
||||||
|
"${pkgdir}/usr/share/keepassgo/browser-extension/icons/icon-48.png"
|
||||||
|
install -Dm644 browser/extension/icons/icon-96.png \
|
||||||
|
"${pkgdir}/usr/share/keepassgo/browser-extension/icons/icon-96.png"
|
||||||
|
install -Dm644 browser/extension/icons/icon-128.png \
|
||||||
|
"${pkgdir}/usr/share/keepassgo/browser-extension/icons/icon-128.png"
|
||||||
install -Dm644 browser/extension/manifest.chromium.json \
|
install -Dm644 browser/extension/manifest.chromium.json \
|
||||||
"${pkgdir}/usr/share/keepassgo/browser-extension/manifest.chromium.json"
|
"${pkgdir}/usr/share/keepassgo/browser-extension/manifest.chromium.json"
|
||||||
install -Dm644 browser/extension/manifest.firefox.json \
|
install -Dm644 browser/extension/manifest.firefox.json \
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import shutil
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
SOURCE_DIR = REPO_ROOT / "browser" / "extension"
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
parser = argparse.ArgumentParser(description="Prepare a Firefox extension directory for web-ext.")
|
||||||
|
parser.add_argument("output_dir", help="directory to write the prepared extension into")
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
output_dir = Path(args.output_dir).resolve()
|
||||||
|
if output_dir.exists():
|
||||||
|
shutil.rmtree(output_dir)
|
||||||
|
shutil.copytree(SOURCE_DIR, output_dir)
|
||||||
|
|
||||||
|
manifest = json.loads((output_dir / "manifest.firefox.json").read_text(encoding="utf-8"))
|
||||||
|
(output_dir / "manifest.json").write_text(json.dumps(manifest, indent=2) + "\n", encoding="utf-8")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
Reference in New Issue
Block a user