diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index a60ec99..dfc69db 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -39,6 +39,11 @@ jobs: distribution: temurin java-version: "25" + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: "22" + - name: Install native build dependencies shell: bash run: | @@ -47,6 +52,7 @@ jobs: apt-get update apt-get install -y --no-install-recommends \ zsh \ + python3 \ pkg-config \ libx11-dev \ libx11-xcb-dev \ @@ -58,6 +64,12 @@ jobs: libxcursor-dev \ libxfixes-dev + - name: Install web-ext + shell: bash + run: | + set -euo pipefail + npm install -g web-ext + - name: Lint shell: bash run: | @@ -74,6 +86,12 @@ jobs: trap 'rm -rf -- "$state_dir"' EXIT KEEPASSGO_STATE_DIR="$state_dir" go test -tags nox11,nowayland,novulkan ./... + - name: Firefox extension lint + shell: bash + run: | + set -euo pipefail + make browser-extension-firefox-lint + build: needs: lint-test runs-on: keepassgo-android @@ -92,6 +110,11 @@ jobs: distribution: temurin java-version: "25" + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: "22" + - name: Install native build dependencies shell: bash run: | @@ -100,6 +123,7 @@ jobs: apt-get update apt-get install -y --no-install-recommends \ zsh \ + python3 \ pkg-config \ libx11-dev \ libx11-xcb-dev \ @@ -111,6 +135,12 @@ jobs: libxcursor-dev \ libxfixes-dev + - name: Install web-ext + shell: bash + run: | + set -euo pipefail + npm install -g web-ext + - name: Prepare dist directory shell: bash run: | @@ -159,6 +189,13 @@ jobs: make apk-release RELEASE_SIGNKEY="$signkey_path" RELEASE_SIGNPASS_FILE="$signpass_path" cp build/keepassgo.apk "${DIST_DIR}/keepassgo.apk" + - name: Build Firefox extension + shell: bash + run: | + set -euo pipefail + make browser-extension-firefox-build + cp build/browser-extension/*.zip "${DIST_DIR}/" + - name: Upload CI artifacts uses: christopherhx/gitea-upload-artifact@v4 env: @@ -171,6 +208,7 @@ jobs: dist/keepassgo-windows-amd64.exe dist/keepassgo-windows-arm64.exe dist/keepassgo.apk + dist/*.zip retention-days: 30 - name: Publish release artifacts @@ -193,4 +231,5 @@ jobs: "${DIST_DIR}/keepassgo-linux-amd64" \ "${DIST_DIR}/keepassgo-windows-amd64.exe" \ "${DIST_DIR}/keepassgo-windows-arm64.exe" \ - "${DIST_DIR}/keepassgo.apk" + "${DIST_DIR}/keepassgo.apk" \ + "${DIST_DIR}"/*.zip diff --git a/AGENTS.md b/AGENTS.md index 17557af..b064fbf 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -136,6 +136,10 @@ These features are product requirements, not “nice to have” ideas. ## Delivery Discipline - Treat bug fixes as the highest-priority items in `TODO.md`. +- Do not start a new feature while unrelated tracked or untracked local changes + remain in the repo. +- If previous work leaves unrelated uncommitted changes behind, stop and ask + the user before continuing with the next feature. - Do not treat this product as complete until the stated requirements in this file are actually satisfied. - Do not stop at a “good checkpoint” or “meaningful tranche” when required product capabilities are still missing. - Continue iterating in test-first slices: diff --git a/Makefile b/Makefile index 2c29c83..3bfe151 100644 --- a/Makefile +++ b/Makefile @@ -20,6 +20,9 @@ ARCH_PKG_TMPL ?= $(ARCH_PKG_DIR)/PKGBUILD.tmpl ARCH_PKGBUILD ?= $(ARCH_PKG_DIR)/PKGBUILD ARCH_PKGVER ?= $(shell printf 'r%s.%s' "$$(git rev-list --count HEAD 2>/dev/null || echo 0)" "$$(git rev-parse --short HEAD 2>/dev/null || echo dev)") ARCH_REPO_DIR ?= $(CURDIR) +WEB_EXT ?= web-ext +FIREFOX_EXTENSION_DIR ?= build/firefox-extension +FIREFOX_EXTENSION_ARTIFACT_DIR ?= build/browser-extension GOGIO_SIGN_FLAGS := ifneq ($(strip $(SIGNKEY)),) @@ -44,7 +47,7 @@ CONTAINER_SIGNPASSFILE_MOUNT += -v "$(dir $(abspath $(SIGNPASS_FILE))):$(dir $(a CONTAINER_SIGN_ARGS += SIGNPASS_FILE="$(abspath $(SIGNPASS_FILE))" endif -.PHONY: apk apk-local apk-release apk-container apk-container-image archlinux-pkgbuild browser-bridge browser-extension-validate +.PHONY: apk apk-local apk-release apk-container apk-container-image archlinux-pkgbuild browser-bridge browser-extension-validate browser-extension-firefox-dir browser-extension-firefox-lint browser-extension-firefox-build browser-extension-firefox-run browser-extension-firefox-sign apk: @if [ -x "$(JAVA_HOME)/bin/java" ] && "$(JAVA_HOME)/bin/java" -version 2>&1 | grep -q 'version "25'; then \ $(MAKE) apk-local JAVA_HOME="$(JAVA_HOME)"; \ @@ -132,6 +135,23 @@ archlinux-pkgbuild: $(ARCH_PKG_TMPL) Makefile browser-bridge: go build ./cmd/keepassgo-browser-bridge +browser-extension-firefox-dir: + @mkdir -p "$(dir $(FIREFOX_EXTENSION_DIR))" + @python3 scripts/prepare_firefox_extension.py "$(FIREFOX_EXTENSION_DIR)" + +browser-extension-firefox-lint: browser-extension-firefox-dir + $(WEB_EXT) lint --source-dir "$(FIREFOX_EXTENSION_DIR)" + +browser-extension-firefox-build: browser-extension-firefox-dir + @mkdir -p "$(FIREFOX_EXTENSION_ARTIFACT_DIR)" + $(WEB_EXT) build --source-dir "$(FIREFOX_EXTENSION_DIR)" --artifacts-dir "$(FIREFOX_EXTENSION_ARTIFACT_DIR)" + +browser-extension-firefox-run: browser-extension-firefox-dir + $(WEB_EXT) run --source-dir "$(FIREFOX_EXTENSION_DIR)" + +browser-extension-firefox-sign: browser-extension-firefox-dir + $(WEB_EXT) sign --source-dir "$(FIREFOX_EXTENSION_DIR)" + browser-extension-validate: @command -v xvfb-run >/dev/null 2>&1 || { echo "xvfb-run is required"; exit 1; } @command -v firefox >/dev/null 2>&1 || { echo "firefox is required"; exit 1; } diff --git a/TODO.md b/TODO.md index bd73621..feef56d 100644 --- a/TODO.md +++ b/TODO.md @@ -130,6 +130,98 @@ These are important, but they should likely move behind a dedicated settings gea - Accessibility preferences: future display-density, contrast, reduced-motion, or keyboard-focus tuning should live under settings. +## Upstream Gap Review + +This section tracks explicit feature gaps against the source-level behavior of: + +- KeePass 2.57.1 +- KeePassHttp +- Keepass2Android + +These are not speculative enhancements. They are parity gaps relative to the +stated product requirement to cover the practical feature surface of those +upstream tools where it fits KeePassGO's security model. + +### Stage 1 + +- Android autofill parity/completeness: + close the remaining gaps in Android autofill behavior, including broader + page/app detection coverage, stronger approval and visibility UX, and more + reliable fill behavior across real-world apps and browsers. +- Android fallback fill workflows: + provide a non-autofill fallback comparable in usefulness to KP2A's keyboard + and share-driven workflows for apps and browsers that do not cooperate with + platform autofill. +- Browser extension save/update: + add the browser-side save/update-credential workflow after successful form + submission, not only lookup and fill. +- Search and matching controls: + browser/API result behavior does not yet expose KeePassHttp-style controls + such as best-match-only, scheme matching, and sort preferences as a finished + product surface. +- Unlock-request workflow: + KeePassHttp has an explicit locked-database browser flow; KeePassGO still + needs a polished browser-visible locked/unlock request experience. +- Android share/intents: + browser/app share-driven lookup and open flows comparable to KP2A are not + implemented as a full user workflow. + +### Stage 2 + +- OTP/TOTP: + implement real OTP/TOTP support, including storage conventions compatible + with common KeePass ecosystems and usable display/copy/fill workflows. +- TOTP product surface: + KP2A exposes TOTP directly in entry and list UX; KeePassGO does not. +- Browser-returned field breadth: + KeePassHttp can return string fields for browser consumers; KeePassGO does + not yet have a finished policy and browser UX for rich field return. +- Placeholder and field-reference parity: + KeePass-style placeholder expansion, field references, and related command + and URL override behavior are not implemented as a product surface. +- Offline/work-offline flow: + KP2A has explicit offline/cache-oriented remote-file workflows that are more + mature than KeePassGO's current user-facing remote behavior. + +### Stage 3 + +- Desktop automation: + implement desktop login automation comparable in practical capability to + KeePass auto-type, or replace it with a demonstrably superior workflow that + covers global invocation, selected-entry invocation, window targeting, and + field sequencing. +- Trigger system: + KeePass-style event/condition/action triggers are not implemented. +- Import/export breadth: + KDBX load/save exists, but KeePass-style breadth for CSV/XML/HTML and other + exchange formats is still missing. +- Multi-database lookup: + KeePassHttp can search across all opened databases when configured; KeePassGO + does not yet have an equivalent multi-vault lookup model. +- Remote backend breadth: + KP2A supports far more remote/file backends than KeePassGO currently does; + KeePassGO is still effectively WebDAV-first. +- Plugin/extensibility model: + KeePassGO has integrations, but not a first-class plugin model comparable to + KeePass. +- Plugin ecosystem replacements: + QR transfer, keyboard transport, and related mobile integration equivalents + do not exist in KeePassGO. +- Emergency and recovery utilities: + emergency-sheet/key-file-backup style flows are not implemented. + +### Immediate Product Questions + +- Desktop automation: + decide whether KeePassGO will implement true auto-type, or a different + security model that still satisfies the practical workflows KeePass users + expect. +- OTP model: + decide the canonical KeePassGO representation for TOTP/HOTP so desktop, + Android, gRPC, and browser workflows can all target the same semantics. +- Remote breadth: + decide which non-WebDAV backends are in product scope after WebDAV. + ### Exit Criteria - The main workflow screens prioritize opening, browsing, copying, editing, and synchronizing credentials. @@ -269,7 +361,7 @@ Exit criteria: - Tests cover clear/reset transitions. - `go test ./...` passes. -### Segment 10: Template CRUD UI +### Segment 10 (stage 3): Template CRUD UI Scope: - Create template. diff --git a/android/application_snippets.xml b/android/application_snippets.xml index d2f6caf..5e1a60d 100644 --- a/android/application_snippets.xml +++ b/android/application_snippets.xml @@ -35,6 +35,11 @@ android:name="org.julianfamily.keepassgo.SharedVaultImportActivity" android:exported="true" android:theme="@android:style/Theme.Translucent.NoTitleBar"> + + + + + @@ -42,6 +47,13 @@ + + + + + + + diff --git a/androidsrc/org/julianfamily/keepassgo/AutofillCacheStore.java b/androidsrc/org/julianfamily/keepassgo/AutofillCacheStore.java index 5686193..187dc49 100644 --- a/androidsrc/org/julianfamily/keepassgo/AutofillCacheStore.java +++ b/androidsrc/org/julianfamily/keepassgo/AutofillCacheStore.java @@ -25,26 +25,7 @@ final class AutofillCacheStore { if (entries.isEmpty()) { return null; } - NormalizedTarget target = normalizeURL(webDomain); - if (target.host.isEmpty()) { - return null; - } - List exactHost = new ArrayList<>(); - List 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); + return fromMatcherEntry(AutofillTargetMatcher.findBestMatch(toMatcherEntries(entries), webDomain)); } static Entry findByID(Context context, String entryID) { @@ -64,7 +45,7 @@ final class AutofillCacheStore { if (entries.isEmpty()) { return entries; } - Entry direct = findBestMatch(context, rawTarget); + Entry direct = fromMatcherEntry(AutofillTargetMatcher.findBestMatch(toMatcherEntries(entries), rawTarget)); if (direct != null) { List resolved = new ArrayList<>(); resolved.add(direct); @@ -77,6 +58,30 @@ final class AutofillCacheStore { return entries; } + private static List toMatcherEntries(List entries) { + List 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 readEntries(Context context) { File cacheFile = findCacheFile(context); if (cacheFile == null) { @@ -199,143 +204,7 @@ final class AutofillCacheStore { } private static String normalizeHost(String raw) { - return normalizeURL(raw).host; - } - - private static NormalizedTarget normalizeURL(String raw) { - if (raw == null) { - return new NormalizedTarget("", "", ""); - } - String value = raw.trim().toLowerCase(Locale.US); - if (value.startsWith("http://")) { - value = value.substring("http://".length()); - } else if (value.startsWith("https://")) { - value = value.substring("https://".length()); - } - int slash = value.indexOf('/'); - if (slash >= 0) { - value = value.substring(0, slash); - } - int colon = value.indexOf(':'); - if (colon >= 0) { - value = value.substring(0, colon); - } - String host = value; - String path = "/"; - int schemeSep = raw.indexOf("://"); - String original = raw.trim(); - if (schemeSep < 0) { - original = "https://" + original; - } - try { - java.net.URI uri = java.net.URI.create(original); - if (uri.getHost() != null) { - host = uri.getHost().toLowerCase(Locale.US); - } - path = cleanPath(uri.getPath()); - } catch (IllegalArgumentException ignored) { - path = "/"; - } - return new NormalizedTarget(host, path, host + path); - } - - private static String cleanPath(String raw) { - if (raw == null || raw.trim().isEmpty() || "/".equals(raw.trim())) { - return "/"; - } - String value = raw.trim(); - while (value.endsWith("/") && value.length() > 1) { - value = value.substring(0, value.length() - 1); - } - if (!value.startsWith("/")) { - value = "/" + value; - } - return value; - } - - private static Entry chooseEntry(NormalizedTarget target, List entries) { - if (entries.isEmpty()) { - return null; - } - if (entries.size() == 1) { - return entries.get(0); - } - - List exact = new ArrayList<>(); - List 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 entryTargets(Entry entry) { - List rawTargets = entry.targets; - if (rawTargets.isEmpty()) { - rawTargets = new ArrayList<>(); - rawTargets.add(entry.url); - } - List targets = new ArrayList<>(); - for (String rawTarget : rawTargets) { - NormalizedTarget target = normalizeURL(rawTarget); - if (!target.host.isEmpty()) { - targets.add(target); - } - } - return targets; - } - - private static MatchQuality bestTargetMatch(Entry entry, NormalizedTarget target) { - int bestPrefixLen = -1; - for (NormalizedTarget entryTarget : entryTargets(entry)) { - if (entryTarget.url.equals(target.url)) { - return new MatchQuality(true, 0); - } - if (!"/".equals(entryTarget.path) && target.path.startsWith(entryTarget.path)) { - bestPrefixLen = Math.max(bestPrefixLen, entryTarget.path.length()); - } - } - return new MatchQuality(false, bestPrefixLen); + return AutofillTargetMatcher.normalize(raw).host; } static final class Entry { @@ -359,26 +228,4 @@ final class AutofillCacheStore { 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; - } - } } diff --git a/androidsrc/org/julianfamily/keepassgo/AutofillFallbackTarget.java b/androidsrc/org/julianfamily/keepassgo/AutofillFallbackTarget.java new file mode 100644 index 0000000..821e175 --- /dev/null +++ b/androidsrc/org/julianfamily/keepassgo/AutofillFallbackTarget.java @@ -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(); + } +} diff --git a/androidsrc/org/julianfamily/keepassgo/AutofillTargetMatcher.java b/androidsrc/org/julianfamily/keepassgo/AutofillTargetMatcher.java new file mode 100644 index 0000000..706da68 --- /dev/null +++ b/androidsrc/org/julianfamily/keepassgo/AutofillTargetMatcher.java @@ -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 entries, String rawTarget) { + if (entries == null || entries.isEmpty()) { + return null; + } + NormalizedTarget target = normalize(rawTarget); + if (target.host.isEmpty()) { + return null; + } + List exactHost = new ArrayList<>(); + List 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 chooserCandidates(List entries, String rawTarget) { + if (entries == null || entries.isEmpty()) { + return new ArrayList<>(); + } + Entry direct = findBestMatch(entries, rawTarget); + if (direct != null) { + List resolved = new ArrayList<>(); + resolved.add(direct); + return resolved; + } + NormalizedTarget target = normalize(rawTarget); + List exactHost = new ArrayList<>(); + List 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 entries) { + if (entries.isEmpty()) { + return null; + } + if (entries.size() == 1) { + return entries.get(0); + } + List exact = new ArrayList<>(); + List 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 entryTargets(Entry entry) { + List rawTargets = entry.targets; + if (rawTargets.isEmpty()) { + rawTargets = new ArrayList<>(); + rawTargets.add(entry.url); + } + List 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 sortEntries(List entries) { + List 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 targets; + final List path; + + Entry(String id, String title, String username, String password, String host, String url, List targets, List 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; + } + } +} diff --git a/androidsrc/org/julianfamily/keepassgo/KeePassGOAccessibilityService.java b/androidsrc/org/julianfamily/keepassgo/KeePassGOAccessibilityService.java index b347812..40d5f67 100644 --- a/androidsrc/org/julianfamily/keepassgo/KeePassGOAccessibilityService.java +++ b/androidsrc/org/julianfamily/keepassgo/KeePassGOAccessibilityService.java @@ -12,7 +12,6 @@ import java.util.List; public final class KeePassGOAccessibilityService extends AccessibilityService { private static final String TAG = "KeePassGOA11y"; - private String lastFilledSignature = ""; @Override @@ -22,22 +21,17 @@ public final class KeePassGOAccessibilityService extends AccessibilityService { if (root == null) { return; } - CharSequence packageName = root.getPackageName(); - if (packageName == null || !"com.android.chrome".contentEquals(packageName)) { - return; - } - - ChromeForm form = inspectChrome(root); + ChromeForm form = inspectWindow(root); if (form == null || form.passwordField == null) { return; } - AutofillCacheStore.Entry entry = AutofillCacheStore.findBestMatch(this, form.url); + AutofillCacheStore.Entry entry = AutofillCacheStore.findBestMatch(this, form.matchTarget); if (entry == null) { - Log.i(TAG, "no accessibility-fill match for " + form.url); + Log.i(TAG, "no accessibility-fill match for " + form.matchTarget); return; } - String signature = form.url + "|" + entry.username + "|" + nodeKey(form.passwordField); + String signature = form.matchTarget + "|" + entry.username + "|" + nodeKey(form.passwordField); if (signature.equals(lastFilledSignature)) { return; } @@ -49,7 +43,7 @@ public final class KeePassGOAccessibilityService extends AccessibilityService { filled |= setNodeText(form.passwordField, entry.password); if (filled) { lastFilledSignature = signature; - Log.i(TAG, "filled login form for " + form.url + " with " + entry.username); + Log.i(TAG, "filled login form for " + form.matchTarget + " with " + entry.username); } } catch (Exception err) { Log.e(TAG, "accessibility fill failed", err); @@ -61,11 +55,11 @@ public final class KeePassGOAccessibilityService extends AccessibilityService { Log.i(TAG, "accessibility service interrupted"); } - private static ChromeForm inspectChrome(AccessibilityNodeInfo root) { + private static ChromeForm inspectWindow(AccessibilityNodeInfo root) { List editables = new ArrayList<>(); ChromeForm form = new ChromeForm(); walk(root, editables, form); - if (form.url.isEmpty()) { + if (form.matchTarget.isEmpty()) { return null; } if (form.passwordField == null) { @@ -111,7 +105,13 @@ public final class KeePassGOAccessibilityService extends AccessibilityService { if (viewID != null && viewID.toString().endsWith(":id/url_bar")) { CharSequence text = node.getText(); if (text != null) { - form.url = text.toString().trim(); + form.webDomain = text.toString().trim(); + } + } + if (form.packageName.isEmpty()) { + CharSequence packageName = node.getPackageName(); + if (packageName != null) { + form.packageName = packageName.toString().trim(); } } if (node.isEditable()) { @@ -128,6 +128,7 @@ public final class KeePassGOAccessibilityService extends AccessibilityService { walk(child, editables, form); } } + form.matchTarget = AutofillFallbackTarget.resolve(form.packageName, form.webDomain); } private static boolean isPasswordNode(AccessibilityNodeInfo node) { @@ -188,7 +189,9 @@ public final class KeePassGOAccessibilityService extends AccessibilityService { } private static final class ChromeForm { - String url = ""; + String packageName = ""; + String webDomain = ""; + String matchTarget = ""; AccessibilityNodeInfo usernameField; AccessibilityNodeInfo passwordField; } diff --git a/androidsrc/org/julianfamily/keepassgo/SharedVaultImportActivity.java b/androidsrc/org/julianfamily/keepassgo/SharedVaultImportActivity.java index 778b50c..c4eb884 100644 --- a/androidsrc/org/julianfamily/keepassgo/SharedVaultImportActivity.java +++ b/androidsrc/org/julianfamily/keepassgo/SharedVaultImportActivity.java @@ -20,6 +20,9 @@ import java.util.ArrayList; public final class SharedVaultImportActivity extends Activity { private static final String TAG = "KeePassGOImport"; private static final String DEFAULT_NAME = "shared-vault.kdbx"; + private static final String PENDING_SHARED_VAULT = "pending-shared-vault.kdbx"; + private static final String PENDING_SHARED_VAULT_NAME = "pending-shared-vault-name.txt"; + private static final String PENDING_SHARED_LOOKUP = "pending-shared-lookup.txt"; @Override protected void onCreate(Bundle state) { @@ -40,6 +43,16 @@ public final class SharedVaultImportActivity extends Activity { private void handleIntent(Intent intent) { logIntent(intent); + String sharedLookup = resolveSharedLookup(intent); + if (!sharedLookup.isEmpty()) { + try { + persistPendingLookup(sharedLookup); + Log.i(TAG, "queued shared lookup target"); + } catch (IOException | RuntimeException err) { + Log.e(TAG, "failed to queue shared lookup target", err); + } + return; + } Uri uri = resolveSharedUri(intent); if (uri == null) { Log.i(TAG, "no shared vault URI on intent"); @@ -86,12 +99,35 @@ public final class SharedVaultImportActivity extends Activity { return null; } + private String resolveSharedLookup(Intent intent) { + if (intent == null) { + return ""; + } + String action = intent.getAction(); + if (Intent.ACTION_SEND.equals(action) && "text/plain".equalsIgnoreCase(intent.getType())) { + CharSequence extraText = intent.getCharSequenceExtra(Intent.EXTRA_TEXT); + if (extraText != null) { + return extraText.toString().trim(); + } + } + if (Intent.ACTION_VIEW.equals(action)) { + Uri data = intent.getData(); + if (data != null) { + String scheme = data.getScheme(); + if ("http".equalsIgnoreCase(scheme) || "https".equalsIgnoreCase(scheme)) { + return data.toString(); + } + } + } + return ""; + } + private void persistPendingImport(Uri uri) throws IOException { File dir = new File(getFilesDir(), "keepassgo"); if (!dir.exists() && !dir.mkdirs()) { throw new IOException("failed to create " + dir.getAbsolutePath()); } - File pendingFile = new File(dir, "pending-shared-vault.kdbx"); + File pendingFile = new File(dir, PENDING_SHARED_VAULT); try (InputStream in = openSharedInputStream(uri)) { if (in == null) { throw new IOException("failed to open shared vault stream"); @@ -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)) { out.write(resolveDisplayName(uri).getBytes(StandardCharsets.UTF_8)); } } + private void persistPendingLookup(String lookup) throws IOException { + File dir = new File(getFilesDir(), "keepassgo"); + if (!dir.exists() && !dir.mkdirs()) { + throw new IOException("failed to create " + dir.getAbsolutePath()); + } + File pendingFile = new File(dir, PENDING_SHARED_LOOKUP); + try (FileOutputStream out = new FileOutputStream(pendingFile, false)) { + out.write(lookup.getBytes(StandardCharsets.UTF_8)); + } + } + private InputStream openSharedInputStream(Uri uri) throws IOException { if ("file".equalsIgnoreCase(uri.getScheme())) { String path = uri.getPath(); diff --git a/androidtestsrc/org/julianfamily/keepassgo/AutofillCacheStoreBehaviorTest.java b/androidtestsrc/org/julianfamily/keepassgo/AutofillCacheStoreBehaviorTest.java new file mode 100644 index 0000000..6c29946 --- /dev/null +++ b/androidtestsrc/org/julianfamily/keepassgo/AutofillCacheStoreBehaviorTest.java @@ -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 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 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 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 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 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 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 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 entries) { + List ids = new ArrayList<>(); + for (AutofillTargetMatcher.Entry entry : entries) { + ids.add(entry.id); + } + return ids.toString(); + } + + private static boolean containsIDs(List entries, String... wantIDs) { + List ids = new ArrayList<>(); + for (AutofillTargetMatcher.Entry entry : entries) { + ids.add(entry.id); + } + return ids.containsAll(Arrays.asList(wantIDs)) && ids.size() == wantIDs.length; + } +} diff --git a/androidtestsrc/org/julianfamily/keepassgo/AutofillFallbackTargetBehaviorTest.java b/androidtestsrc/org/julianfamily/keepassgo/AutofillFallbackTargetBehaviorTest.java new file mode 100644 index 0000000..1cdc4e2 --- /dev/null +++ b/androidtestsrc/org/julianfamily/keepassgo/AutofillFallbackTargetBehaviorTest.java @@ -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"); + } + } +} diff --git a/browser/extension/background.js b/browser/extension/background.js index 3cfb627..5ccb0f7 100644 --- a/browser/extension/background.js +++ b/browser/extension/background.js @@ -3,7 +3,10 @@ const nativeHost = "com.keepassgo.browser"; const isNodeTestEnv = typeof module !== "undefined" && module.exports; const usePromiseAPI = typeof globalThis.browser !== "undefined"; const defaultSettings = { - bearerToken: "" + bearerToken: "", + bestMatchOnly: false, + requireSchemeMatch: false, + sortResults: "quality" }; const pageStatePrefix = "keepassgo-page-state:"; const matchCacheTTL = 30 * 1000; @@ -173,12 +176,20 @@ function connectNative(message) { } async function loadSettings() { - const stored = await storageGet(["bearerToken"]); + const stored = await storageGet(["bearerToken", "bestMatchOnly", "requireSchemeMatch", "sortResults"]); + const sortResults = normalizeSortResults(stored.sortResults); return { - bearerToken: (stored.bearerToken || defaultSettings.bearerToken).trim() + bearerToken: (stored.bearerToken || defaultSettings.bearerToken).trim(), + bestMatchOnly: Boolean(stored.bestMatchOnly ?? defaultSettings.bestMatchOnly), + requireSchemeMatch: Boolean(stored.requireSchemeMatch ?? defaultSettings.requireSchemeMatch), + sortResults }; } +function normalizeSortResults(value) { + return ["quality", "title", "path"].includes(value) ? value : defaultSettings.sortResults; +} + function supportsPageStateURL(rawURL) { return typeof rawURL === "string" && /^https?:\/\//i.test(rawURL); } @@ -191,6 +202,185 @@ function cloneTarget(target) { 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 matchScheme(rawURL) { + if (typeof rawURL !== "string") { + return ""; + } + const trimmed = rawURL.trim(); + if (!trimmed) { + return ""; + } + try { + return new URL(trimmed).protocol.toLowerCase(); + } catch (_error) { + return ""; + } +} + +function qualityRank(quality) { + switch (String(quality || "").trim().toLowerCase()) { + case "exact": + return 0; + case "scheme": + return 1; + case "host": + return 2; + default: + return 3; + } +} + +function compareMatchText(left, right) { + return String(left || "").localeCompare(String(right || ""), undefined, { sensitivity: "base" }); +} + +function sortMatches(matches, sortResults) { + const sorted = [...matches]; + switch (normalizeSortResults(sortResults)) { + case "title": + sorted.sort((left, right) => + compareMatchText(left?.title, right?.title) || + compareMatchText(left?.username, right?.username) || + compareMatchText(left?.path?.join("/"), right?.path?.join("/")) + ); + return sorted; + case "path": + sorted.sort((left, right) => + compareMatchText(left?.path?.join("/"), right?.path?.join("/")) || + compareMatchText(left?.title, right?.title) || + compareMatchText(left?.username, right?.username) + ); + return sorted; + default: + sorted.sort((left, right) => + qualityRank(left?.quality) - qualityRank(right?.quality) || + compareMatchText(left?.title, right?.title) || + compareMatchText(left?.username, right?.username) + ); + return sorted; + } +} + +function filterSchemeMatches(matches, pageURL) { + const pageScheme = matchScheme(pageURL); + if (!pageScheme) { + return [...matches]; + } + return matches.filter((match) => { + const entryScheme = matchScheme(match?.url); + return !entryScheme || entryScheme === pageScheme; + }); +} + +function filterBestQuality(matches) { + if (!Array.isArray(matches) || matches.length === 0) { + return []; + } + const bestRank = Math.min(...matches.map((match) => qualityRank(match?.quality))); + return matches.filter((match) => qualityRank(match?.quality) === bestRank); +} + +function applyMatchControls(matches, settings, pageURL) { + let filtered = Array.isArray(matches) ? [...matches] : []; + if (settings?.requireSchemeMatch) { + filtered = filterSchemeMatches(filtered, pageURL); + } + if (settings?.bestMatchOnly) { + filtered = filterBestQuality(filtered); + } + return sortMatches(filtered, settings?.sortResults); +} + +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) { return { tabId: Number.isInteger(state?.tabId) ? state.tabId : null, @@ -207,6 +397,7 @@ function normalizePageState(state) { pendingEntryId: typeof state?.pendingEntryId === "string" ? state.pendingEntryId : "", pendingTarget: cloneTarget(state?.pendingTarget), pendingMessage: typeof state?.pendingMessage === "string" ? state.pendingMessage : "", + pendingSave: cloneSavePlan(state?.pendingSave), lastFilledEntryId: typeof state?.lastFilledEntryId === "string" ? state.lastFilledEntryId : "", updatedAt: Number.isFinite(state?.updatedAt) ? state.updatedAt : 0 }; @@ -228,6 +419,7 @@ function defaultPageState(tabId, pageUrl) { pendingEntryId: "", pendingTarget: null, pendingMessage: "", + pendingSave: null, lastFilledEntryId: "", updatedAt: 0 }); @@ -292,6 +484,16 @@ function approvalHintForState(state) { 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) { if (!Number.isInteger(tabId)) { return; @@ -337,6 +539,12 @@ function actionPresentationForState(state) { badgeText = "!"; color = "#9f5f0e"; 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) { title = "Configure KeePassGO Browser in extension settings"; } else if (!state.success) { @@ -492,7 +700,7 @@ async function refreshPageState(tabId, pageUrl, options = {}) { state.matches = []; state.updatedAt = Date.now(); const saved = await setPageState(tabId, state); - if (saved.pendingFill) { + if (shouldContinueWatchingState(saved)) { schedulePendingPoll(tabId, resolvedURL); } else { clearPendingPoll(tabId); @@ -502,7 +710,7 @@ async function refreshPageState(tabId, pageUrl, options = {}) { if (shouldReuseMatches(state, force)) { const saved = await setPageState(tabId, state); - if (saved.pendingFill) { + if (shouldContinueWatchingState(saved)) { schedulePendingPoll(tabId, resolvedURL); } else { clearPendingPoll(tabId); @@ -515,6 +723,11 @@ async function refreshPageState(tabId, pageUrl, options = {}) { bearerToken: settings.bearerToken, url: resolvedURL }); + const filteredMatches = applyMatchControls( + Array.isArray(matches?.matches) ? matches.matches : [], + settings, + resolvedURL + ); state = { ...state, @@ -524,12 +737,12 @@ async function refreshPageState(tabId, pageUrl, options = {}) { pendingMessage: tokenPendingApprovalCount(matches?.status ?? state.status) > 0 ? approvalHintForState(state) || "Approve or deny the browser fill request in KeePassGO." : "", - matches: Array.isArray(matches?.matches) ? matches.matches : [], + matches: filteredMatches, error: matches?.error ?? "", updatedAt: Date.now() }; const saved = await setPageState(tabId, state); - if (saved.pendingFill) { + if (shouldContinueWatchingState(saved)) { schedulePendingPoll(tabId, resolvedURL); } else { clearPendingPoll(tabId); @@ -653,11 +866,65 @@ async function refreshActivePage(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 = { + applyMatchControls, normalizePageState, actionPresentationForState, shouldReuseMatches, + shouldContinueWatchingState, tokenPendingApprovalCount, + savePlanForObservedLogin, defaultSettings }; @@ -692,11 +959,62 @@ if (isNodeTestEnv) { return; case "keepassgo-save-settings": await storageSet({ - bearerToken: String(message.settings?.bearerToken || "").trim() + bearerToken: String(message.settings?.bearerToken || "").trim(), + bestMatchOnly: Boolean(message.settings?.bestMatchOnly), + requireSchemeMatch: Boolean(message.settings?.requireSchemeMatch), + sortResults: normalizeSortResults(message.settings?.sortResults) }); await refreshActivePage({ force: true }).catch(() => null); sendResponse({ success: true }); return; + case "keepassgo-search-logins": { + const settings = await loadSettings(); + const page = await activePageContext(); + const response = await connectNative({ + action: "search-logins", + bearerToken: settings.bearerToken, + query: String(message?.query || "").trim() + }); + const searchResults = applyMatchControls( + Array.isArray(response?.searchResults) ? response.searchResults : [], + settings, + page.url + ); + sendResponse({ + success: Boolean(response?.success), + error: response?.error || "", + results: searchResults, + 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": if (Number.isInteger(sender?.tab?.id)) { sendResponse(await refreshPageState(sender.tab.id, sender.tab.url, { diff --git a/browser/extension/background.test.cjs b/browser/extension/background.test.cjs index c6d7e4e..ee48b2b 100644 --- a/browser/extension/background.test.cjs +++ b/browser/extension/background.test.cjs @@ -49,6 +49,128 @@ test("tokenPendingApprovalCount reads token-scoped approval state", () => { assert.equal(background.tokenPendingApprovalCount({}), 0); }); +test("shouldContinueWatchingState keeps polling locked login pages", () => { + assert.equal(background.shouldContinueWatchingState({ + pageHasLoginForm: true, + pendingFill: false, + status: { locked: true } + }), true); + assert.equal(background.shouldContinueWatchingState({ + pageHasLoginForm: true, + pendingFill: true, + status: { locked: false } + }), true); + assert.equal(background.shouldContinueWatchingState({ + pageHasLoginForm: true, + pendingFill: false, + status: { locked: false } + }), false); +}); + test("default settings include a blank bearer token that can be overridden by harness patching", () => { assert.equal(background.defaultSettings.bearerToken, ""); + assert.equal(background.defaultSettings.bestMatchOnly, false); + assert.equal(background.defaultSettings.requireSchemeMatch, false); + assert.equal(background.defaultSettings.sortResults, "quality"); +}); + +test("savePlanForObservedLogin prefers updating an exact username match", () => { + const plan = background.savePlanForObservedLogin({ + username: "dannyocean", + password: "bellagio-safe", + url: "https://vault.example.invalid/login" + }, [ + { + id: "vault-console", + title: "Vault Console", + username: "dannyocean", + url: "vault.example.invalid", + path: ["Crew", "Internet"] + }, + { + id: "bellagio-backup", + title: "Bellagio Backup", + username: "rustyryan", + url: "vault.example.invalid", + path: ["Crew", "Internet"] + } + ]); + + assert.deepEqual(plan, { + mode: "update", + entryId: "vault-console", + title: "Vault Console", + path: ["Crew", "Internet"], + username: "dannyocean", + password: "bellagio-safe", + url: "https://vault.example.invalid/login" + }); +}); + +test("savePlanForObservedLogin falls back to saving into the current page group", () => { + const plan = background.savePlanForObservedLogin({ + username: "linuscaldwell", + password: "yellow-chip", + url: "https://vault.example.invalid/login" + }, [ + { + id: "vault-console", + title: "Vault Console", + username: "dannyocean", + url: "vault.example.invalid", + path: ["Crew", "Internet"] + } + ]); + + assert.deepEqual(plan, { + mode: "save", + entryId: "", + title: "vault.example.invalid", + path: ["Crew", "Internet"], + username: "linuscaldwell", + password: "yellow-chip", + url: "https://vault.example.invalid/login" + }); +}); + +test("applyMatchControls keeps only the strongest quality band when best-match-only is enabled", () => { + const filtered = background.applyMatchControls([ + { id: "livingston", title: "Livingston Dell", quality: "exact" }, + { id: "rusty", title: "Rusty Ryan", quality: "host" }, + { id: "linus", title: "Linus Caldwell", quality: "scheme" } + ], { + bestMatchOnly: true, + requireSchemeMatch: false, + sortResults: "quality" + }, "https://vault.example.invalid/login"); + + assert.deepEqual(filtered.map((match) => match.id), ["livingston"]); +}); + +test("applyMatchControls removes explicit scheme mismatches but keeps scheme-less matches", () => { + const filtered = background.applyMatchControls([ + { id: "yen", title: "The Amazing Yen", url: "https://vault.example.invalid/login", quality: "exact" }, + { id: "saul", title: "Saul Bloom", url: "http://vault.example.invalid/login", quality: "exact" }, + { id: "basher", title: "Basher Tarr", url: "vault.example.invalid", quality: "host" } + ], { + bestMatchOnly: false, + requireSchemeMatch: true, + sortResults: "quality" + }, "https://vault.example.invalid/login"); + + assert.deepEqual(filtered.map((match) => match.id), ["yen", "basher"]); +}); + +test("applyMatchControls sorts by path when requested", () => { + const filtered = background.applyMatchControls([ + { id: "linus", title: "Linus Caldwell", path: ["Crew", "Inside"] }, + { id: "rusty", title: "Rusty Ryan", path: ["Crew", "Casino"] }, + { id: "danny", title: "Danny Ocean", path: ["Crew"] } + ], { + bestMatchOnly: false, + requireSchemeMatch: false, + sortResults: "path" + }, "https://vault.example.invalid/login"); + + assert.deepEqual(filtered.map((match) => match.id), ["danny", "rusty", "linus"]); }); diff --git a/browser/extension/content.js b/browser/extension/content.js index cc09521..cf3b936 100644 --- a/browser/extension/content.js +++ b/browser/extension/content.js @@ -396,6 +396,22 @@ function fillCredential(credential, targetDescriptor) { return { ok: true }; } +function submittedCredential(candidate, rawURL) { + if (!candidate?.passwordInput) { + return null; + } + const password = String(candidate.passwordInput.value || "").trim(); + if (!password) { + return null; + } + return { + title: domainLabel(rawURL), + username: String(candidate.usernameInput?.value || "").trim(), + password, + url: String(rawURL || "").trim() + }; +} + function domainLabel(rawURL) { try { return new URL(rawURL).host || ""; @@ -429,6 +445,7 @@ function shouldShowInlineOverlay(state, hasTarget, suppressed, idleHidden) { state?.pageHasLoginForm && ( state?.pendingFill || + (state?.configured && state?.success && state?.status?.locked) || (state?.configured && state?.success && !state?.status?.locked && Array.isArray(state?.matches) && state.matches.length > 0) ) ); @@ -446,7 +463,8 @@ const contentTestExports = { fieldHintText, scopeHintText, hasAuthFlowSignals, - authFlowCandidate + authFlowCandidate, + submittedCredential }; if (isNodeTestEnv) { @@ -727,10 +745,13 @@ if (isNodeTestEnv) { ensureRootMounted(); 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) { meta.textContent = "Approval needed in KeePassGO"; panelCopy.textContent = pageState.pendingMessage || "Approve or deny the fill request in KeePassGO."; + } else if (pageState.status?.locked) { + meta.textContent = "Unlock KeePassGO"; + panelCopy.textContent = "Unlock KeePassGO to turn this field back into live login suggestions."; } else { const count = Array.isArray(pageState.matches) ? pageState.matches.length : 0; meta.textContent = count === 1 ? "1 login ready" : `${count} logins ready`; @@ -801,6 +822,23 @@ if (isNodeTestEnv) { scheduleRefresh(false); }, true); + document.addEventListener("submit", (event) => { + const form = event.target instanceof HTMLFormElement ? event.target : null; + if (!form) { + return; + } + const passwordInput = visibleInputs(form).find(isPasswordCandidate) || null; + const candidate = passwordInput ? authFlowCandidate(passwordInput) : null; + const observed = submittedCredential(candidate, window.location.href); + if (!observed) { + return; + } + void runtimeSend({ + type: "keepassgo-observed-login", + observed + }).catch(() => null); + }, true); + document.addEventListener("click", (event) => { if (!root.contains(event.target)) { chooserOpen = false; diff --git a/browser/extension/content.test.cjs b/browser/extension/content.test.cjs index ecee94d..1e0166a 100644 --- a/browser/extension/content.test.cjs +++ b/browser/extension/content.test.cjs @@ -94,6 +94,19 @@ test("shouldShowInlineOverlay hides the page overlay after it is suppressed", () assert.equal(content.shouldShowInlineOverlay(state, true, true, false), false); }); +test("shouldShowInlineOverlay stays visible for locked login pages", () => { + const state = { + pageHasLoginForm: true, + configured: true, + success: true, + status: { locked: true }, + matches: [], + pendingFill: false + }; + + assert.equal(content.shouldShowInlineOverlay(state, true, false, false), true); +}); + test("shouldShowInlineOverlay hides the page overlay after idle expiry", () => { const state = { pageHasLoginForm: true, @@ -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, 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" + }); +}); diff --git a/browser/extension/icons/icon-128.png b/browser/extension/icons/icon-128.png new file mode 100644 index 0000000..8602f77 Binary files /dev/null and b/browser/extension/icons/icon-128.png differ diff --git a/browser/extension/icons/icon-16.png b/browser/extension/icons/icon-16.png new file mode 100644 index 0000000..e976c5e Binary files /dev/null and b/browser/extension/icons/icon-16.png differ diff --git a/browser/extension/icons/icon-32.png b/browser/extension/icons/icon-32.png new file mode 100644 index 0000000..c1faec1 Binary files /dev/null and b/browser/extension/icons/icon-32.png differ diff --git a/browser/extension/icons/icon-48.png b/browser/extension/icons/icon-48.png new file mode 100644 index 0000000..2144944 Binary files /dev/null and b/browser/extension/icons/icon-48.png differ diff --git a/browser/extension/icons/icon-96.png b/browser/extension/icons/icon-96.png new file mode 100644 index 0000000..00c1a42 Binary files /dev/null and b/browser/extension/icons/icon-96.png differ diff --git a/browser/extension/manifest.firefox.json b/browser/extension/manifest.firefox.json index e652fad..1c15b6e 100644 --- a/browser/extension/manifest.firefox.json +++ b/browser/extension/manifest.firefox.json @@ -3,6 +3,13 @@ "name": "KeePassGO Browser", "version": "0.1.0", "description": "Fill credentials from KeePassGO on sign-in pages.", + "icons": { + "16": "icons/icon-16.png", + "32": "icons/icon-32.png", + "48": "icons/icon-48.png", + "96": "icons/icon-96.png", + "128": "icons/icon-128.png" + }, "permissions": [ "activeTab", "nativeMessaging", @@ -16,6 +23,10 @@ }, "browser_action": { "default_title": "KeePassGO Browser", + "default_icon": { + "16": "icons/icon-16.png", + "32": "icons/icon-32.png" + }, "default_popup": "popup.html" }, "options_ui": { @@ -31,7 +42,14 @@ ], "browser_specific_settings": { "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" } } } diff --git a/browser/extension/options.html b/browser/extension/options.html index 1f9a6b1..ce5631c 100644 --- a/browser/extension/options.html +++ b/browser/extension/options.html @@ -19,6 +19,25 @@ API token + + Browser Matching + + + Best match only + + + + Require current scheme + + + Sort results + + KeePassGO match quality + Title + Path + + + Save diff --git a/browser/extension/options.js b/browser/extension/options.js index 2cdd4ed..faee9ec 100644 --- a/browser/extension/options.js +++ b/browser/extension/options.js @@ -23,6 +23,9 @@ async function loadSettings() { throw new Error(response?.error || "Could not load settings."); } document.getElementById("bearer-token").value = response.settings.bearerToken || ""; + document.getElementById("best-match-only").checked = Boolean(response.settings.bestMatchOnly); + document.getElementById("require-scheme-match").checked = Boolean(response.settings.requireSchemeMatch); + document.getElementById("sort-results").value = response.settings.sortResults || "quality"; } async function saveSettings(event) { @@ -33,7 +36,10 @@ async function saveSettings(event) { const response = await runtimeSend({ type: "keepassgo-save-settings", settings: { - bearerToken: document.getElementById("bearer-token").value + bearerToken: document.getElementById("bearer-token").value, + bestMatchOnly: document.getElementById("best-match-only").checked, + requireSchemeMatch: document.getElementById("require-scheme-match").checked, + sortResults: document.getElementById("sort-results").value } }); if (!response?.success) { diff --git a/browser/extension/popup.html b/browser/extension/popup.html index 8df2a02..c8401f8 100644 --- a/browser/extension/popup.html +++ b/browser/extension/popup.html @@ -20,10 +20,25 @@ Checking KeePassGO. Loading page state. + + + Save Submitted Login + KeePassGO can save this login. + + Save Login + Matches + + Search Vault + + + Search + + +
Checking KeePassGO.
Loading page state.
KeePassGO can save this login.