Compare commits

..

12 Commits

Author SHA1 Message Date
Joe Julian 9a9d9e7447 Add Firefox extension icons and gap review
ci / lint-test (push) Successful in 4m8s
ci / build (push) Failing after 5m40s
2026-04-23 21:42:52 -07:00
Joe Julian 2c065a04a4 Narrow Android autofill chooser results 2026-04-23 21:02:34 -07:00
Joe Julian f82ddf7435 Add browser save and update workflow 2026-04-23 21:00:29 -07:00
Joe Julian 14c9bc72f6 Support Android share-driven credential lookup 2026-04-23 20:51:39 -07:00
Joe Julian 515eb730f0 Broaden Android accessibility autofill fallback 2026-04-23 20:44:32 -07:00
Joe Julian d60a8d2fbf Improve locked vault browser workflow 2026-04-23 20:37:49 -07:00
Joe Julian 4afbc3c933 Add browser search and richer URL matching 2026-04-23 20:36:17 -07:00
Joe Julian c7d35927f3 Add GUI test plan
ci / lint-test (push) Successful in 3m55s
ci / build (push) Successful in 6m14s
2026-04-19 22:01:33 -07:00
joejulian a6340f5c9e Merge pull request 'Fix CI APK JDK selection' (#8) from bugfix/ci-apk-java-selection into main
ci / lint-test (push) Successful in 3m41s
ci / build (push) Successful in 6m11s
2026-04-20 04:30:31 +00:00
Joe Julian 0adf1b8826 Run CI for pull requests
ci / lint-test (pull_request) Successful in 5m56s
ci / build (pull_request) Successful in 5m39s
2026-04-19 21:17:49 -07:00
Joe Julian c517794182 Provision Java 25 directly in CI 2026-04-19 20:37:46 -07:00
Joe Julian b511ab4dc0 Fix CI APK JDK selection 2026-04-19 20:27:14 -07:00
41 changed files with 2309 additions and 276 deletions
+15 -1
View File
@@ -8,6 +8,9 @@ on:
- "v*"
- "release-*"
- "[0-9]+.[0-9]+.[0-9]+*"
pull_request:
branches:
- main
permissions:
contents: write
@@ -16,7 +19,6 @@ env:
GO_VERSION: "1.26.1"
ANDROID_SDK_ROOT: /opt/android-sdk
ANDROID_NDK_ROOT: /opt/android-sdk/ndk
JAVA_HOME: /usr/lib/jvm/java-21-openjdk-amd64
DIST_DIR: dist
jobs:
@@ -31,6 +33,12 @@ jobs:
with:
go-version: ${{ env.GO_VERSION }}
- name: Setup Java
uses: actions/setup-java@v4
with:
distribution: temurin
java-version: "25"
- name: Install native build dependencies
shell: bash
run: |
@@ -78,6 +86,12 @@ jobs:
with:
go-version: ${{ env.GO_VERSION }}
- name: Setup Java
uses: actions/setup-java@v4
with:
distribution: temurin
java-version: "25"
- name: Install native build dependencies
shell: bash
run: |
+1 -1
View File
@@ -177,7 +177,7 @@ These features are product requirements, not “nice to have” ideas.
local `ANDROID_NDK_ROOT=/opt/android-ndk`,
CI `ANDROID_NDK_ROOT=/opt/android-sdk/ndk`,
local `JAVA_HOME=/usr/lib/jvm/java-25-openjdk`,
CI `JAVA_HOME=/usr/lib/jvm/java-21-openjdk-amd64`.
CI `JAVA_HOME` provided by `actions/setup-java` with Temurin 25.
- Remember the known Android runtime regression:
`gioui.org v0.9.0` produced a black screen on the `KeepassGoAPI35` emulator, while `gioui.org v0.8.0` rendered correctly. Treat Gio upgrades on Android as regression-sensitive and verify them on-device or in the emulator.
- When validating an APK in the emulator, prefer the known KeePassGO setup:
+3 -2
View File
@@ -15,7 +15,8 @@ make apk-release
`make apk` uses a local Java 25 install when `JAVA_HOME` points to one.
If the host does not have a working Java 25 install, it falls back to the
repo-managed Docker image in `packaging/docker/android-apk/`, which also builds
with Java 25.
with Java 25. CI provisions Java 25 directly in the build job so release builds
use that same local path instead of nested Docker.
`make apk` remains a developer build path and may use Gio's default debug or
ephemeral signing behavior if no explicit signing key is provided.
@@ -67,7 +68,7 @@ The Android build uses the branded icon asset at:
Note:
- KeePassGO's documented Android build uses Java 25 locally.
- KeePassGO's documented Android build uses Java 25 locally and in CI.
- If that host setup is unavailable, `make apk` falls back to the Docker image
so the build still runs under Java 25 instead of encoding a newer host JDK as
a requirement.
+1 -1
View File
@@ -86,7 +86,7 @@ apk-local: android/keepassgo-android.jar
apk-release:
@test -f "$(RELEASE_SIGNKEY)" || { echo "Release signing key not found at $(RELEASE_SIGNKEY)"; exit 1; }
@test -f "$(RELEASE_SIGNPASS_FILE)" || { echo "Release signing password file not found at $(RELEASE_SIGNPASS_FILE)"; exit 1; }
@$(MAKE) apk SIGNKEY="$(abspath $(RELEASE_SIGNKEY))" SIGNPASS_FILE="$(abspath $(RELEASE_SIGNPASS_FILE))"
@$(MAKE) apk SIGNKEY="$(abspath $(RELEASE_SIGNKEY))" SIGNPASS_FILE="$(abspath $(RELEASE_SIGNPASS_FILE))" JAVA_HOME="$(JAVA_HOME)"
apk-container: apk-container-image
@command -v docker >/dev/null 2>&1 || { echo "docker is required for apk-container"; exit 1; }
+3 -2
View File
@@ -95,8 +95,9 @@ make apk
`make apk` prefers a local Java 25 install at `JAVA_HOME`. If that is not
available, it falls back to the repo-managed Docker build image, which also
uses Java 25. You still need the Android SDK and NDK installed and configured
for real device or release packaging.
uses Java 25. CI provisions Java 25 directly in the build job so release
packaging follows that same local path. You still need the Android SDK and NDK
installed and configured for real device or release packaging.
Release package:
+93 -1
View File
@@ -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.
+12
View File
@@ -35,6 +35,11 @@
android:name="org.julianfamily.keepassgo.SharedVaultImportActivity"
android:exported="true"
android:theme="@android:style/Theme.Translucent.NoTitleBar">
<intent-filter>
<action android:name="android.intent.action.SEND" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="text/plain" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.SEND" />
<category android:name="android.intent.category.DEFAULT" />
@@ -42,6 +47,13 @@
<data android:mimeType="application/x-keepass2" />
<data android:mimeType="application/vnd.keepass" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="http" />
<data android:scheme="https" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
@@ -25,26 +25,7 @@ final class AutofillCacheStore {
if (entries.isEmpty()) {
return null;
}
NormalizedTarget target = normalizeURL(webDomain);
if (target.host.isEmpty()) {
return null;
}
List<Entry> exactHost = new ArrayList<>();
List<Entry> parentHost = new ArrayList<>();
for (Entry entry : entries) {
if (entryMatchesHost(entry, target.host)) {
exactHost.add(entry);
continue;
}
if (entryMatchesParentHost(entry, target.host)) {
parentHost.add(entry);
}
}
Entry matched = chooseEntry(target, exactHost);
if (matched != null) {
return matched;
}
return chooseEntry(target, parentHost);
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<Entry> resolved = new ArrayList<>();
resolved.add(direct);
@@ -77,6 +58,30 @@ final class AutofillCacheStore {
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) {
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<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);
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;
}
}
}
@@ -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 {
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<AccessibilityNodeInfo> 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;
}
@@ -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();
@@ -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");
}
}
}
+206 -3
View File
@@ -191,6 +191,96 @@ 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 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 +297,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 +319,7 @@ function defaultPageState(tabId, pageUrl) {
pendingEntryId: "",
pendingTarget: null,
pendingMessage: "",
pendingSave: null,
lastFilledEntryId: "",
updatedAt: 0
});
@@ -292,6 +384,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 +439,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 +600,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 +610,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);
@@ -529,7 +637,7 @@ async function refreshPageState(tabId, pageUrl, options = {}) {
updatedAt: Date.now()
};
const saved = await setPageState(tabId, state);
if (saved.pendingFill) {
if (shouldContinueWatchingState(saved)) {
schedulePendingPoll(tabId, resolvedURL);
} else {
clearPendingPoll(tabId);
@@ -653,11 +761,64 @@ 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 = {
normalizePageState,
actionPresentationForState,
shouldReuseMatches,
shouldContinueWatchingState,
tokenPendingApprovalCount,
savePlanForObservedLogin,
defaultSettings
};
@@ -697,6 +858,48 @@ if (isNodeTestEnv) {
await refreshActivePage({ force: true }).catch(() => null);
sendResponse({ success: true });
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: Array.isArray(response?.searchResults) ? response.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, {
+77
View File
@@ -49,6 +49,83 @@ 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, "");
});
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"
});
});
+40 -2
View File
@@ -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;
+27
View File
@@ -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"
});
});
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

