Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 72006aa4b1 | |||
| e171f49287 | |||
| 11e883279d | |||
| e305a25802 | |||
| 8b4609c141 | |||
| d18627cda4 |
@@ -116,6 +116,10 @@ These features are product requirements, not “nice to have” ideas.
|
|||||||
- UI state should not be the source of truth for vault structure or search behavior.
|
- UI state should not be the source of truth for vault structure or search behavior.
|
||||||
- Domain packages must be test-driven where practical.
|
- Domain packages must be test-driven where practical.
|
||||||
- Prefer behavior-oriented tests that describe expected product behavior rather than implementation details.
|
- Prefer behavior-oriented tests that describe expected product behavior rather than implementation details.
|
||||||
|
- Prefer simplifying refactors that extract shared behavior into smaller named
|
||||||
|
functions. When a new path needs most of an existing function, factor the
|
||||||
|
common behavior out and let the specific functions call it instead of adding
|
||||||
|
flags or branches that make the original function larger.
|
||||||
- Provide a secure gRPC API as a first-class programmatic surface, not as a thin wrapper around UI state.
|
- Provide a secure gRPC API as a first-class programmatic surface, not as a thin wrapper around UI state.
|
||||||
- Design browser-extension and automation integrations against the gRPC API, not against ad hoc local protocols.
|
- Design browser-extension and automation integrations against the gRPC API, not against ad hoc local protocols.
|
||||||
- Treat the vault model as local-first across all platforms:
|
- Treat the vault model as local-first across all platforms:
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ PATH := $(JAVA_HOME)/bin:$(ANDROID_SDK_ROOT)/cmdline-tools/latest/bin:$(ANDROID_
|
|||||||
APK_BUILD_IMAGE ?= keepassgo/android-apk-build:java25
|
APK_BUILD_IMAGE ?= keepassgo/android-apk-build:java25
|
||||||
APP_ID ?= org.julianfamily.keepassgo
|
APP_ID ?= org.julianfamily.keepassgo
|
||||||
APK_OUT ?= build/keepassgo.apk
|
APK_OUT ?= build/keepassgo.apk
|
||||||
APK_VERSION ?= 0.1.0.1
|
APK_VERSION ?= 0.8.2.298
|
||||||
APP_VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo dev)
|
APP_VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo dev)
|
||||||
GO_LDFLAGS ?= -X git.julianfamily.org/keepassgo/internal/appui.appVersion=$(APP_VERSION)
|
GO_LDFLAGS ?= -X git.julianfamily.org/keepassgo/internal/appui.appVersion=$(APP_VERSION)
|
||||||
APK_ARCH ?= arm64,amd64
|
APK_ARCH ?= arm64,amd64
|
||||||
|
|||||||
@@ -10,9 +10,7 @@ import java.io.IOException;
|
|||||||
import java.io.InputStreamReader;
|
import java.io.InputStreamReader;
|
||||||
import java.nio.charset.StandardCharsets;
|
import java.nio.charset.StandardCharsets;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.Comparator;
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Locale;
|
|
||||||
|
|
||||||
final class AutofillCacheStore {
|
final class AutofillCacheStore {
|
||||||
private static final String TAG = "KeePassGOAutofill";
|
private static final String TAG = "KeePassGOAutofill";
|
||||||
@@ -41,21 +39,25 @@ final class AutofillCacheStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
static List<Entry> chooserCandidates(Context context, String rawTarget) {
|
static List<Entry> chooserCandidates(Context context, String rawTarget) {
|
||||||
List<Entry> entries = readEntries(context);
|
return chooserCandidatesFromEntries(readEntries(context), rawTarget);
|
||||||
|
}
|
||||||
|
|
||||||
|
static List<Entry> chooserCandidatesFromEntries(List<Entry> entries, String rawTarget) {
|
||||||
if (entries.isEmpty()) {
|
if (entries.isEmpty()) {
|
||||||
return entries;
|
return entries;
|
||||||
}
|
}
|
||||||
Entry direct = fromMatcherEntry(AutofillTargetMatcher.findBestMatch(toMatcherEntries(entries), rawTarget));
|
return fromMatcherEntries(AutofillTargetMatcher.chooserCandidates(toMatcherEntries(entries), rawTarget));
|
||||||
if (direct != null) {
|
}
|
||||||
List<Entry> resolved = new ArrayList<>();
|
|
||||||
resolved.add(direct);
|
static List<Entry> relevantCandidates(Context context, String rawTarget) {
|
||||||
return resolved;
|
return relevantCandidatesFromEntries(readEntries(context), rawTarget);
|
||||||
|
}
|
||||||
|
|
||||||
|
static List<Entry> relevantCandidatesFromEntries(List<Entry> entries, String rawTarget) {
|
||||||
|
if (entries.isEmpty()) {
|
||||||
|
return entries;
|
||||||
}
|
}
|
||||||
entries.sort(Comparator
|
return fromMatcherEntries(AutofillTargetMatcher.relevantCandidates(toMatcherEntries(entries), rawTarget));
|
||||||
.comparing((Entry entry) -> entry.title.toLowerCase(Locale.US))
|
|
||||||
.thenComparing(entry -> String.join("/", entry.path).toLowerCase(Locale.US))
|
|
||||||
.thenComparing(entry -> entry.id));
|
|
||||||
return entries;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static List<AutofillTargetMatcher.Entry> toMatcherEntries(List<Entry> entries) {
|
private static List<AutofillTargetMatcher.Entry> toMatcherEntries(List<Entry> entries) {
|
||||||
@@ -82,6 +84,14 @@ final class AutofillCacheStore {
|
|||||||
return new Entry(entry.id, entry.title, entry.username, entry.password, entry.host, entry.url, entry.targets, entry.path);
|
return new Entry(entry.id, entry.title, entry.username, entry.password, entry.host, entry.url, entry.targets, entry.path);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static List<Entry> fromMatcherEntries(List<AutofillTargetMatcher.Entry> entries) {
|
||||||
|
List<Entry> converted = new ArrayList<>(entries.size());
|
||||||
|
for (AutofillTargetMatcher.Entry entry : entries) {
|
||||||
|
converted.add(fromMatcherEntry(entry));
|
||||||
|
}
|
||||||
|
return converted;
|
||||||
|
}
|
||||||
|
|
||||||
private static List<Entry> readEntries(Context context) {
|
private static List<Entry> readEntries(Context context) {
|
||||||
File cacheFile = findCacheFile(context);
|
File cacheFile = findCacheFile(context);
|
||||||
if (cacheFile == null) {
|
if (cacheFile == null) {
|
||||||
|
|||||||
@@ -37,6 +37,17 @@ final class AutofillTargetMatcher {
|
|||||||
}
|
}
|
||||||
|
|
||||||
static List<Entry> chooserCandidates(List<Entry> entries, String rawTarget) {
|
static List<Entry> chooserCandidates(List<Entry> entries, String rawTarget) {
|
||||||
|
if (entries == null || entries.isEmpty()) {
|
||||||
|
return new ArrayList<>();
|
||||||
|
}
|
||||||
|
List<Entry> related = relevantCandidates(entries, rawTarget);
|
||||||
|
if (!related.isEmpty()) {
|
||||||
|
return related;
|
||||||
|
}
|
||||||
|
return sortEntries(entries);
|
||||||
|
}
|
||||||
|
|
||||||
|
static List<Entry> relevantCandidates(List<Entry> entries, String rawTarget) {
|
||||||
if (entries == null || entries.isEmpty()) {
|
if (entries == null || entries.isEmpty()) {
|
||||||
return new ArrayList<>();
|
return new ArrayList<>();
|
||||||
}
|
}
|
||||||
@@ -47,6 +58,9 @@ final class AutofillTargetMatcher {
|
|||||||
return resolved;
|
return resolved;
|
||||||
}
|
}
|
||||||
NormalizedTarget target = normalize(rawTarget);
|
NormalizedTarget target = normalize(rawTarget);
|
||||||
|
if (target.host.isEmpty()) {
|
||||||
|
return new ArrayList<>();
|
||||||
|
}
|
||||||
List<Entry> exactHost = new ArrayList<>();
|
List<Entry> exactHost = new ArrayList<>();
|
||||||
List<Entry> parentHost = new ArrayList<>();
|
List<Entry> parentHost = new ArrayList<>();
|
||||||
for (Entry entry : entries) {
|
for (Entry entry : entries) {
|
||||||
@@ -64,7 +78,7 @@ final class AutofillTargetMatcher {
|
|||||||
if (!parentHost.isEmpty()) {
|
if (!parentHost.isEmpty()) {
|
||||||
return sortEntries(parentHost);
|
return sortEntries(parentHost);
|
||||||
}
|
}
|
||||||
return sortEntries(entries);
|
return new ArrayList<>();
|
||||||
}
|
}
|
||||||
|
|
||||||
static NormalizedTarget normalize(String raw) {
|
static NormalizedTarget normalize(String raw) {
|
||||||
|
|||||||
@@ -68,17 +68,31 @@ public final class KeePassGOAutofillService extends AutofillService {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
AutofillCacheStore.Entry entry = findBoundOrBestMatch(target.matchTarget);
|
AutofillCacheStore.Entry entry = findBoundEntry(target.matchTarget);
|
||||||
if (entry == null) {
|
if (entry == null) {
|
||||||
FillResponse chooser = chooserResponse(target, fields);
|
List<AutofillCacheStore.Entry> candidates = AutofillCacheStore.relevantCandidates(this, target.matchTarget);
|
||||||
if (chooser == null) {
|
if (candidates.isEmpty()) {
|
||||||
|
FillResponse chooser = chooserResponse(target, fields);
|
||||||
|
if (chooser == null) {
|
||||||
|
Log.i(TAG, "no autofill cache match");
|
||||||
|
callback.onSuccess(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Log.i(TAG, "returning searchable chooser dataset for " + target.matchTarget);
|
||||||
|
callback.onSuccess(chooser);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (candidates.size() > 1) {
|
||||||
|
Log.i(TAG, "returning " + candidates.size() + " scoped autofill datasets for " + target.matchTarget);
|
||||||
|
callback.onSuccess(candidatesFillResponse(candidates, fields));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
entry = candidates.get(0);
|
||||||
|
if (entry == null) {
|
||||||
Log.i(TAG, "no autofill cache match");
|
Log.i(TAG, "no autofill cache match");
|
||||||
callback.onSuccess(null);
|
callback.onSuccess(null);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
Log.i(TAG, "returning chooser dataset for " + target.matchTarget);
|
|
||||||
callback.onSuccess(chooser);
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
Log.i(TAG, "matched entry title=" + entry.title + " user=" + entry.username + " host=" + entry.host);
|
Log.i(TAG, "matched entry title=" + entry.title + " user=" + entry.username + " host=" + entry.host);
|
||||||
|
|
||||||
@@ -97,7 +111,7 @@ public final class KeePassGOAutofillService extends AutofillService {
|
|||||||
callback.onSuccess();
|
callback.onSuccess();
|
||||||
}
|
}
|
||||||
|
|
||||||
private AutofillCacheStore.Entry findBoundOrBestMatch(String matchTarget) {
|
private AutofillCacheStore.Entry findBoundEntry(String matchTarget) {
|
||||||
String entryID = AutofillBindingStore.entryIDForTarget(this, matchTarget);
|
String entryID = AutofillBindingStore.entryIDForTarget(this, matchTarget);
|
||||||
if (!entryID.isEmpty()) {
|
if (!entryID.isEmpty()) {
|
||||||
AutofillCacheStore.Entry bound = AutofillCacheStore.findByID(this, entryID);
|
AutofillCacheStore.Entry bound = AutofillCacheStore.findByID(this, entryID);
|
||||||
@@ -105,15 +119,30 @@ public final class KeePassGOAutofillService extends AutofillService {
|
|||||||
return bound;
|
return bound;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return AutofillCacheStore.findBestMatch(this, matchTarget);
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
private FillResponse directFillResponse(AutofillCacheStore.Entry entry, ParsedFields fields) {
|
private FillResponse directFillResponse(AutofillCacheStore.Entry entry, ParsedFields fields) {
|
||||||
|
return new FillResponse.Builder()
|
||||||
|
.addDataset(datasetForEntry(entry, fields))
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
private FillResponse candidatesFillResponse(List<AutofillCacheStore.Entry> entries, ParsedFields fields) {
|
||||||
|
FillResponse.Builder response = new FillResponse.Builder();
|
||||||
|
for (AutofillCacheStore.Entry entry : entries) {
|
||||||
|
response.addDataset(datasetForEntry(entry, fields));
|
||||||
|
}
|
||||||
|
return response.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
private Dataset datasetForEntry(AutofillCacheStore.Entry entry, ParsedFields fields) {
|
||||||
RemoteViews presentation = new RemoteViews(getPackageName(), android.R.layout.simple_list_item_1);
|
RemoteViews presentation = new RemoteViews(getPackageName(), android.R.layout.simple_list_item_1);
|
||||||
presentation.setTextViewText(
|
String label = entry.title == null || entry.title.trim().isEmpty() ? "KeePassGO entry" : entry.title;
|
||||||
android.R.id.text1,
|
if (entry.username != null && !entry.username.trim().isEmpty()) {
|
||||||
entry.title + " (" + entry.username + ")"
|
label += " (" + entry.username + ")";
|
||||||
);
|
}
|
||||||
|
presentation.setTextViewText(android.R.id.text1, label);
|
||||||
|
|
||||||
Dataset.Builder dataset = new Dataset.Builder(presentation);
|
Dataset.Builder dataset = new Dataset.Builder(presentation);
|
||||||
dataset.setId(entry.id);
|
dataset.setId(entry.id);
|
||||||
@@ -121,10 +150,7 @@ public final class KeePassGOAutofillService extends AutofillService {
|
|||||||
dataset.setValue(fields.usernameId, AutofillValue.forText(entry.username));
|
dataset.setValue(fields.usernameId, AutofillValue.forText(entry.username));
|
||||||
}
|
}
|
||||||
dataset.setValue(fields.passwordId, AutofillValue.forText(entry.password));
|
dataset.setValue(fields.passwordId, AutofillValue.forText(entry.password));
|
||||||
|
return dataset.build();
|
||||||
return new FillResponse.Builder()
|
|
||||||
.addDataset(dataset.build())
|
|
||||||
.build();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private FillResponse chooserResponse(ParsedTarget target, ParsedFields fields) {
|
private FillResponse chooserResponse(ParsedTarget target, ParsedFields fields) {
|
||||||
|
|||||||
@@ -10,6 +10,9 @@ public final class AutofillCacheStoreBehaviorTest {
|
|||||||
testChooserCandidatesCollapseToExactAndroidAppMatch();
|
testChooserCandidatesCollapseToExactAndroidAppMatch();
|
||||||
testChooserCandidatesStayScopedToExactHostMatches();
|
testChooserCandidatesStayScopedToExactHostMatches();
|
||||||
testChooserCandidatesStayScopedToParentHostMatches();
|
testChooserCandidatesStayScopedToParentHostMatches();
|
||||||
|
testCacheStoreChooserCandidatesStayScopedToExactHostMatches();
|
||||||
|
testCacheStoreChooserCandidatesFallBackToAllEntriesOnlyWhenUnrelated();
|
||||||
|
testCacheStoreRelevantCandidatesDoNotFallBackToAllEntries();
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void testFindBestMatchUsesAndroidAppTargets() {
|
private static void testFindBestMatchUsesAndroidAppTargets() {
|
||||||
@@ -128,6 +131,103 @@ public final class AutofillCacheStoreBehaviorTest {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static void testCacheStoreChooserCandidatesStayScopedToExactHostMatches() {
|
||||||
|
List<AutofillCacheStore.Entry> entries = new ArrayList<>();
|
||||||
|
entries.add(new AutofillCacheStore.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 AutofillCacheStore.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 AutofillCacheStore.Entry(
|
||||||
|
"night-fox-entry",
|
||||||
|
"Night Fox",
|
||||||
|
"nightfox",
|
||||||
|
"vault-code",
|
||||||
|
"gitlab.com",
|
||||||
|
"https://gitlab.com",
|
||||||
|
Arrays.asList("https://gitlab.com"),
|
||||||
|
Arrays.asList("Crew", "Internet")
|
||||||
|
));
|
||||||
|
|
||||||
|
List<AutofillCacheStore.Entry> got = AutofillCacheStore.chooserCandidatesFromEntries(entries, "https://bellagio.example.invalid/security");
|
||||||
|
if (got.size() != 2 || !containsStoreIDs(got, "bellagio-primary", "bellagio-backup")) {
|
||||||
|
throw new AssertionError("AutofillCacheStore chooser candidates = " + describeStore(got) + ", want only Bellagio entries");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void testCacheStoreChooserCandidatesFallBackToAllEntriesOnlyWhenUnrelated() {
|
||||||
|
List<AutofillCacheStore.Entry> entries = new ArrayList<>();
|
||||||
|
entries.add(new AutofillCacheStore.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 AutofillCacheStore.Entry(
|
||||||
|
"night-fox-entry",
|
||||||
|
"Night Fox",
|
||||||
|
"nightfox",
|
||||||
|
"vault-code",
|
||||||
|
"gitlab.com",
|
||||||
|
"https://gitlab.com",
|
||||||
|
Arrays.asList("https://gitlab.com"),
|
||||||
|
Arrays.asList("Crew", "Internet")
|
||||||
|
));
|
||||||
|
|
||||||
|
List<AutofillCacheStore.Entry> got = AutofillCacheStore.chooserCandidatesFromEntries(entries, "https://tessio.example.invalid/login");
|
||||||
|
if (got.size() != 2 || !containsStoreIDs(got, "bellagio-primary", "night-fox-entry")) {
|
||||||
|
throw new AssertionError("AutofillCacheStore unrelated chooser candidates = " + describeStore(got) + ", want full fallback list");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void testCacheStoreRelevantCandidatesDoNotFallBackToAllEntries() {
|
||||||
|
List<AutofillCacheStore.Entry> entries = new ArrayList<>();
|
||||||
|
entries.add(new AutofillCacheStore.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 AutofillCacheStore.Entry(
|
||||||
|
"night-fox-entry",
|
||||||
|
"Night Fox",
|
||||||
|
"nightfox",
|
||||||
|
"vault-code",
|
||||||
|
"gitlab.com",
|
||||||
|
"https://gitlab.com",
|
||||||
|
Arrays.asList("https://gitlab.com"),
|
||||||
|
Arrays.asList("Crew", "Internet")
|
||||||
|
));
|
||||||
|
|
||||||
|
List<AutofillCacheStore.Entry> got = AutofillCacheStore.relevantCandidatesFromEntries(entries, "https://tessio.example.invalid/login");
|
||||||
|
if (!got.isEmpty()) {
|
||||||
|
throw new AssertionError("AutofillCacheStore relevant unrelated candidates = " + describeStore(got) + ", want []");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private static String describe(AutofillTargetMatcher.Entry entry) {
|
private static String describe(AutofillTargetMatcher.Entry entry) {
|
||||||
if (entry == null) {
|
if (entry == null) {
|
||||||
return "null";
|
return "null";
|
||||||
@@ -150,4 +250,20 @@ public final class AutofillCacheStoreBehaviorTest {
|
|||||||
}
|
}
|
||||||
return ids.containsAll(Arrays.asList(wantIDs)) && ids.size() == wantIDs.length;
|
return ids.containsAll(Arrays.asList(wantIDs)) && ids.size() == wantIDs.length;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static String describeStore(List<AutofillCacheStore.Entry> entries) {
|
||||||
|
List<String> ids = new ArrayList<>();
|
||||||
|
for (AutofillCacheStore.Entry entry : entries) {
|
||||||
|
ids.add(entry.id);
|
||||||
|
}
|
||||||
|
return ids.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean containsStoreIDs(List<AutofillCacheStore.Entry> entries, String... wantIDs) {
|
||||||
|
List<String> ids = new ArrayList<>();
|
||||||
|
for (AutofillCacheStore.Entry entry : entries) {
|
||||||
|
ids.add(entry.id);
|
||||||
|
}
|
||||||
|
return ids.containsAll(Arrays.asList(wantIDs)) && ids.size() == wantIDs.length;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -700,7 +700,34 @@ async function statusForPage(options = {}) {
|
|||||||
return refreshPageState(page.tabId, page.url, options);
|
return refreshPageState(page.tabId, page.url, options);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fillLogin(tabId, entryId) {
|
function matchedLoginCredentialRequest(settings, entryId, pageUrl) {
|
||||||
|
return {
|
||||||
|
action: "get-login",
|
||||||
|
bearerToken: settings.bearerToken,
|
||||||
|
entryId,
|
||||||
|
url: pageUrl
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectedLoginCredentialRequest(settings, entryId) {
|
||||||
|
return {
|
||||||
|
action: "get-login",
|
||||||
|
bearerToken: settings.bearerToken,
|
||||||
|
entryId
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fillMatchedLogin(tabId, entryId) {
|
||||||
|
const page = await loginFillPage(tabId);
|
||||||
|
return fillLoginOnPage(tabId, entryId, page.url, matchedLoginCredentialRequest);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fillSelectedLogin(tabId, entryId) {
|
||||||
|
const page = await loginFillPage(tabId);
|
||||||
|
return fillLoginOnPage(tabId, entryId, page.url, selectedLoginCredentialRequest);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loginFillPage(tabId) {
|
||||||
if (!Number.isInteger(tabId)) {
|
if (!Number.isInteger(tabId)) {
|
||||||
throw new Error("No active tab is available.");
|
throw new Error("No active tab is available.");
|
||||||
}
|
}
|
||||||
@@ -709,7 +736,10 @@ async function fillLogin(tabId, entryId) {
|
|||||||
if (!supportsPageStateURL(pageUrl)) {
|
if (!supportsPageStateURL(pageUrl)) {
|
||||||
throw new Error("This page cannot be filled.");
|
throw new Error("This page cannot be filled.");
|
||||||
}
|
}
|
||||||
|
return { url: pageUrl };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fillLoginOnPage(tabId, entryId, pageUrl, credentialRequest) {
|
||||||
let state = await getPageState(tabId, pageUrl);
|
let state = await getPageState(tabId, pageUrl);
|
||||||
state = await setPageState(tabId, {
|
state = await setPageState(tabId, {
|
||||||
...state,
|
...state,
|
||||||
@@ -729,12 +759,7 @@ async function fillLogin(tabId, entryId) {
|
|||||||
throw new Error("API token is not configured.");
|
throw new Error("API token is not configured.");
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await connectNative({
|
const response = await connectNative(credentialRequest(settings, entryId, pageUrl));
|
||||||
action: "get-login",
|
|
||||||
bearerToken: settings.bearerToken,
|
|
||||||
entryId,
|
|
||||||
url: pageUrl
|
|
||||||
});
|
|
||||||
if (!response?.success || !response.credential) {
|
if (!response?.success || !response.credential) {
|
||||||
throw new Error(response?.error || "KeePassGO did not return a credential.");
|
throw new Error(response?.error || "KeePassGO did not return a credential.");
|
||||||
}
|
}
|
||||||
@@ -846,6 +871,8 @@ const backgroundTestExports = {
|
|||||||
shouldContinueWatchingState,
|
shouldContinueWatchingState,
|
||||||
tokenPendingApprovalCount,
|
tokenPendingApprovalCount,
|
||||||
savePlanForObservedLogin,
|
savePlanForObservedLogin,
|
||||||
|
matchedLoginCredentialRequest,
|
||||||
|
selectedLoginCredentialRequest,
|
||||||
defaultSettings
|
defaultSettings
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -872,7 +899,14 @@ if (isNodeTestEnv) {
|
|||||||
focusTarget: cloneTarget(message.target)
|
focusTarget: cloneTarget(message.target)
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
sendResponse({ success: true, ...(await fillLogin(targetTabID, message.entryId)) });
|
sendResponse({ success: true, ...(await fillMatchedLogin(targetTabID, message.entryId)) });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
case "keepassgo-fill-selected-entry": {
|
||||||
|
const targetTabID = Number.isInteger(message?.tabId)
|
||||||
|
? message.tabId
|
||||||
|
: (Number.isInteger(sender?.tab?.id) ? sender.tab.id : (await activePageContext()).tabId);
|
||||||
|
sendResponse({ success: true, ...(await fillSelectedLogin(targetTabID, message.entryId)) });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
case "keepassgo-load-settings":
|
case "keepassgo-load-settings":
|
||||||
|
|||||||
@@ -149,3 +149,24 @@ test("applyBestMatchOnly preserves all matches when disabled", () => {
|
|||||||
|
|
||||||
assert.deepEqual(filtered.map((match) => match.id), ["livingston", "rusty"]);
|
assert.deepEqual(filtered.map((match) => match.id), ["livingston", "rusty"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("matched login credential requests include the page URL for URL validation", () => {
|
||||||
|
assert.deepEqual(background.matchedLoginCredentialRequest({
|
||||||
|
bearerToken: "token-1"
|
||||||
|
}, "vault-console", "https://bellagio.example.invalid/login"), {
|
||||||
|
action: "get-login",
|
||||||
|
bearerToken: "token-1",
|
||||||
|
entryId: "vault-console",
|
||||||
|
url: "https://bellagio.example.invalid/login"
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("explicit selected credential requests omit the page URL", () => {
|
||||||
|
assert.deepEqual(background.selectedLoginCredentialRequest({
|
||||||
|
bearerToken: "token-1"
|
||||||
|
}, "no-url-entry"), {
|
||||||
|
action: "get-login",
|
||||||
|
bearerToken: "token-1",
|
||||||
|
entryId: "no-url-entry"
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"manifest_version": 3,
|
"manifest_version": 3,
|
||||||
"name": "KeePassGO Browser",
|
"name": "KeePassGO Browser",
|
||||||
"version": "0.1.0",
|
"version": "0.8.2",
|
||||||
"description": "Fill credentials from KeePassGO on sign-in pages.",
|
"description": "Fill credentials from KeePassGO on sign-in pages.",
|
||||||
"permissions": ["activeTab", "nativeMessaging", "storage", "tabs"],
|
"permissions": ["activeTab", "nativeMessaging", "storage", "tabs"],
|
||||||
"host_permissions": ["http://*/*", "https://*/*"],
|
"host_permissions": ["http://*/*", "https://*/*"],
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"manifest_version": 2,
|
"manifest_version": 2,
|
||||||
"name": "KeePassGO Browser",
|
"name": "KeePassGO Browser",
|
||||||
"version": "0.1.0",
|
"version": "0.8.2",
|
||||||
"description": "Fill credentials from KeePassGO on sign-in pages.",
|
"description": "Fill credentials from KeePassGO on sign-in pages.",
|
||||||
"icons": {
|
"icons": {
|
||||||
"16": "icons/icon-16.png",
|
"16": "icons/icon-16.png",
|
||||||
|
|||||||
@@ -97,7 +97,7 @@ function renderMatchList(root, matches, options = {}) {
|
|||||||
setStatus("Filled", `${match.title} was sent to the current page.`, "ready");
|
setStatus("Filled", `${match.title} was sent to the current page.`, "ready");
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setStatus(options.onSelect ? "Save failed" : "Fill failed", error instanceof Error ? error.message : String(error), "error");
|
setStatus(options.errorTitle || (options.onSelect ? "Save failed" : "Fill failed"), error instanceof Error ? error.message : String(error), "error");
|
||||||
} finally {
|
} finally {
|
||||||
row.disabled = false;
|
row.disabled = false;
|
||||||
}
|
}
|
||||||
@@ -147,7 +147,20 @@ function renderSearchResults(results, query) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
renderMatchList(root, results, {
|
renderMatchList(root, results, {
|
||||||
emptyMessage: `No entries matched "${query}".`
|
emptyMessage: `No entries matched "${query}".`,
|
||||||
|
errorTitle: "Fill failed",
|
||||||
|
onSelect: async (match, targetTabID) => {
|
||||||
|
setStatus("Approval may be required", "KeePassGO will prompt if this token needs approval before fill.", "warning");
|
||||||
|
const result = await runtimeSend({
|
||||||
|
type: "keepassgo-fill-selected-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");
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -12,7 +12,7 @@ const (
|
|||||||
DefaultJavaHome = "/usr/lib/jvm/java-25-openjdk"
|
DefaultJavaHome = "/usr/lib/jvm/java-25-openjdk"
|
||||||
DefaultAppID = "org.julianfamily.keepassgo"
|
DefaultAppID = "org.julianfamily.keepassgo"
|
||||||
DefaultAPKOut = "build/keepassgo.apk"
|
DefaultAPKOut = "build/keepassgo.apk"
|
||||||
DefaultVersion = "0.1.0.1"
|
DefaultVersion = "0.8.2.298"
|
||||||
DefaultLdflags = "-X git.julianfamily.org/keepassgo/internal/appui.appVersion=dev"
|
DefaultLdflags = "-X git.julianfamily.org/keepassgo/internal/appui.appVersion=dev"
|
||||||
DefaultMinSDK = "28"
|
DefaultMinSDK = "28"
|
||||||
DefaultTargetSDK = "35"
|
DefaultTargetSDK = "35"
|
||||||
|
|||||||
@@ -394,6 +394,10 @@ func (s *Server) GetBrowserCredential(ctx context.Context, req *keepassgov1.GetB
|
|||||||
return nil, status.Error(codes.InvalidArgument, "entry url does not match requested page")
|
return nil, status.Error(codes.InvalidArgument, "entry url does not match requested page")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
return s.browserCredential(ctx, token, entry)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) browserCredential(ctx context.Context, token apitokens.Token, entry vault.Entry) (*keepassgov1.GetBrowserCredentialResponse, error) {
|
||||||
if strings.TrimSpace(entry.Username) != "" {
|
if strings.TrimSpace(entry.Username) != "" {
|
||||||
if _, err := s.authorizeResourceRequest(ctx, token, apitokens.OperationCopyUsername, apitokens.Resource{Kind: apitokens.ResourceEntry, EntryID: entry.ID, Path: entry.Path}); err != nil {
|
if _, err := s.authorizeResourceRequest(ctx, token, apitokens.OperationCopyUsername, apitokens.Resource{Kind: apitokens.ResourceEntry, EntryID: entry.ID, Path: entry.Path}); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|||||||
@@ -693,6 +693,40 @@ func TestVaultServiceGetsBrowserCredentialForAuthorizedClients(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestVaultServiceGetsExplicitBrowserCredentialWithoutURLMatch(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
client, _, cleanup := newTestClientForModel(t, vault.Model{
|
||||||
|
Entries: []vault.Entry{
|
||||||
|
{
|
||||||
|
ID: "no-url-entry",
|
||||||
|
Title: "Livingston Console",
|
||||||
|
Username: "livingstondell",
|
||||||
|
Password: "demo-loop",
|
||||||
|
Path: []string{"Root", "Heist Crew"},
|
||||||
|
},
|
||||||
|
testAPITokenEntry(t,
|
||||||
|
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationCopyUsername, Resource: apitokens.Resource{Kind: apitokens.ResourceEntry, EntryID: "no-url-entry", Path: []string{"Root", "Heist Crew"}}},
|
||||||
|
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationCopyPassword, Resource: apitokens.Resource{Kind: apitokens.ResourceEntry, EntryID: "no-url-entry", Path: []string{"Root", "Heist Crew"}}},
|
||||||
|
),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
resp, err := client.GetBrowserCredential(tokenContext(defaultTestTokenSecret), &keepassgov1.GetBrowserCredentialRequest{
|
||||||
|
Id: "no-url-entry",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetBrowserCredential(no-url-entry without page URL) error = %v", err)
|
||||||
|
}
|
||||||
|
if resp.GetId() != "no-url-entry" {
|
||||||
|
t.Fatalf("GetBrowserCredential(no-url-entry without page URL).Id = %q, want no-url-entry", resp.GetId())
|
||||||
|
}
|
||||||
|
if resp.GetPassword() != "demo-loop" {
|
||||||
|
t.Fatalf("GetBrowserCredential(no-url-entry without page URL).Password = %q, want demo-loop", resp.GetPassword())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestVaultServiceRejectsUnauthorizedBrowserCredentialAccess(t *testing.T) {
|
func TestVaultServiceRejectsUnauthorizedBrowserCredentialAccess(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user