+19 -1
View File
@@ -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"
}
}
}
+15
View File
@@ -20,10 +20,25 @@
<p id="status-message" class="subtle">Checking KeePassGO.</p>
</section>
<p id="page-hint" class="inline-hint subtle">Loading page state.</p>
<section id="save-card" class="save-card" hidden>
<div>
<h2>Save Submitted Login</h2>
<p id="save-message" class="subtle">KeePassGO can save this login.</p>
</div>
<button id="save-action" type="button">Save Login</button>
</section>
<section>
<h2>Matches</h2>
<div id="matches" class="match-list"></div>
</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>
<script src="popup.js"></script>
</body>
+143 -17
View File
@@ -43,21 +43,25 @@ function matchSubtitle(match) {
return parts.join(" · ") || "No username";
}
function renderMatches(state) {
const root = document.getElementById("matches");
function saveCardLabel(pendingSave) {
return pendingSave?.mode === "update"
? `Update ${pendingSave.title || "Login"}`
: "Save Login";
}
function renderMatchList(root, matches, options = {}) {
const targetTabID = popupTabID();
const emptyMessage = options.emptyMessage || "No matching entries.";
root.textContent = "";
if (!Array.isArray(state.matches) || state.matches.length === 0) {
if (!Array.isArray(matches) || matches.length === 0) {
const empty = document.createElement("p");
empty.className = "subtle";
empty.textContent = state.pageHasLoginForm
? "No matching entries for this page."
: "No login fields detected on this page.";
empty.textContent = emptyMessage;
root.appendChild(empty);
return;
}
for (const match of state.matches) {
for (const match of matches) {
const row = document.createElement("button");
row.type = "button";
row.className = "match-row";
@@ -77,19 +81,23 @@ function renderMatches(state) {
row.appendChild(quality);
row.addEventListener("click", async () => {
row.disabled = true;
setStatus("Approval may be required", "KeePassGO will prompt if this token needs approval before fill.", "warning");
try {
const result = await runtimeSend({
type: "keepassgo-fill-entry",
entryId: match.id,
tabId: targetTabID
});
if (!result?.success) {
throw new Error(result?.error || "Fill failed.");
if (typeof options.onSelect === "function") {
await options.onSelect(match, targetTabID);
} else {
setStatus("Approval may be required", "KeePassGO will prompt if this token needs approval before fill.", "warning");
const result = await runtimeSend({
type: "keepassgo-fill-entry",
entryId: match.id,
tabId: targetTabID
});
if (!result?.success) {
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) {
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 {
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) {
const hint = document.getElementById("page-hint");
if (state.pendingFill) {
@@ -115,6 +168,46 @@ function renderPageHint(state) {
hint.textContent = "Open a sign-in page to see KeePassGO suggestions here.";
}
function renderPendingSave(state) {
const card = document.getElementById("save-card");
const message = document.getElementById("save-message");
const action = document.getElementById("save-action");
const pendingSave = state.pendingSave;
if (!pendingSave) {
card.hidden = true;
action.onclick = null;
return;
}
card.hidden = false;
action.textContent = saveCardLabel(pendingSave);
if (pendingSave.mode === "update") {
message.textContent = `KeePassGO can update ${pendingSave.title || "this login"} with the submitted password.`;
} else if (Array.isArray(pendingSave.path) && pendingSave.path.length > 0) {
message.textContent = `KeePassGO can save this login in ${pendingSave.path.join(" / ")}. Search the vault to choose a different group if needed.`;
} else {
message.textContent = "Search the vault below to choose a group for this submitted login.";
}
action.disabled = pendingSave.mode !== "update" && (!Array.isArray(pendingSave.path) || pendingSave.path.length === 0);
action.onclick = async () => {
action.disabled = true;
try {
const result = await runtimeSend({
type: "keepassgo-save-login",
tabId: popupTabID()
});
if (!result?.success) {
throw new Error(result?.error || "Save failed.");
}
setStatus("Saved", `${pendingSave.title || "Login"} is now in KeePassGO.`, "ready");
card.hidden = true;
} catch (error) {
setStatus("Save failed", error instanceof Error ? error.message : String(error), "error");
} finally {
action.disabled = false;
}
};
}
function popupTabID() {
const rawValue = new URLSearchParams(window.location.search).get("tabId");
if (rawValue === null) {
@@ -124,8 +217,38 @@ function popupTabID() {
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() {
try {
document.getElementById("search-form").addEventListener("submit", searchVault);
renderSearchResults([], "");
const state = await runtimeSend({
type: "keepassgo-popup-state",
force: true,
@@ -133,6 +256,7 @@ async function main() {
});
document.getElementById("page-host").textContent = hostFromURL(state.pageUrl || "");
renderPageHint(state);
renderPendingSave(state);
if (!state.configured) {
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;
if (!state.pageHasLoginForm) {
setStatus("Ready", "KeePassGO is connected. Open a login form to check for matches.", "ready");
} else if (state.pendingSave) {
setStatus("Save submitted login", state.pendingSave.mode === "update" ? `Update ${state.pendingSave.title || "this login"} or pick a different target below.` : "Save this submitted login or search below to choose a target entry.", "ready");
} else if (count === 0) {
setStatus("Checked this page", "KeePassGO did not find a matching login for this form.", "ready");
} else {
+23
View File
@@ -96,6 +96,29 @@ h2 {
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,
button,
.link-button {
+78
View File
@@ -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.
+66
View File
@@ -93,6 +93,72 @@ 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.
- 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.
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
## Locked Vault Workflow
User story:
- When the current page has a login form but KeePassGO is locked, the browser
must still make that state visible on the page and in the popup.
- Unlocking KeePassGO should not require the user to reopen the popup multiple
times or reload the page before the extension becomes usable again.
Expected behavior:
- The popup shows a locked-state message instead of silently falling back to
"no matches."
- The inline page affordance stays visible on login forms while KeePassGO is
locked and tells the user to unlock the vault.
- After the vault is unlocked, the extension rechecks the page automatically
and turns the locked affordance back into live matches without requiring a
page reload.
## Save And Update Workflow
User story:
- After the user submits a login form, the browser extension should help store
that credential instead of forcing the user back into KeePassGO manually.
- If KeePassGO already has a matching entry for that site and username, the
popup should offer an update.
- If the user is creating a new login, the popup should let the user save it
into a relevant vault group without leaving the browser.
Expected behavior:
- Submitted login forms queue a pending browser save/update state for the
active tab.
- The popup shows that pending save/update state prominently instead of hiding
it behind page matches alone.
- When KeePassGO finds an exact browser match for the submitted username and
site, the popup offers an `Update` action for that entry.
- When there is no exact entry match, the popup offers a `Save` action using a
relevant group path from the current page matches or a user-selected search
result.
- The browser save/update action writes through KeePassGO's existing secure
gRPC mutation API and stays scoped to the browser token's allowed groups.
For extension-side regression checks, run:
```bash
+207
View File
@@ -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
View File
@@ -275,7 +275,7 @@ func (s *Server) FindBrowserLogins(ctx context.Context, req *keepassgov1.FindBro
var matches []rankedBrowserMatch
for _, entry := range displayModel.Entries {
quality, score := classifyBrowserEntryMatch(pageHost, entry.URL)
quality, score := classifyBrowserEntry(pageHost, entry)
if score == 0 {
continue
}
@@ -390,7 +390,7 @@ func (s *Server) GetBrowserCredential(ctx context.Context, req *keepassgov1.GetB
if err != nil {
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")
}
}
@@ -446,19 +446,22 @@ func (s *Server) ListEntries(ctx context.Context, req *keepassgov1.ListEntriesRe
}
displayModel := visibleModel(model)
internalPath := expandClientPath(displayModel, req.GetPath())
if _, err := s.authorizePathRequest(ctx, apitokens.OperationListEntries, internalPath); err != nil {
return nil, err
}
model = displayModel
var entries []vault.Entry
if strings.TrimSpace(req.GetQuery()) != "" {
token, err := s.authenticateRequest(ctx)
if err != nil {
return nil, err
}
results := model.Search(req.GetQuery())
entries = make([]vault.Entry, 0, len(results))
for _, result := range results {
entries = append(entries, result.Entry)
entries, err = s.authorizedSearchEntries(ctx, model, token, internalPath, results)
if err != nil {
return nil, err
}
} else {
if _, err := s.authorizePathRequest(ctx, apitokens.OperationListEntries, internalPath); err != nil {
return nil, err
}
entries = model.EntriesInPath(internalPath)
}
@@ -472,6 +475,49 @@ func (s *Server) ListEntries(ctx context.Context, req *keepassgov1.ListEntriesRe
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) {
model, locked := s.snapshotModel()
if locked {
@@ -1063,6 +1109,52 @@ func normalizedBrowserEntryHost(raw string) string {
return ""
}
func browserURLFieldKey(key string) bool {
if len(key) <= len("URL") || !strings.EqualFold(key[:len("URL")], "URL") {
return false
}
for _, r := range key[len("URL"):] {
if r < '0' || r > '9' {
return false
}
}
return true
}
func browserEntryURLs(entry vault.Entry) []string {
urls := make([]string, 0, 1+len(entry.Fields))
if raw := strings.TrimSpace(entry.URL); raw != "" {
urls = append(urls, raw)
}
if len(entry.Fields) == 0 {
return urls
}
keys := slices.Collect(maps.Keys(entry.Fields))
slices.Sort(keys)
for _, key := range keys {
if !browserURLFieldKey(key) {
continue
}
if raw := strings.TrimSpace(entry.Fields[key]); raw != "" {
urls = append(urls, raw)
}
}
return urls
}
func classifyBrowserEntry(pageHost string, entry vault.Entry) (string, int) {
bestQuality := ""
bestScore := 0
for _, rawURL := range browserEntryURLs(entry) {
quality, score := classifyBrowserEntryMatch(pageHost, rawURL)
if score > bestScore {
bestQuality = quality
bestScore = score
}
}
return bestQuality, bestScore
}
func classifyBrowserEntryMatch(pageHost, rawEntryURL string) (string, int) {
entryHost := normalizedBrowserEntryHost(rawEntryURL)
if entryHost == "" {
@@ -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 {
out := model
out.Entries = nil
+94
View File
@@ -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) {
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) {
t.Parallel()
+6
View File
@@ -140,6 +140,7 @@ type statePaths struct {
AutofillCachePath string
PendingSharedVaultPath string
PendingSharedVaultNamePath string
PendingSharedLookupPath string
}
type recentVaultRecord struct {
@@ -474,6 +475,8 @@ type ui struct {
autofillCachePath string
pendingSharedVaultPath string
pendingSharedVaultNamePath string
pendingSharedLookupPath string
pendingSharedLookupQuery string
editingEntry bool
syncDefaultSourceMode syncSourceMode
syncDefaultDirection syncDirection
@@ -656,6 +659,7 @@ func newUIWithState(mode string, sess appstate.CurrentSession, paths statePaths)
autofillCachePath: paths.AutofillCachePath,
pendingSharedVaultPath: paths.PendingSharedVaultPath,
pendingSharedVaultNamePath: paths.PendingSharedVaultNamePath,
pendingSharedLookupPath: paths.PendingSharedLookupPath,
recentVaultGroups: map[string][]string{},
recentVaultUsedAt: map[string]time.Time{},
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.consumePendingSharedVaultImport()
u.consumePendingSharedLookup()
u.restoreStartupLifecycleTarget()
u.requestMasterPassFocus = u.hasSelectedLifecycleTarget()
u.loadUIPreferences()
@@ -785,6 +790,7 @@ func defaultStatePaths(stateDir string) statePaths {
AutofillCachePath: filepath.Join(baseDir, "autofill-cache.json"),
PendingSharedVaultPath: filepath.Join(baseDir, "pending-shared-vault.kdbx"),
PendingSharedVaultNamePath: filepath.Join(baseDir, "pending-shared-vault-name.txt"),
PendingSharedLookupPath: filepath.Join(baseDir, "pending-shared-lookup.txt"),
}
}
+49
View File
@@ -4,8 +4,10 @@ import (
"errors"
"fmt"
"io"
"net/url"
"os"
"path/filepath"
"regexp"
"runtime"
"strings"
@@ -17,6 +19,8 @@ import (
"git.julianfamily.org/keepassgo/internal/webdav"
)
var pendingSharedLookupURLPattern = regexp.MustCompile(`https?://[^\s<>"']+`)
func (u *ui) createVaultAction() error {
key, err := u.currentMasterKey()
defer u.clearMasterPassword()
@@ -78,6 +82,7 @@ func (u *ui) openVaultAction() error {
u.loadSecuritySettingsFromSession()
u.editingEntry = false
u.filter()
u.applyPendingSharedLookup()
u.applyPendingLifecycleOpenIntent()
return nil
}
@@ -120,6 +125,7 @@ func (u *ui) startOpenVaultAction() {
u.loadSecuritySettingsFromSession()
u.editingEntry = false
u.filter()
u.applyPendingSharedLookup()
u.applyPendingLifecycleOpenIntent()
return 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 {
target := u.importedVaultDestination(name)
if err := os.MkdirAll(filepath.Dir(target), 0o700); err != nil {
+92
View File
@@ -8390,6 +8390,9 @@ func TestDefaultStatePathsUsesProvidedStateDir(t *testing.T) {
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"))
}
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) {
@@ -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) {
t.Parallel()
+192 -36
View File
@@ -2,12 +2,15 @@ package browserbridge
import (
"context"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/binary"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"net/url"
"os"
"path/filepath"
"runtime"
@@ -28,19 +31,25 @@ const (
)
type Request struct {
Action string `json:"action"`
BearerToken string `json:"bearerToken,omitempty"`
URL string `json:"url,omitempty"`
EntryID string `json:"entryId,omitempty"`
Action string `json:"action"`
BearerToken string `json:"bearerToken,omitempty"`
URL string `json:"url,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 {
Success bool `json:"success"`
Error string `json:"error,omitempty"`
Status *Status `json:"status,omitempty"`
Matches []Match `json:"matches,omitempty"`
Credential *Credential `json:"credential,omitempty"`
Version string `json:"version,omitempty"`
Success bool `json:"success"`
Error string `json:"error,omitempty"`
Status *Status `json:"status,omitempty"`
Matches []Match `json:"matches,omitempty"`
SearchResults []Match `json:"searchResults,omitempty"`
Credential *Credential `json:"credential,omitempty"`
Version string `json:"version,omitempty"`
}
type Status struct {
@@ -77,11 +86,15 @@ type Connection struct {
type Client interface {
Status(context.Context) (*keepassgov1.GetSessionStatusResponse, error)
FindBrowserLogins(context.Context, string) ([]*keepassgov1.BrowserLoginMatch, error)
ListEntries(context.Context, []string, string) ([]*keepassgov1.Entry, error)
GetBrowserCredential(context.Context, string, string) (*keepassgov1.GetBrowserCredentialResponse, error)
UpsertEntry(context.Context, *keepassgov1.Entry) (*keepassgov1.Entry, error)
}
type Browser string
type actionHandler func(context.Context, Client, Request, string) Response
const (
BrowserFirefox Browser = "firefox"
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()}
}
action := strings.TrimSpace(req.Action)
switch action {
case "status":
status, err := statusResponse(ctx, client, conn.GRPCAddress)
if err != nil {
return Response{Success: false, Error: err.Error(), Status: disconnectedStatus(conn.GRPCAddress)}
}
return Response{Success: true, Status: status, Version: responseVersion}
case "find-logins":
matches, err := findMatches(ctx, client, req.URL)
if err != nil {
if status := inferredActionStatus(conn.GRPCAddress, err); status != nil {
return Response{Success: true, Status: status, Matches: nil, Version: responseVersion}
}
return Response{Success: false, Error: err.Error(), Status: disconnectedStatus(conn.GRPCAddress)}
}
return Response{Success: true, Status: availableStatus(conn.GRPCAddress), Matches: matches, Version: responseVersion}
case "get-login":
credential, err := loadCredential(ctx, client, req.EntryID, req.URL)
if err != nil {
if status := inferredActionStatus(conn.GRPCAddress, err); status != nil {
return Response{Success: false, Error: err.Error(), Status: status}
}
return Response{Success: false, Error: err.Error(), Status: disconnectedStatus(conn.GRPCAddress)}
}
return Response{Success: true, Status: availableStatus(conn.GRPCAddress), Credential: credential, Version: responseVersion}
default:
handler, ok := actionHandlers[action]
if !ok {
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 {
@@ -264,6 +313,113 @@ func loadCredential(ctx context.Context, client Client, entryID, rawURL string)
}, 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) {
path := strings.TrimSpace(binaryPath)
if path == "" {
+126
View File
@@ -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) {
t.Parallel()
@@ -309,10 +413,14 @@ func TestEnsureNativeHostManifestsInstallsFirefoxAndDiscoveredChromium(t *testin
type fakeClient struct {
status *keepassgov1.GetSessionStatusResponse
matches []*keepassgov1.BrowserLoginMatch
entries []*keepassgov1.Entry
credential *keepassgov1.GetBrowserCredentialResponse
upserted *keepassgov1.Entry
err error
matchesErr error
entriesErr error
credentialErr error
upsertErr error
statusCalls int
}
@@ -382,6 +490,16 @@ func (f *fakeClient) FindBrowserLogins(context.Context, string) ([]*keepassgov1.
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) {
if f.credentialErr != nil {
return nil, f.credentialErr
@@ -394,3 +512,11 @@ func (f *fakeClient) GetBrowserCredential(context.Context, string, string) (*kee
}
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
}
+19
View File
@@ -65,9 +65,28 @@ func (c *GRPCClient) FindBrowserLogins(ctx context.Context, pageURL string) ([]*
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) {
return c.client.GetBrowserCredential(ctx, &keepassgov1.GetBrowserCredentialRequest{
Id: strings.TrimSpace(entryID),
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
}
+6 -3
View File
@@ -5,10 +5,13 @@ import "git.julianfamily.org/keepassgo/internal/vault"
// HiddenRoot returns the single synthetic top-level vault group that should be
// treated as an internal storage root rather than as a user-visible group.
func HiddenRoot(model vault.Model) string {
if !hasGroup(model.Groups, []string{KeepassRoot}) {
return ""
if hasGroup(model.Groups, []string{KeepassRoot}) {
return KeepassRoot
}
return KeepassRoot
if usesTopLevelRoot(model, KeepassRoot) {
return KeepassRoot
}
return ""
}
func hasGroup(groups [][]string, path []string) bool {
+17
View File
@@ -24,3 +24,20 @@ func TestHiddenRootIgnoresRecycleBin(t *testing.T) {
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"
install -Dm644 browser/extension/content.js \
"${pkgdir}/usr/share/keepassgo/browser-extension/content.js"
install -Dm644 browser/extension/icons/icon-16.png \
"${pkgdir}/usr/share/keepassgo/browser-extension/icons/icon-16.png"
install -Dm644 browser/extension/icons/icon-32.png \
"${pkgdir}/usr/share/keepassgo/browser-extension/icons/icon-32.png"
install -Dm644 browser/extension/icons/icon-48.png \
"${pkgdir}/usr/share/keepassgo/browser-extension/icons/icon-48.png"
install -Dm644 browser/extension/icons/icon-96.png \
"${pkgdir}/usr/share/keepassgo/browser-extension/icons/icon-96.png"
install -Dm644 browser/extension/icons/icon-128.png \
"${pkgdir}/usr/share/keepassgo/browser-extension/icons/icon-128.png"
install -Dm644 browser/extension/manifest.chromium.json \
"${pkgdir}/usr/share/keepassgo/browser-extension/manifest.chromium.json"
install -Dm644 browser/extension/manifest.firefox.json \