Complete browser extension gRPC flow
This commit is contained in:
@@ -25,7 +25,7 @@ ifneq ($(strip $(SIGNPASS)),)
|
|||||||
GOGIO_SIGN_FLAGS += -signpass $(SIGNPASS)
|
GOGIO_SIGN_FLAGS += -signpass $(SIGNPASS)
|
||||||
endif
|
endif
|
||||||
|
|
||||||
.PHONY: apk archlinux-pkgbuild browser-bridge
|
.PHONY: apk archlinux-pkgbuild browser-bridge browser-extension-validate
|
||||||
apk: android/keepassgo-android.jar
|
apk: android/keepassgo-android.jar
|
||||||
@test -x "$(JAVA_HOME)/bin/java" || { echo "JAVA_HOME must point to a working JDK install"; exit 1; }
|
@test -x "$(JAVA_HOME)/bin/java" || { echo "JAVA_HOME must point to a working JDK install"; exit 1; }
|
||||||
@test -d "$(ANDROID_SDK_ROOT)" || { echo "ANDROID_SDK_ROOT must point to an Android SDK install"; exit 1; }
|
@test -d "$(ANDROID_SDK_ROOT)" || { echo "ANDROID_SDK_ROOT must point to an Android SDK install"; exit 1; }
|
||||||
@@ -71,3 +71,9 @@ archlinux-pkgbuild: $(ARCH_PKG_TMPL) Makefile
|
|||||||
|
|
||||||
browser-bridge:
|
browser-bridge:
|
||||||
go build ./cmd/keepassgo-browser-bridge
|
go build ./cmd/keepassgo-browser-bridge
|
||||||
|
|
||||||
|
browser-extension-validate:
|
||||||
|
@command -v xvfb-run >/dev/null 2>&1 || { echo "xvfb-run is required"; exit 1; }
|
||||||
|
@command -v firefox >/dev/null 2>&1 || { echo "firefox is required"; exit 1; }
|
||||||
|
@command -v openssl >/dev/null 2>&1 || { echo "openssl is required"; exit 1; }
|
||||||
|
xvfb-run -a python scripts/validate_browser_extension.py $(if $(BROWSER),--browser $(BROWSER),)
|
||||||
|
|||||||
@@ -2,10 +2,26 @@
|
|||||||
|
|
||||||
Shared extension assets for Firefox and Chromium-based browsers live here.
|
Shared extension assets for Firefox and Chromium-based browsers live here.
|
||||||
|
|
||||||
|
The Arch package installs this directory under `/usr/share/keepassgo/browser-extension/`. On Linux desktop builds, launching KeePassGO refreshes the user-scoped native messaging manifests for Firefox and for any installed Chrome or Chromium `KeePassGO Browser` extension ids it can discover from browser profiles.
|
||||||
|
|
||||||
- `manifest.firefox.json` uses the fixed Firefox extension id `browser@keepassgo.com`
|
- `manifest.firefox.json` uses the fixed Firefox extension id `browser@keepassgo.com`
|
||||||
- `manifest.chromium.json` is the Chromium/Chrome manifest template
|
- `manifest.chromium.json` is the Chromium/Chrome manifest template
|
||||||
- `background.js` talks to the native messaging host `com.keepassgo.browser`
|
- `background.js` caches per-tab match state, updates the toolbar badge, keeps token-scoped approval state visible, and talks to the native messaging host `com.keepassgo.browser`
|
||||||
- `content.js` fills username and password fields on the current page
|
- `content.js` fills username and password fields on the current page, keeps fills tied to the focused form when possible, and shows inline KeePassGO field affordances when matches exist
|
||||||
- `options.html` stores the local gRPC address and API token in browser extension storage
|
- `options.html` stores the local gRPC address and API token in browser extension storage
|
||||||
|
|
||||||
The extension sends the API token to the native host on each request. The bridge does not store the token on disk.
|
The extension sends the API token to the native host on each request. The bridge does not store the token on disk.
|
||||||
|
|
||||||
|
Quick extension-side checks:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
node --test browser/extension/background.test.cjs browser/extension/content.test.cjs
|
||||||
|
```
|
||||||
|
|
||||||
|
Reproducible Chromium validation:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make browser-extension-validate
|
||||||
|
```
|
||||||
|
|
||||||
|
That command validates Firefox by default. Use `make browser-extension-validate BROWSER=chromium` for the Chromium harness.
|
||||||
|
|||||||
+637
-82
@@ -1,11 +1,22 @@
|
|||||||
const ext = globalThis.browser ?? globalThis.chrome;
|
const ext = globalThis.browser ?? globalThis.chrome;
|
||||||
const nativeHost = "com.keepassgo.browser";
|
const nativeHost = "com.keepassgo.browser";
|
||||||
|
const isNodeTestEnv = typeof module !== "undefined" && module.exports;
|
||||||
|
const usePromiseAPI = typeof globalThis.browser !== "undefined";
|
||||||
const defaultSettings = {
|
const defaultSettings = {
|
||||||
grpcAddress: "",
|
grpcAddress: "",
|
||||||
bearerToken: ""
|
bearerToken: ""
|
||||||
};
|
};
|
||||||
|
const pageStatePrefix = "keepassgo-page-state:";
|
||||||
|
const matchCacheTTL = 30 * 1000;
|
||||||
|
const pendingPollMillis = 1500;
|
||||||
|
const pageStates = new Map();
|
||||||
|
const refreshJobs = new Map();
|
||||||
|
const pendingPollers = new Map();
|
||||||
|
|
||||||
function storageGet(keys) {
|
function storageGet(keys) {
|
||||||
|
if (usePromiseAPI) {
|
||||||
|
return ext.storage.local.get(keys);
|
||||||
|
}
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
ext.storage.local.get(keys, (value) => {
|
ext.storage.local.get(keys, (value) => {
|
||||||
const error = ext.runtime.lastError;
|
const error = ext.runtime.lastError;
|
||||||
@@ -19,6 +30,9 @@ function storageGet(keys) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function storageSet(value) {
|
function storageSet(value) {
|
||||||
|
if (usePromiseAPI) {
|
||||||
|
return ext.storage.local.set(value);
|
||||||
|
}
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
ext.storage.local.set(value, () => {
|
ext.storage.local.set(value, () => {
|
||||||
const error = ext.runtime.lastError;
|
const error = ext.runtime.lastError;
|
||||||
@@ -31,7 +45,74 @@ function storageSet(value) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function sessionArea() {
|
||||||
|
return ext.storage?.session ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sessionStorageGet(keys) {
|
||||||
|
const area = sessionArea();
|
||||||
|
if (!area) {
|
||||||
|
return Promise.resolve({});
|
||||||
|
}
|
||||||
|
if (usePromiseAPI) {
|
||||||
|
return area.get(keys).then((value) => value || {});
|
||||||
|
}
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
area.get(keys, (value) => {
|
||||||
|
const error = ext.runtime.lastError;
|
||||||
|
if (error) {
|
||||||
|
reject(new Error(error.message));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
resolve(value || {});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function sessionStorageSet(value) {
|
||||||
|
const area = sessionArea();
|
||||||
|
if (!area) {
|
||||||
|
return Promise.resolve();
|
||||||
|
}
|
||||||
|
if (usePromiseAPI) {
|
||||||
|
return area.set(value);
|
||||||
|
}
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
area.set(value, () => {
|
||||||
|
const error = ext.runtime.lastError;
|
||||||
|
if (error) {
|
||||||
|
reject(new Error(error.message));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function sessionStorageRemove(keys) {
|
||||||
|
const area = sessionArea();
|
||||||
|
if (!area) {
|
||||||
|
return Promise.resolve();
|
||||||
|
}
|
||||||
|
if (usePromiseAPI) {
|
||||||
|
return area.remove(keys);
|
||||||
|
}
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
area.remove(keys, () => {
|
||||||
|
const error = ext.runtime.lastError;
|
||||||
|
if (error) {
|
||||||
|
reject(new Error(error.message));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function tabsQuery(query) {
|
function tabsQuery(query) {
|
||||||
|
if (usePromiseAPI) {
|
||||||
|
return ext.tabs.query(query);
|
||||||
|
}
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
ext.tabs.query(query, (tabs) => {
|
ext.tabs.query(query, (tabs) => {
|
||||||
const error = ext.runtime.lastError;
|
const error = ext.runtime.lastError;
|
||||||
@@ -44,7 +125,26 @@ function tabsQuery(query) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function tabsGet(tabId) {
|
||||||
|
if (usePromiseAPI) {
|
||||||
|
return ext.tabs.get(tabId);
|
||||||
|
}
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
ext.tabs.get(tabId, (tab) => {
|
||||||
|
const error = ext.runtime.lastError;
|
||||||
|
if (error) {
|
||||||
|
reject(new Error(error.message));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
resolve(tab);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function tabsSendMessage(tabId, message) {
|
function tabsSendMessage(tabId, message) {
|
||||||
|
if (usePromiseAPI) {
|
||||||
|
return ext.tabs.sendMessage(tabId, message);
|
||||||
|
}
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
ext.tabs.sendMessage(tabId, message, (response) => {
|
ext.tabs.sendMessage(tabId, message, (response) => {
|
||||||
const error = ext.runtime.lastError;
|
const error = ext.runtime.lastError;
|
||||||
@@ -58,6 +158,9 @@ function tabsSendMessage(tabId, message) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function connectNative(message) {
|
function connectNative(message) {
|
||||||
|
if (usePromiseAPI) {
|
||||||
|
return ext.runtime.sendNativeMessage(nativeHost, message);
|
||||||
|
}
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
ext.runtime.sendNativeMessage(nativeHost, message, (response) => {
|
ext.runtime.sendNativeMessage(nativeHost, message, (response) => {
|
||||||
const error = ext.runtime.lastError;
|
const error = ext.runtime.lastError;
|
||||||
@@ -74,122 +177,574 @@ async function loadSettings() {
|
|||||||
const stored = await storageGet(["grpcAddress", "bearerToken"]);
|
const stored = await storageGet(["grpcAddress", "bearerToken"]);
|
||||||
return {
|
return {
|
||||||
grpcAddress: (stored.grpcAddress || defaultSettings.grpcAddress).trim(),
|
grpcAddress: (stored.grpcAddress || defaultSettings.grpcAddress).trim(),
|
||||||
bearerToken: (stored.bearerToken || "").trim()
|
bearerToken: (stored.bearerToken || defaultSettings.bearerToken).trim()
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function supportsPageStateURL(rawURL) {
|
||||||
|
return typeof rawURL === "string" && /^https?:\/\//i.test(rawURL);
|
||||||
|
}
|
||||||
|
|
||||||
|
function pageStateKey(tabId) {
|
||||||
|
return `${pageStatePrefix}${String(tabId)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function cloneTarget(target) {
|
||||||
|
return target && typeof target === "object" ? { ...target } : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizePageState(state) {
|
||||||
|
return {
|
||||||
|
tabId: Number.isInteger(state?.tabId) ? state.tabId : null,
|
||||||
|
pageUrl: typeof state?.pageUrl === "string" ? state.pageUrl : "",
|
||||||
|
configured: Boolean(state?.configured),
|
||||||
|
success: state?.success !== false,
|
||||||
|
status: state?.status ?? null,
|
||||||
|
matches: Array.isArray(state?.matches) ? state.matches : [],
|
||||||
|
error: typeof state?.error === "string" ? state.error : "",
|
||||||
|
pageHasLoginForm: Boolean(state?.pageHasLoginForm),
|
||||||
|
signature: typeof state?.signature === "string" ? state.signature : "",
|
||||||
|
focusTarget: cloneTarget(state?.focusTarget),
|
||||||
|
pendingFill: Boolean(state?.pendingFill),
|
||||||
|
pendingEntryId: typeof state?.pendingEntryId === "string" ? state.pendingEntryId : "",
|
||||||
|
pendingTarget: cloneTarget(state?.pendingTarget),
|
||||||
|
pendingMessage: typeof state?.pendingMessage === "string" ? state.pendingMessage : "",
|
||||||
|
lastFilledEntryId: typeof state?.lastFilledEntryId === "string" ? state.lastFilledEntryId : "",
|
||||||
|
updatedAt: Number.isFinite(state?.updatedAt) ? state.updatedAt : 0
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function defaultPageState(tabId, pageUrl) {
|
||||||
|
return normalizePageState({
|
||||||
|
tabId,
|
||||||
|
pageUrl,
|
||||||
|
configured: true,
|
||||||
|
success: true,
|
||||||
|
status: null,
|
||||||
|
matches: [],
|
||||||
|
error: "",
|
||||||
|
pageHasLoginForm: false,
|
||||||
|
signature: "",
|
||||||
|
focusTarget: null,
|
||||||
|
pendingFill: false,
|
||||||
|
pendingEntryId: "",
|
||||||
|
pendingTarget: null,
|
||||||
|
pendingMessage: "",
|
||||||
|
lastFilledEntryId: "",
|
||||||
|
updatedAt: 0
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getPageState(tabId, pageUrl) {
|
||||||
|
if (!Number.isInteger(tabId)) {
|
||||||
|
return defaultPageState(null, pageUrl || "");
|
||||||
|
}
|
||||||
|
const existing = pageStates.get(tabId);
|
||||||
|
if (existing && (!pageUrl || existing.pageUrl === pageUrl)) {
|
||||||
|
return normalizePageState(existing);
|
||||||
|
}
|
||||||
|
const stored = await sessionStorageGet(pageStateKey(tabId));
|
||||||
|
const state = normalizePageState(stored[pageStateKey(tabId)] || defaultPageState(tabId, pageUrl || ""));
|
||||||
|
if (pageUrl && state.pageUrl !== pageUrl) {
|
||||||
|
return defaultPageState(tabId, pageUrl);
|
||||||
|
}
|
||||||
|
pageStates.set(tabId, state);
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function setPageState(tabId, nextState) {
|
||||||
|
const state = normalizePageState({ ...nextState, tabId });
|
||||||
|
if (!Number.isInteger(tabId)) {
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
pageStates.set(tabId, state);
|
||||||
|
await sessionStorageSet({ [pageStateKey(tabId)]: state });
|
||||||
|
await updateActionState(tabId, state);
|
||||||
|
await notifyContentState(tabId, state);
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearPendingPoll(tabId) {
|
||||||
|
const timer = pendingPollers.get(tabId);
|
||||||
|
if (timer !== undefined) {
|
||||||
|
clearTimeout(timer);
|
||||||
|
pendingPollers.delete(tabId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function clearPageState(tabId) {
|
||||||
|
if (!Number.isInteger(tabId)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
pageStates.delete(tabId);
|
||||||
|
refreshJobs.delete(tabId);
|
||||||
|
clearPendingPoll(tabId);
|
||||||
|
await sessionStorageRemove(pageStateKey(tabId));
|
||||||
|
await clearActionState(tabId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function describeError(error) {
|
||||||
|
return error instanceof Error ? error.message : String(error || "Unknown error");
|
||||||
|
}
|
||||||
|
|
||||||
|
function approvalHintForState(state) {
|
||||||
|
if (!state.pendingFill) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
return state.pendingMessage || "Approve or deny the fill request in KeePassGO.";
|
||||||
|
}
|
||||||
|
|
||||||
|
function schedulePendingPoll(tabId, pageUrl) {
|
||||||
|
if (!Number.isInteger(tabId)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
clearPendingPoll(tabId);
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
pendingPollers.delete(tabId);
|
||||||
|
void refreshPageState(tabId, pageUrl, { force: true }).catch(() => null);
|
||||||
|
}, pendingPollMillis);
|
||||||
|
pendingPollers.set(tabId, timer);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function notifyContentState(tabId, state) {
|
||||||
|
if (!Number.isInteger(tabId)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await tabsSendMessage(tabId, {
|
||||||
|
type: "keepassgo-page-state",
|
||||||
|
state
|
||||||
|
});
|
||||||
|
} catch (_error) {
|
||||||
|
// Ignore pages without a ready content script.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function clearActionState(tabId) {
|
||||||
|
if (!Number.isInteger(tabId) || !ext.action) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await Promise.allSettled([
|
||||||
|
ext.action.setBadgeText({ tabId, text: "" }),
|
||||||
|
ext.action.setTitle({ tabId, title: "KeePassGO Browser" })
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function actionPresentationForState(state) {
|
||||||
|
let badgeText = "";
|
||||||
|
let title = "KeePassGO Browser";
|
||||||
|
let color = "#255f4a";
|
||||||
|
|
||||||
|
if (state.pendingFill) {
|
||||||
|
badgeText = "!";
|
||||||
|
color = "#9f5f0e";
|
||||||
|
title = approvalHintForState(state) || "KeePassGO approval needed for this page";
|
||||||
|
} else if (!state.configured) {
|
||||||
|
title = "Configure KeePassGO Browser in extension settings";
|
||||||
|
} else if (!state.success) {
|
||||||
|
badgeText = "!";
|
||||||
|
color = "#9f2f2f";
|
||||||
|
title = state.error || "KeePassGO is unavailable for this page";
|
||||||
|
} else if (state.status?.locked) {
|
||||||
|
title = "Unlock KeePassGO to fill this page";
|
||||||
|
} else if (state.pageHasLoginForm && state.matches.length > 0) {
|
||||||
|
badgeText = String(Math.min(state.matches.length, 9));
|
||||||
|
title = `KeePassGO found ${state.matches.length} matching entr${state.matches.length === 1 ? "y" : "ies"} on this page`;
|
||||||
|
} else if (state.pageHasLoginForm) {
|
||||||
|
title = "KeePassGO found no matching entries on this page";
|
||||||
|
}
|
||||||
|
|
||||||
|
return { badgeText, title, color };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateActionState(tabId, state) {
|
||||||
|
if (!Number.isInteger(tabId) || !ext.action) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const presentation = actionPresentationForState(state);
|
||||||
|
await Promise.allSettled([
|
||||||
|
ext.action.setBadgeText({ tabId, text: presentation.badgeText }),
|
||||||
|
ext.action.setBadgeBackgroundColor({ tabId, color: presentation.color }),
|
||||||
|
ext.action.setTitle({ tabId, title: presentation.title })
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
async function activePageContext() {
|
async function activePageContext() {
|
||||||
const [tab] = await tabsQuery({ active: true, currentWindow: true });
|
const [tab] = await tabsQuery({ active: true, currentWindow: true });
|
||||||
return {
|
return {
|
||||||
tabId: tab?.id ?? null,
|
tabId: Number.isInteger(tab?.id) ? tab.id : null,
|
||||||
url: typeof tab?.url === "string" ? tab.url : ""
|
url: typeof tab?.url === "string" ? tab.url : ""
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async function statusForPage() {
|
async function scanTabForLoginForm(tabId) {
|
||||||
const settings = await loadSettings();
|
if (!Number.isInteger(tabId)) {
|
||||||
const page = await activePageContext();
|
return { pageHasLoginForm: false, focusTarget: null, signature: "" };
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const response = await tabsSendMessage(tabId, { type: "keepassgo-page-scan" });
|
||||||
|
return {
|
||||||
|
pageHasLoginForm: Boolean(response?.pageHasLoginForm),
|
||||||
|
focusTarget: cloneTarget(response?.focusTarget),
|
||||||
|
signature: typeof response?.signature === "string" ? response.signature : ""
|
||||||
|
};
|
||||||
|
} catch (_error) {
|
||||||
|
return { pageHasLoginForm: false, focusTarget: null, signature: "" };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function shouldReuseMatches(state, force) {
|
||||||
|
if (force || state.pendingFill) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!state.pageHasLoginForm || !Array.isArray(state.matches)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return Date.now() - (state.updatedAt || 0) < matchCacheTTL;
|
||||||
|
}
|
||||||
|
|
||||||
|
function tokenPendingApprovalCount(status) {
|
||||||
|
return Number(status?.tokenPendingApprovalCount || 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchStatus(settings) {
|
||||||
if (!settings.bearerToken) {
|
if (!settings.bearerToken) {
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
configured: false,
|
configured: false,
|
||||||
status: null,
|
status: null,
|
||||||
pageUrl: page.url,
|
|
||||||
matches: [],
|
|
||||||
error: "Set an API token in extension settings."
|
error: "Set an API token in extension settings."
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const status = await connectNative({
|
const status = await connectNative({
|
||||||
action: "status",
|
action: "status",
|
||||||
grpcAddress: settings.grpcAddress,
|
grpcAddress: settings.grpcAddress,
|
||||||
bearerToken: settings.bearerToken
|
bearerToken: settings.bearerToken
|
||||||
});
|
});
|
||||||
if (!status.success || status.status?.locked || !page.url.startsWith("http")) {
|
return {
|
||||||
return {
|
success: Boolean(status?.success),
|
||||||
success: status.success,
|
configured: true,
|
||||||
|
status: status?.status ?? null,
|
||||||
|
error: status?.error ?? ""
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshPageState(tabId, pageUrl, options = {}) {
|
||||||
|
if (!Number.isInteger(tabId)) {
|
||||||
|
return defaultPageState(null, pageUrl || "");
|
||||||
|
}
|
||||||
|
const force = Boolean(options.force);
|
||||||
|
const existingJob = refreshJobs.get(tabId);
|
||||||
|
if (existingJob && !force) {
|
||||||
|
return existingJob;
|
||||||
|
}
|
||||||
|
|
||||||
|
const job = (async () => {
|
||||||
|
let resolvedURL = typeof pageUrl === "string" ? pageUrl : "";
|
||||||
|
if (!supportsPageStateURL(resolvedURL)) {
|
||||||
|
const tab = await tabsGet(tabId).catch(() => null);
|
||||||
|
resolvedURL = typeof tab?.url === "string" ? tab.url : resolvedURL;
|
||||||
|
}
|
||||||
|
if (!supportsPageStateURL(resolvedURL)) {
|
||||||
|
await clearPageState(tabId);
|
||||||
|
return defaultPageState(tabId, resolvedURL || "");
|
||||||
|
}
|
||||||
|
|
||||||
|
let state = await getPageState(tabId, resolvedURL);
|
||||||
|
if (state.pageUrl !== resolvedURL) {
|
||||||
|
state = defaultPageState(tabId, resolvedURL);
|
||||||
|
}
|
||||||
|
|
||||||
|
const scan = typeof options.pageHasLoginForm === "boolean"
|
||||||
|
? {
|
||||||
|
pageHasLoginForm: options.pageHasLoginForm,
|
||||||
|
focusTarget: cloneTarget(options.focusTarget) || state.focusTarget,
|
||||||
|
signature: typeof options.signature === "string" ? options.signature : state.signature
|
||||||
|
}
|
||||||
|
: await scanTabForLoginForm(tabId);
|
||||||
|
|
||||||
|
state = {
|
||||||
|
...state,
|
||||||
|
pageUrl: resolvedURL,
|
||||||
|
pageHasLoginForm: scan.pageHasLoginForm,
|
||||||
|
focusTarget: cloneTarget(scan.focusTarget) || state.focusTarget,
|
||||||
|
signature: typeof scan.signature === "string" ? scan.signature : state.signature
|
||||||
|
};
|
||||||
|
|
||||||
|
const settings = await loadSettings();
|
||||||
|
const statusInfo = await fetchStatus(settings).catch((error) => ({
|
||||||
|
success: false,
|
||||||
configured: true,
|
configured: true,
|
||||||
status: status.status ?? null,
|
status: null,
|
||||||
pageUrl: page.url,
|
error: describeError(error)
|
||||||
matches: [],
|
}));
|
||||||
error: status.error ?? ""
|
|
||||||
|
state = {
|
||||||
|
...state,
|
||||||
|
configured: statusInfo.configured,
|
||||||
|
success: statusInfo.success,
|
||||||
|
status: statusInfo.status,
|
||||||
|
pendingFill: state.pendingFill || tokenPendingApprovalCount(statusInfo.status) > 0,
|
||||||
|
pendingMessage: tokenPendingApprovalCount(statusInfo.status) > 0
|
||||||
|
? approvalHintForState(state) || "Approve or deny the browser fill request in KeePassGO."
|
||||||
|
: "",
|
||||||
|
error: statusInfo.error
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!statusInfo.configured || !statusInfo.success || statusInfo.status?.locked || !state.pageHasLoginForm) {
|
||||||
|
state.matches = [];
|
||||||
|
state.updatedAt = Date.now();
|
||||||
|
const saved = await setPageState(tabId, state);
|
||||||
|
if (saved.pendingFill) {
|
||||||
|
schedulePendingPoll(tabId, resolvedURL);
|
||||||
|
} else {
|
||||||
|
clearPendingPoll(tabId);
|
||||||
|
}
|
||||||
|
return saved;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (shouldReuseMatches(state, force)) {
|
||||||
|
const saved = await setPageState(tabId, state);
|
||||||
|
if (saved.pendingFill) {
|
||||||
|
schedulePendingPoll(tabId, resolvedURL);
|
||||||
|
} else {
|
||||||
|
clearPendingPoll(tabId);
|
||||||
|
}
|
||||||
|
return saved;
|
||||||
|
}
|
||||||
|
|
||||||
|
const matches = await connectNative({
|
||||||
|
action: "find-logins",
|
||||||
|
grpcAddress: settings.grpcAddress,
|
||||||
|
bearerToken: settings.bearerToken,
|
||||||
|
url: resolvedURL
|
||||||
|
});
|
||||||
|
|
||||||
|
state = {
|
||||||
|
...state,
|
||||||
|
success: Boolean(matches?.success),
|
||||||
|
status: matches?.status ?? state.status,
|
||||||
|
pendingFill: state.pendingFill || tokenPendingApprovalCount(matches?.status ?? state.status) > 0,
|
||||||
|
pendingMessage: tokenPendingApprovalCount(matches?.status ?? state.status) > 0
|
||||||
|
? approvalHintForState(state) || "Approve or deny the browser fill request in KeePassGO."
|
||||||
|
: "",
|
||||||
|
matches: Array.isArray(matches?.matches) ? matches.matches : [],
|
||||||
|
error: matches?.error ?? "",
|
||||||
|
updatedAt: Date.now()
|
||||||
|
};
|
||||||
|
const saved = await setPageState(tabId, state);
|
||||||
|
if (saved.pendingFill) {
|
||||||
|
schedulePendingPoll(tabId, resolvedURL);
|
||||||
|
} else {
|
||||||
|
clearPendingPoll(tabId);
|
||||||
|
}
|
||||||
|
return saved;
|
||||||
|
})().finally(() => {
|
||||||
|
if (refreshJobs.get(tabId) === job) {
|
||||||
|
refreshJobs.delete(tabId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
refreshJobs.set(tabId, job);
|
||||||
|
return job;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function statusForPage(options = {}) {
|
||||||
|
let page = await activePageContext();
|
||||||
|
if (Number.isInteger(options.tabId)) {
|
||||||
|
const tab = await tabsGet(options.tabId).catch(() => null);
|
||||||
|
page = {
|
||||||
|
tabId: options.tabId,
|
||||||
|
url: typeof tab?.url === "string" ? tab.url : ""
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
if (page.tabId == null) {
|
||||||
const matches = await connectNative({
|
return defaultPageState(null, page.url);
|
||||||
action: "find-logins",
|
}
|
||||||
grpcAddress: settings.grpcAddress,
|
if (!options.force) {
|
||||||
bearerToken: settings.bearerToken,
|
const cached = await getPageState(page.tabId, page.url);
|
||||||
url: page.url
|
if (cached.pageUrl === page.url && cached.updatedAt && !cached.pendingFill) {
|
||||||
});
|
return cached;
|
||||||
return {
|
}
|
||||||
success: matches.success,
|
}
|
||||||
configured: true,
|
return refreshPageState(page.tabId, page.url, options);
|
||||||
status: matches.status ?? status.status ?? null,
|
|
||||||
pageUrl: page.url,
|
|
||||||
matches: matches.matches ?? [],
|
|
||||||
error: matches.error ?? ""
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fillLogin(entryId) {
|
async function fillLogin(tabId, entryId) {
|
||||||
const settings = await loadSettings();
|
if (!Number.isInteger(tabId)) {
|
||||||
const page = await activePageContext();
|
|
||||||
if (!settings.bearerToken) {
|
|
||||||
throw new Error("API token is not configured.");
|
|
||||||
}
|
|
||||||
if (page.tabId == null) {
|
|
||||||
throw new Error("No active tab is available.");
|
throw new Error("No active tab is available.");
|
||||||
}
|
}
|
||||||
|
const tab = await tabsGet(tabId);
|
||||||
const response = await connectNative({
|
const pageUrl = typeof tab?.url === "string" ? tab.url : "";
|
||||||
action: "get-login",
|
if (!supportsPageStateURL(pageUrl)) {
|
||||||
grpcAddress: settings.grpcAddress,
|
throw new Error("This page cannot be filled.");
|
||||||
bearerToken: settings.bearerToken,
|
|
||||||
entryId,
|
|
||||||
url: page.url
|
|
||||||
});
|
|
||||||
if (!response.success || !response.credential) {
|
|
||||||
throw new Error(response.error || "KeePassGO did not return a credential.");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const fillResponse = await tabsSendMessage(page.tabId, {
|
let state = await getPageState(tabId, pageUrl);
|
||||||
type: "keepassgo-fill-credential",
|
state = await setPageState(tabId, {
|
||||||
credential: response.credential
|
...state,
|
||||||
|
pageUrl,
|
||||||
|
pendingFill: true,
|
||||||
|
pendingEntryId: String(entryId || "").trim(),
|
||||||
|
pendingTarget: cloneTarget(state.focusTarget),
|
||||||
|
pendingMessage: "Approve or deny the browser fill request in KeePassGO.",
|
||||||
|
error: "",
|
||||||
|
updatedAt: Date.now()
|
||||||
});
|
});
|
||||||
if (!fillResponse?.ok) {
|
schedulePendingPoll(tabId, pageUrl);
|
||||||
throw new Error(fillResponse?.error || "The current page could not be filled.");
|
|
||||||
|
try {
|
||||||
|
const settings = await loadSettings();
|
||||||
|
if (!settings.bearerToken) {
|
||||||
|
throw new Error("API token is not configured.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await connectNative({
|
||||||
|
action: "get-login",
|
||||||
|
grpcAddress: settings.grpcAddress,
|
||||||
|
bearerToken: settings.bearerToken,
|
||||||
|
entryId,
|
||||||
|
url: pageUrl
|
||||||
|
});
|
||||||
|
if (!response?.success || !response.credential) {
|
||||||
|
throw new Error(response?.error || "KeePassGO did not return a credential.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const fillResponse = await tabsSendMessage(tabId, {
|
||||||
|
type: "keepassgo-fill-credential",
|
||||||
|
credential: response.credential,
|
||||||
|
target: state.pendingTarget
|
||||||
|
});
|
||||||
|
if (!fillResponse?.ok) {
|
||||||
|
throw new Error(fillResponse?.error || "The current page could not be filled.");
|
||||||
|
}
|
||||||
|
|
||||||
|
state = await setPageState(tabId, {
|
||||||
|
...state,
|
||||||
|
pendingFill: false,
|
||||||
|
pendingEntryId: "",
|
||||||
|
pendingTarget: null,
|
||||||
|
pendingMessage: "",
|
||||||
|
lastFilledEntryId: String(entryId || "").trim(),
|
||||||
|
error: "",
|
||||||
|
updatedAt: Date.now()
|
||||||
|
});
|
||||||
|
clearPendingPoll(tabId);
|
||||||
|
return {
|
||||||
|
credential: response.credential,
|
||||||
|
pageUrl,
|
||||||
|
state
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
state = await setPageState(tabId, {
|
||||||
|
...state,
|
||||||
|
pendingFill: false,
|
||||||
|
pendingEntryId: "",
|
||||||
|
pendingTarget: null,
|
||||||
|
pendingMessage: "",
|
||||||
|
error: describeError(error),
|
||||||
|
updatedAt: Date.now()
|
||||||
|
});
|
||||||
|
clearPendingPoll(tabId);
|
||||||
|
throw error;
|
||||||
}
|
}
|
||||||
return {
|
|
||||||
credential: response.credential,
|
|
||||||
pageUrl: page.url
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
ext.runtime.onMessage.addListener((message, _sender, sendResponse) => {
|
async function refreshActivePage(options = {}) {
|
||||||
(async () => {
|
const page = await activePageContext();
|
||||||
switch (message?.type) {
|
if (page.tabId == null) {
|
||||||
case "keepassgo-popup-state":
|
return defaultPageState(null, page.url);
|
||||||
sendResponse(await statusForPage());
|
}
|
||||||
return;
|
return refreshPageState(page.tabId, page.url, options);
|
||||||
case "keepassgo-fill-entry":
|
}
|
||||||
sendResponse({ success: true, ...(await fillLogin(message.entryId)) });
|
|
||||||
return;
|
const backgroundTestExports = {
|
||||||
case "keepassgo-load-settings":
|
normalizePageState,
|
||||||
sendResponse({ success: true, settings: await loadSettings() });
|
actionPresentationForState,
|
||||||
return;
|
shouldReuseMatches,
|
||||||
case "keepassgo-save-settings":
|
tokenPendingApprovalCount,
|
||||||
await storageSet({
|
defaultSettings
|
||||||
grpcAddress: String(message.settings?.grpcAddress || defaultSettings.grpcAddress).trim(),
|
};
|
||||||
bearerToken: String(message.settings?.bearerToken || "").trim()
|
|
||||||
});
|
if (isNodeTestEnv) {
|
||||||
sendResponse({ success: true });
|
module.exports = backgroundTestExports;
|
||||||
return;
|
} else {
|
||||||
default:
|
ext.runtime.onMessage.addListener((message, sender, sendResponse) => {
|
||||||
sendResponse({ success: false, error: `Unsupported message ${message?.type || ""}`.trim() });
|
(async () => {
|
||||||
}
|
switch (message?.type) {
|
||||||
})().catch((error) => {
|
case "keepassgo-popup-state":
|
||||||
sendResponse({ success: false, error: error instanceof Error ? error.message : String(error) });
|
sendResponse(await statusForPage({
|
||||||
|
force: Boolean(message.force),
|
||||||
|
tabId: Number.isInteger(message?.tabId) ? message.tabId : null
|
||||||
|
}));
|
||||||
|
return;
|
||||||
|
case "keepassgo-fill-entry": {
|
||||||
|
const targetTabID = Number.isInteger(message?.tabId)
|
||||||
|
? message.tabId
|
||||||
|
: (Number.isInteger(sender?.tab?.id) ? sender.tab.id : (await activePageContext()).tabId);
|
||||||
|
if (Number.isInteger(targetTabID) && message.target) {
|
||||||
|
const targetState = await getPageState(targetTabID, "");
|
||||||
|
await setPageState(targetTabID, {
|
||||||
|
...targetState,
|
||||||
|
focusTarget: cloneTarget(message.target)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
sendResponse({ success: true, ...(await fillLogin(targetTabID, message.entryId)) });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
case "keepassgo-load-settings":
|
||||||
|
sendResponse({ success: true, settings: await loadSettings() });
|
||||||
|
return;
|
||||||
|
case "keepassgo-save-settings":
|
||||||
|
await storageSet({
|
||||||
|
grpcAddress: String(message.settings?.grpcAddress || defaultSettings.grpcAddress).trim(),
|
||||||
|
bearerToken: String(message.settings?.bearerToken || "").trim()
|
||||||
|
});
|
||||||
|
await refreshActivePage({ force: true }).catch(() => null);
|
||||||
|
sendResponse({ success: true });
|
||||||
|
return;
|
||||||
|
case "keepassgo-page-ready":
|
||||||
|
if (Number.isInteger(sender?.tab?.id)) {
|
||||||
|
sendResponse(await refreshPageState(sender.tab.id, sender.tab.url, {
|
||||||
|
force: Boolean(message.force),
|
||||||
|
pageHasLoginForm: Boolean(message.pageHasLoginForm),
|
||||||
|
focusTarget: cloneTarget(message.focusTarget),
|
||||||
|
signature: typeof message.signature === "string" ? message.signature : ""
|
||||||
|
}));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
sendResponse(defaultPageState(null, ""));
|
||||||
|
return;
|
||||||
|
case "keepassgo-refresh-page-state":
|
||||||
|
if (Number.isInteger(sender?.tab?.id)) {
|
||||||
|
sendResponse(await refreshPageState(sender.tab.id, sender.tab.url, { force: true }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
sendResponse(defaultPageState(null, ""));
|
||||||
|
return;
|
||||||
|
default:
|
||||||
|
sendResponse({ success: false, error: `Unsupported message ${message?.type || ""}`.trim() });
|
||||||
|
}
|
||||||
|
})().catch((error) => {
|
||||||
|
sendResponse({ success: false, error: describeError(error) });
|
||||||
|
});
|
||||||
|
return true;
|
||||||
});
|
});
|
||||||
return true;
|
|
||||||
});
|
ext.tabs?.onActivated?.addListener(({ tabId }) => {
|
||||||
|
void refreshPageState(tabId, "", { force: false }).catch(() => null);
|
||||||
|
});
|
||||||
|
|
||||||
|
ext.tabs?.onUpdated?.addListener((tabId, changeInfo, tab) => {
|
||||||
|
if (typeof changeInfo.url === "string") {
|
||||||
|
void clearPageState(tabId).catch(() => null);
|
||||||
|
}
|
||||||
|
if (changeInfo.status === "complete") {
|
||||||
|
void refreshPageState(tabId, tab?.url || changeInfo.url || "", { force: false }).catch(() => null);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
ext.tabs?.onRemoved?.addListener((tabId) => {
|
||||||
|
void clearPageState(tabId).catch(() => null);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
const test = require("node:test");
|
||||||
|
const assert = require("node:assert/strict");
|
||||||
|
|
||||||
|
const background = require("./background.js");
|
||||||
|
|
||||||
|
test("normalizePageState preserves focused and pending field targets", () => {
|
||||||
|
const state = background.normalizePageState({
|
||||||
|
tabId: 7,
|
||||||
|
pageUrl: "https://vault.example.invalid/login",
|
||||||
|
focusTarget: { role: "username", formIndex: 0, fieldIndex: 1 },
|
||||||
|
pendingTarget: { role: "password", formIndex: 0, fieldIndex: 2 }
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.deepEqual(state.focusTarget, { role: "username", formIndex: 0, fieldIndex: 1 });
|
||||||
|
assert.deepEqual(state.pendingTarget, { role: "password", formIndex: 0, fieldIndex: 2 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("shouldReuseMatches only reuses recent non-pending page matches", () => {
|
||||||
|
const recentState = {
|
||||||
|
pageHasLoginForm: true,
|
||||||
|
matches: [{ id: "vault-console" }],
|
||||||
|
pendingFill: false,
|
||||||
|
updatedAt: Date.now()
|
||||||
|
};
|
||||||
|
assert.equal(background.shouldReuseMatches(recentState, false), true);
|
||||||
|
|
||||||
|
assert.equal(background.shouldReuseMatches({ ...recentState, pendingFill: true }, false), false);
|
||||||
|
assert.equal(background.shouldReuseMatches({ ...recentState, pageHasLoginForm: false }, false), false);
|
||||||
|
assert.equal(background.shouldReuseMatches(recentState, true), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("actionPresentationForState prioritizes approval visibility", () => {
|
||||||
|
const presentation = background.actionPresentationForState({
|
||||||
|
pendingFill: true,
|
||||||
|
pendingMessage: "Approve the browser fill request in KeePassGO.",
|
||||||
|
configured: true,
|
||||||
|
success: true,
|
||||||
|
pageHasLoginForm: true,
|
||||||
|
matches: [{ id: "vault-console" }]
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(presentation.badgeText, "!");
|
||||||
|
assert.equal(presentation.color, "#9f5f0e");
|
||||||
|
assert.match(presentation.title, /approve/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("tokenPendingApprovalCount reads token-scoped approval state", () => {
|
||||||
|
assert.equal(background.tokenPendingApprovalCount({ tokenPendingApprovalCount: 2 }), 2);
|
||||||
|
assert.equal(background.tokenPendingApprovalCount({}), 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("default settings include a blank bearer token that can be overridden by harness patching", () => {
|
||||||
|
assert.equal(background.defaultSettings.bearerToken, "");
|
||||||
|
});
|
||||||
+615
-26
@@ -1,3 +1,23 @@
|
|||||||
|
const ext = globalThis.browser ?? globalThis.chrome;
|
||||||
|
const isNodeTestEnv = typeof module !== "undefined" && module.exports;
|
||||||
|
const usePromiseAPI = typeof globalThis.browser !== "undefined";
|
||||||
|
|
||||||
|
function runtimeSend(message) {
|
||||||
|
if (usePromiseAPI) {
|
||||||
|
return ext.runtime.sendMessage(message);
|
||||||
|
}
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
ext.runtime.sendMessage(message, (response) => {
|
||||||
|
const error = ext.runtime.lastError;
|
||||||
|
if (error) {
|
||||||
|
reject(new Error(error.message));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
resolve(response);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function isVisibleInput(input) {
|
function isVisibleInput(input) {
|
||||||
if (!(input instanceof HTMLInputElement)) {
|
if (!(input instanceof HTMLInputElement)) {
|
||||||
return false;
|
return false;
|
||||||
@@ -12,6 +32,38 @@ function isVisibleInput(input) {
|
|||||||
return input.offsetParent !== null || style.position === "fixed";
|
return input.offsetParent !== null || style.position === "fixed";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeRole(rawRole) {
|
||||||
|
switch (String(rawRole || "").trim().toLowerCase()) {
|
||||||
|
case "password":
|
||||||
|
return "password";
|
||||||
|
default:
|
||||||
|
return "username";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function describeFieldRole(input) {
|
||||||
|
const type = String(input?.getAttribute?.("type") || "").toLowerCase();
|
||||||
|
if (type === "password") {
|
||||||
|
return "password";
|
||||||
|
}
|
||||||
|
const autocomplete = String(input?.autocomplete || "").toLowerCase();
|
||||||
|
if (autocomplete.includes("username") || autocomplete.includes("email")) {
|
||||||
|
return "username";
|
||||||
|
}
|
||||||
|
return "username";
|
||||||
|
}
|
||||||
|
|
||||||
|
function isUsernameCandidate(input) {
|
||||||
|
if (!isVisibleInput(input)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return describeFieldRole(input) === "username";
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPasswordCandidate(input) {
|
||||||
|
return isVisibleInput(input) && describeFieldRole(input) === "password";
|
||||||
|
}
|
||||||
|
|
||||||
function dispatchFillEvents(input) {
|
function dispatchFillEvents(input) {
|
||||||
if (typeof InputEvent === "function") {
|
if (typeof InputEvent === "function") {
|
||||||
input.dispatchEvent(new InputEvent("input", { bubbles: true, data: input.value, inputType: "insertText" }));
|
input.dispatchEvent(new InputEvent("input", { bubbles: true, data: input.value, inputType: "insertText" }));
|
||||||
@@ -31,31 +83,145 @@ function setInputValue(input, value) {
|
|||||||
input.value = value;
|
input.value = value;
|
||||||
}
|
}
|
||||||
|
|
||||||
function findPasswordInput() {
|
function visibleInputs(scope) {
|
||||||
return Array.from(document.querySelectorAll('input[type="password"]')).find(isVisibleInput) || null;
|
return Array.from(scope.querySelectorAll("input")).filter(isVisibleInput);
|
||||||
}
|
}
|
||||||
|
|
||||||
function findUsernameInput(passwordInput) {
|
function resolveFormInputs(anchorInput) {
|
||||||
const form = passwordInput?.form || null;
|
if (anchorInput?.form instanceof HTMLFormElement) {
|
||||||
|
return visibleInputs(anchorInput.form);
|
||||||
|
}
|
||||||
|
return visibleInputs(document);
|
||||||
|
}
|
||||||
|
|
||||||
|
function firstVisiblePassword(scope) {
|
||||||
|
return visibleInputs(scope).find(isPasswordCandidate) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function firstVisibleUsername(scope) {
|
||||||
|
return visibleInputs(scope).find(isUsernameCandidate) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function associatedFieldsForAnchor(anchorInput) {
|
||||||
|
const scopeInputs = resolveFormInputs(anchorInput);
|
||||||
|
const passwordInput = scopeInputs.find(isPasswordCandidate) || firstVisiblePassword(document);
|
||||||
|
const usernameInScope = scopeInputs.filter(isUsernameCandidate);
|
||||||
|
let usernameInput = usernameInScope[0] || null;
|
||||||
|
if (passwordInput && usernameInScope.length !== 0) {
|
||||||
|
const priorSibling = usernameInScope.find((input) =>
|
||||||
|
typeof input.compareDocumentPosition === "function" &&
|
||||||
|
Boolean(input.compareDocumentPosition(passwordInput) & Node.DOCUMENT_POSITION_FOLLOWING)
|
||||||
|
);
|
||||||
|
usernameInput = priorSibling || usernameInScope[0] || null;
|
||||||
|
}
|
||||||
|
if (!usernameInput) {
|
||||||
|
usernameInput = firstVisibleUsername(document);
|
||||||
|
}
|
||||||
|
return { usernameInput, passwordInput };
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildFieldDescriptor(input, role) {
|
||||||
|
if (!(input instanceof HTMLInputElement)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const normalizedRole = normalizeRole(role || describeFieldRole(input));
|
||||||
|
const form = input.form instanceof HTMLFormElement ? input.form : null;
|
||||||
const scope = form || document;
|
const scope = form || document;
|
||||||
const candidates = Array.from(scope.querySelectorAll('input[type="text"], input[type="email"], input:not([type]), input[autocomplete="username"], input[autocomplete="email"]'))
|
const inputs = visibleInputs(scope);
|
||||||
.filter(isVisibleInput);
|
const fieldIndex = inputs.indexOf(input);
|
||||||
if (passwordInput) {
|
const forms = Array.from(document.forms || []);
|
||||||
const sameForm = candidates.filter((input) => input.form === passwordInput.form);
|
return {
|
||||||
if (sameForm.length !== 0) {
|
role: normalizedRole,
|
||||||
const priorSibling = sameForm.find((input) =>
|
formIndex: form ? forms.indexOf(form) : -1,
|
||||||
typeof input.compareDocumentPosition === "function" &&
|
fieldIndex,
|
||||||
Boolean(input.compareDocumentPosition(passwordInput) & Node.DOCUMENT_POSITION_FOLLOWING)
|
id: String(input.id || ""),
|
||||||
);
|
name: String(input.name || ""),
|
||||||
return priorSibling || sameForm[0];
|
autocomplete: String(input.autocomplete || "").toLowerCase()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveFieldDescriptor(descriptor) {
|
||||||
|
if (!descriptor || typeof descriptor !== "object") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const normalizedRole = normalizeRole(descriptor.role);
|
||||||
|
const forms = Array.from(document.forms || []);
|
||||||
|
const form = Number.isInteger(descriptor.formIndex) && descriptor.formIndex >= 0 ? forms[descriptor.formIndex] || null : null;
|
||||||
|
const scope = form || document;
|
||||||
|
const inputs = visibleInputs(scope);
|
||||||
|
if (Number.isInteger(descriptor.fieldIndex) && descriptor.fieldIndex >= 0 && descriptor.fieldIndex < inputs.length) {
|
||||||
|
const candidate = inputs[descriptor.fieldIndex];
|
||||||
|
if (describeFieldRole(candidate) === normalizedRole) {
|
||||||
|
return candidate;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return candidates[0] || null;
|
if (descriptor.id) {
|
||||||
|
const byID = scope.querySelector(`#${CSS.escape(descriptor.id)}`);
|
||||||
|
if (byID instanceof HTMLInputElement && isVisibleInput(byID) && describeFieldRole(byID) === normalizedRole) {
|
||||||
|
return byID;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (descriptor.name) {
|
||||||
|
const byName = visibleInputs(scope).find((input) => input.name === descriptor.name && describeFieldRole(input) === normalizedRole);
|
||||||
|
if (byName) {
|
||||||
|
return byName;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (normalizedRole === "password") {
|
||||||
|
return firstVisiblePassword(scope) || firstVisiblePassword(document);
|
||||||
|
}
|
||||||
|
return firstVisibleUsername(scope) || firstVisibleUsername(document);
|
||||||
}
|
}
|
||||||
|
|
||||||
function fillCredential(credential) {
|
function chooseFillTargets(targetDescriptor) {
|
||||||
const passwordInput = findPasswordInput();
|
const anchorInput = resolveFieldDescriptor(targetDescriptor) || (document.activeElement instanceof HTMLInputElement ? document.activeElement : null);
|
||||||
const usernameInput = findUsernameInput(passwordInput);
|
const associated = associatedFieldsForAnchor(anchorInput);
|
||||||
|
if (normalizeRole(targetDescriptor?.role) === "password" && anchorInput instanceof HTMLInputElement) {
|
||||||
|
return {
|
||||||
|
usernameInput: associated.usernameInput,
|
||||||
|
passwordInput: isPasswordCandidate(anchorInput) ? anchorInput : associated.passwordInput,
|
||||||
|
anchorInput
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (normalizeRole(targetDescriptor?.role) === "username" && anchorInput instanceof HTMLInputElement) {
|
||||||
|
return {
|
||||||
|
usernameInput: isUsernameCandidate(anchorInput) ? anchorInput : associated.usernameInput,
|
||||||
|
passwordInput: associated.passwordInput,
|
||||||
|
anchorInput
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
usernameInput: associated.usernameInput,
|
||||||
|
passwordInput: associated.passwordInput,
|
||||||
|
anchorInput: anchorInput || associated.passwordInput || associated.usernameInput || null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function scanLoginFields() {
|
||||||
|
const activeElement = document.activeElement instanceof HTMLInputElement ? document.activeElement : null;
|
||||||
|
const activeUsable = activeElement && isVisibleInput(activeElement) ? activeElement : null;
|
||||||
|
const targets = chooseFillTargets(buildFieldDescriptor(activeUsable, describeFieldRole(activeUsable)));
|
||||||
|
const anchorInput = activeUsable || targets.passwordInput || targets.usernameInput;
|
||||||
|
const focusTarget = buildFieldDescriptor(anchorInput, describeFieldRole(anchorInput));
|
||||||
|
const allVisible = visibleInputs(document);
|
||||||
|
const roles = allVisible
|
||||||
|
.filter((input) => isUsernameCandidate(input) || isPasswordCandidate(input))
|
||||||
|
.map((input) => {
|
||||||
|
const descriptor = buildFieldDescriptor(input, describeFieldRole(input));
|
||||||
|
return `${descriptor.formIndex}:${descriptor.fieldIndex}:${descriptor.role}`;
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
pageHasLoginForm: Boolean(targets.usernameInput || targets.passwordInput),
|
||||||
|
usernameInput: targets.usernameInput,
|
||||||
|
passwordInput: targets.passwordInput,
|
||||||
|
anchorInput,
|
||||||
|
focusTarget,
|
||||||
|
signature: roles.join("|")
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function fillCredential(credential, targetDescriptor) {
|
||||||
|
const { passwordInput, usernameInput } = chooseFillTargets(targetDescriptor);
|
||||||
|
|
||||||
if (usernameInput && credential.username) {
|
if (usernameInput && credential.username) {
|
||||||
usernameInput.focus();
|
usernameInput.focus();
|
||||||
@@ -74,14 +240,437 @@ function fillCredential(credential) {
|
|||||||
return { ok: true };
|
return { ok: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
(globalThis.browser ?? globalThis.chrome).runtime.onMessage.addListener((message, _sender, sendResponse) => {
|
function domainLabel(rawURL) {
|
||||||
if (message?.type !== "keepassgo-fill-credential") {
|
try {
|
||||||
|
return new URL(rawURL).host || "";
|
||||||
|
} catch (_error) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function inlineMatchSummary(match) {
|
||||||
|
const parts = [];
|
||||||
|
if (match.username) {
|
||||||
|
parts.push(match.username);
|
||||||
|
}
|
||||||
|
if (match.url) {
|
||||||
|
const host = domainLabel(match.url);
|
||||||
|
if (host) {
|
||||||
|
parts.push(host);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (Array.isArray(match.path) && match.path.length !== 0) {
|
||||||
|
parts.push(match.path.join(" / "));
|
||||||
|
}
|
||||||
|
return parts.join(" · ") || "No username";
|
||||||
|
}
|
||||||
|
|
||||||
|
function shouldShowInlineOverlay(state, hasTarget, suppressed) {
|
||||||
|
if (suppressed || !hasTarget) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
try {
|
return Boolean(
|
||||||
sendResponse(fillCredential(message.credential || {}));
|
state?.pageHasLoginForm &&
|
||||||
} catch (error) {
|
(
|
||||||
sendResponse({ ok: false, error: error instanceof Error ? error.message : String(error) });
|
state?.pendingFill ||
|
||||||
|
(state?.configured && state?.success && !state?.status?.locked && Array.isArray(state?.matches) && state.matches.length > 0)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const contentTestExports = {
|
||||||
|
normalizeRole,
|
||||||
|
describeFieldRole,
|
||||||
|
buildFieldDescriptor,
|
||||||
|
resolveFieldDescriptor,
|
||||||
|
chooseFillTargets,
|
||||||
|
inlineMatchSummary,
|
||||||
|
domainLabel,
|
||||||
|
shouldShowInlineOverlay
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isNodeTestEnv) {
|
||||||
|
module.exports = contentTestExports;
|
||||||
|
} else {
|
||||||
|
let pageState = {
|
||||||
|
configured: true,
|
||||||
|
success: true,
|
||||||
|
matches: [],
|
||||||
|
pageHasLoginForm: false,
|
||||||
|
pendingFill: false,
|
||||||
|
error: "",
|
||||||
|
focusTarget: null
|
||||||
|
};
|
||||||
|
let chooserOpen = false;
|
||||||
|
let inlineSuppressed = false;
|
||||||
|
let refreshTimer = null;
|
||||||
|
let lastReportedSignature = "";
|
||||||
|
let lastReportedTarget = "";
|
||||||
|
|
||||||
|
const root = document.createElement("div");
|
||||||
|
root.id = "keepassgo-inline-root";
|
||||||
|
root.setAttribute("aria-live", "polite");
|
||||||
|
const shadow = root.attachShadow({ mode: "open" });
|
||||||
|
shadow.innerHTML = `
|
||||||
|
<style>
|
||||||
|
:host {
|
||||||
|
all: initial;
|
||||||
|
}
|
||||||
|
.dock {
|
||||||
|
position: fixed;
|
||||||
|
z-index: 2147483647;
|
||||||
|
display: none;
|
||||||
|
min-width: 220px;
|
||||||
|
max-width: min(340px, calc(100vw - 24px));
|
||||||
|
font: 13px/1.35 "Noto Sans", "Liberation Sans", sans-serif;
|
||||||
|
color: #214f44;
|
||||||
|
}
|
||||||
|
.dock[data-open="true"] .panel {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
.trigger {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border: 1px solid #c6d8cf;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: linear-gradient(180deg, #ffffff, #edf5f0);
|
||||||
|
color: #214f44;
|
||||||
|
box-shadow: 0 12px 26px rgba(33, 79, 68, 0.16);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.trigger[data-tone="warning"] {
|
||||||
|
border-color: #e4d0ae;
|
||||||
|
background: linear-gradient(180deg, #fff8ed, #f9edd6);
|
||||||
|
color: #7f4b09;
|
||||||
|
}
|
||||||
|
.trigger[data-tone="error"] {
|
||||||
|
border-color: #e4bcbc;
|
||||||
|
background: linear-gradient(180deg, #fff5f5, #f9e7e7);
|
||||||
|
color: #8c2f2f;
|
||||||
|
}
|
||||||
|
.brand {
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
}
|
||||||
|
.meta {
|
||||||
|
color: #4d6d66;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.panel {
|
||||||
|
display: none;
|
||||||
|
margin-top: 8px;
|
||||||
|
border: 1px solid #d7e3dc;
|
||||||
|
border-radius: 16px;
|
||||||
|
background: #fffdfa;
|
||||||
|
box-shadow: 0 18px 42px rgba(33, 79, 68, 0.22);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.panel-header {
|
||||||
|
padding: 12px 14px 10px;
|
||||||
|
border-bottom: 1px solid #e6efea;
|
||||||
|
background: linear-gradient(180deg, #f8fbf8, #f1f6f3);
|
||||||
|
}
|
||||||
|
.panel-title {
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
.panel-copy {
|
||||||
|
margin-top: 4px;
|
||||||
|
color: #4d6d66;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.match-list {
|
||||||
|
display: grid;
|
||||||
|
gap: 0;
|
||||||
|
max-height: 280px;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
.match {
|
||||||
|
display: grid;
|
||||||
|
gap: 3px;
|
||||||
|
width: 100%;
|
||||||
|
padding: 12px 14px;
|
||||||
|
border: 0;
|
||||||
|
border-top: 1px solid #eef4f0;
|
||||||
|
background: #fffdfa;
|
||||||
|
color: #214f44;
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.match:hover,
|
||||||
|
.match:focus-visible {
|
||||||
|
background: #edf5f0;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
.match strong {
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.pill {
|
||||||
|
display: inline-flex;
|
||||||
|
width: fit-content;
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #e7f1eb;
|
||||||
|
color: #255f4a;
|
||||||
|
font-size: 11px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
}
|
||||||
|
.subtle {
|
||||||
|
color: #4d6d66;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.empty {
|
||||||
|
padding: 12px 14px;
|
||||||
|
color: #4d6d66;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
<div class="dock" data-open="false">
|
||||||
|
<button type="button" class="trigger" data-tone="ready">
|
||||||
|
<span class="brand">KeePassGO</span>
|
||||||
|
<span class="meta">Checking this form</span>
|
||||||
|
</button>
|
||||||
|
<div class="panel">
|
||||||
|
<div class="panel-header">
|
||||||
|
<div class="panel-title">KeePassGO suggestions</div>
|
||||||
|
<div class="panel-copy">Select a matching login for this field.</div>
|
||||||
|
</div>
|
||||||
|
<div class="match-list"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
const dock = shadow.querySelector(".dock");
|
||||||
|
const trigger = shadow.querySelector(".trigger");
|
||||||
|
const meta = shadow.querySelector(".meta");
|
||||||
|
const matchList = shadow.querySelector(".match-list");
|
||||||
|
const panelCopy = shadow.querySelector(".panel-copy");
|
||||||
|
|
||||||
|
function ensureRootMounted() {
|
||||||
|
if (!root.isConnected) {
|
||||||
|
document.documentElement.appendChild(root);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return false;
|
|
||||||
});
|
function currentTarget() {
|
||||||
|
return chooseFillTargets(pageState.focusTarget).anchorInput;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hideDock() {
|
||||||
|
chooserOpen = false;
|
||||||
|
dock.style.display = "none";
|
||||||
|
dock.dataset.open = "false";
|
||||||
|
}
|
||||||
|
|
||||||
|
function positionDock() {
|
||||||
|
const anchor = currentTarget();
|
||||||
|
if (!anchor || dock.style.display === "none") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const rect = anchor.getBoundingClientRect();
|
||||||
|
const width = Math.min(340, Math.max(220, rect.width));
|
||||||
|
const left = Math.min(window.innerWidth - width - 12, Math.max(12, rect.left));
|
||||||
|
const top = Math.min(window.innerHeight - 16, rect.bottom + 8);
|
||||||
|
dock.style.left = `${left}px`;
|
||||||
|
dock.style.top = `${top}px`;
|
||||||
|
dock.style.width = `${width}px`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderMatches() {
|
||||||
|
matchList.textContent = "";
|
||||||
|
if (pageState.pendingFill) {
|
||||||
|
const pending = document.createElement("div");
|
||||||
|
pending.className = "empty";
|
||||||
|
pending.textContent = pageState.pendingMessage || "Approve or deny the fill request in KeePassGO.";
|
||||||
|
matchList.appendChild(pending);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!Array.isArray(pageState.matches) || pageState.matches.length === 0) {
|
||||||
|
const empty = document.createElement("div");
|
||||||
|
empty.className = "empty";
|
||||||
|
empty.textContent = pageState.error || "No matching entries were found for this page.";
|
||||||
|
matchList.appendChild(empty);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const match of pageState.matches) {
|
||||||
|
const row = document.createElement("button");
|
||||||
|
row.type = "button";
|
||||||
|
row.className = "match";
|
||||||
|
const title = document.createElement("strong");
|
||||||
|
title.textContent = match.title;
|
||||||
|
const summary = document.createElement("span");
|
||||||
|
summary.className = "subtle";
|
||||||
|
summary.textContent = inlineMatchSummary(match);
|
||||||
|
const quality = document.createElement("span");
|
||||||
|
quality.className = "pill";
|
||||||
|
quality.textContent = match.quality || "Candidate";
|
||||||
|
row.appendChild(title);
|
||||||
|
row.appendChild(summary);
|
||||||
|
row.appendChild(quality);
|
||||||
|
row.addEventListener("click", async () => {
|
||||||
|
row.disabled = true;
|
||||||
|
inlineSuppressed = true;
|
||||||
|
hideDock();
|
||||||
|
try {
|
||||||
|
await runtimeSend({
|
||||||
|
type: "keepassgo-fill-entry",
|
||||||
|
entryId: match.id,
|
||||||
|
target: pageState.focusTarget
|
||||||
|
});
|
||||||
|
} catch (_error) {
|
||||||
|
pageState = {
|
||||||
|
...pageState,
|
||||||
|
pendingFill: false,
|
||||||
|
error: "KeePassGO could not fill this page."
|
||||||
|
};
|
||||||
|
renderInlineState();
|
||||||
|
} finally {
|
||||||
|
row.disabled = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
matchList.appendChild(row);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderInlineState() {
|
||||||
|
const target = currentTarget();
|
||||||
|
const shouldShow = shouldShowInlineOverlay(pageState, Boolean(target), inlineSuppressed);
|
||||||
|
|
||||||
|
if (!shouldShow) {
|
||||||
|
hideDock();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
ensureRootMounted();
|
||||||
|
dock.style.display = "block";
|
||||||
|
trigger.dataset.tone = pageState.pendingFill ? "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 {
|
||||||
|
const count = Array.isArray(pageState.matches) ? pageState.matches.length : 0;
|
||||||
|
meta.textContent = count === 1 ? "1 login ready" : `${count} logins ready`;
|
||||||
|
panelCopy.textContent = "Select a matching login for this field.";
|
||||||
|
}
|
||||||
|
dock.dataset.open = chooserOpen ? "true" : "false";
|
||||||
|
renderMatches();
|
||||||
|
positionDock();
|
||||||
|
}
|
||||||
|
|
||||||
|
function reportFieldState(force) {
|
||||||
|
const scan = scanLoginFields();
|
||||||
|
pageState = {
|
||||||
|
...pageState,
|
||||||
|
pageHasLoginForm: scan.pageHasLoginForm,
|
||||||
|
focusTarget: scan.focusTarget
|
||||||
|
};
|
||||||
|
renderInlineState();
|
||||||
|
const nextTarget = JSON.stringify(scan.focusTarget || null);
|
||||||
|
if (!force && scan.signature === lastReportedSignature && nextTarget === lastReportedTarget) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
lastReportedSignature = scan.signature;
|
||||||
|
lastReportedTarget = nextTarget;
|
||||||
|
void runtimeSend({
|
||||||
|
type: "keepassgo-page-ready",
|
||||||
|
force: Boolean(force),
|
||||||
|
pageHasLoginForm: scan.pageHasLoginForm,
|
||||||
|
focusTarget: scan.focusTarget,
|
||||||
|
signature: scan.signature
|
||||||
|
}).then((response) => {
|
||||||
|
if (response && typeof response === "object" && !("success" in response && response.success === false)) {
|
||||||
|
pageState = {
|
||||||
|
...pageState,
|
||||||
|
...response,
|
||||||
|
pageHasLoginForm: Boolean(response.pageHasLoginForm),
|
||||||
|
focusTarget: response.focusTarget || pageState.focusTarget
|
||||||
|
};
|
||||||
|
renderInlineState();
|
||||||
|
}
|
||||||
|
}).catch(() => null);
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleRefresh(force) {
|
||||||
|
if (refreshTimer !== null) {
|
||||||
|
clearTimeout(refreshTimer);
|
||||||
|
}
|
||||||
|
refreshTimer = window.setTimeout(() => {
|
||||||
|
refreshTimer = null;
|
||||||
|
reportFieldState(force);
|
||||||
|
}, force ? 0 : 120);
|
||||||
|
}
|
||||||
|
|
||||||
|
trigger.addEventListener("click", () => {
|
||||||
|
chooserOpen = !chooserOpen;
|
||||||
|
renderInlineState();
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener("focusin", () => {
|
||||||
|
scheduleRefresh(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener("input", () => {
|
||||||
|
scheduleRefresh(false);
|
||||||
|
}, true);
|
||||||
|
|
||||||
|
document.addEventListener("click", (event) => {
|
||||||
|
if (!root.contains(event.target)) {
|
||||||
|
chooserOpen = false;
|
||||||
|
renderInlineState();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
window.addEventListener("scroll", () => {
|
||||||
|
positionDock();
|
||||||
|
}, true);
|
||||||
|
|
||||||
|
window.addEventListener("resize", () => {
|
||||||
|
positionDock();
|
||||||
|
});
|
||||||
|
|
||||||
|
const observer = new MutationObserver((records) => {
|
||||||
|
if (records.some((record) => record.type === "childList")) {
|
||||||
|
scheduleRefresh(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
observer.observe(document.documentElement, {
|
||||||
|
childList: true,
|
||||||
|
subtree: true
|
||||||
|
});
|
||||||
|
|
||||||
|
ext.runtime.onMessage.addListener((message, _sender, sendResponse) => {
|
||||||
|
if (message?.type === "keepassgo-fill-credential") {
|
||||||
|
try {
|
||||||
|
sendResponse(fillCredential(message.credential || {}, message.target || pageState.focusTarget));
|
||||||
|
} catch (error) {
|
||||||
|
sendResponse({ ok: false, error: error instanceof Error ? error.message : String(error) });
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (message?.type === "keepassgo-page-state") {
|
||||||
|
pageState = {
|
||||||
|
...pageState,
|
||||||
|
...(message.state || {})
|
||||||
|
};
|
||||||
|
renderInlineState();
|
||||||
|
sendResponse({ ok: true });
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (message?.type === "keepassgo-page-scan") {
|
||||||
|
const scan = scanLoginFields();
|
||||||
|
sendResponse({
|
||||||
|
pageHasLoginForm: scan.pageHasLoginForm,
|
||||||
|
focusTarget: scan.focusTarget,
|
||||||
|
signature: scan.signature
|
||||||
|
});
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
|
||||||
|
reportFieldState(true);
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
const test = require("node:test");
|
||||||
|
const assert = require("node:assert/strict");
|
||||||
|
|
||||||
|
const content = require("./content.js");
|
||||||
|
|
||||||
|
test("inlineMatchSummary includes username, host, and path context", () => {
|
||||||
|
const summary = content.inlineMatchSummary({
|
||||||
|
username: "dannyocean",
|
||||||
|
url: "https://vault.example.invalid/login",
|
||||||
|
path: ["Root", "Crew"]
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(summary, "dannyocean · vault.example.invalid · Root / Crew");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("domainLabel tolerates invalid URLs", () => {
|
||||||
|
assert.equal(content.domainLabel("https://vault.example.invalid"), "vault.example.invalid");
|
||||||
|
assert.equal(content.domainLabel("not-a-url"), "");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("shouldShowInlineOverlay hides the page overlay after it is suppressed", () => {
|
||||||
|
const state = {
|
||||||
|
pageHasLoginForm: true,
|
||||||
|
configured: true,
|
||||||
|
success: true,
|
||||||
|
status: { locked: false },
|
||||||
|
matches: [{ id: "vault-console" }],
|
||||||
|
pendingFill: false
|
||||||
|
};
|
||||||
|
|
||||||
|
assert.equal(content.shouldShowInlineOverlay(state, true, false), true);
|
||||||
|
assert.equal(content.shouldShowInlineOverlay(state, true, true), false);
|
||||||
|
});
|
||||||
@@ -1,15 +1,20 @@
|
|||||||
{
|
{
|
||||||
"manifest_version": 3,
|
"manifest_version": 2,
|
||||||
"name": "KeePassGO Browser",
|
"name": "KeePassGO Browser",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"description": "Fill credentials from KeePassGO over the local gRPC API.",
|
"description": "Fill credentials from KeePassGO over the local gRPC API.",
|
||||||
"permissions": ["activeTab", "nativeMessaging", "storage", "tabs"],
|
"permissions": [
|
||||||
"host_permissions": ["http://*/*", "https://*/*"],
|
"activeTab",
|
||||||
|
"nativeMessaging",
|
||||||
|
"storage",
|
||||||
|
"tabs",
|
||||||
|
"http://*/*",
|
||||||
|
"https://*/*"
|
||||||
|
],
|
||||||
"background": {
|
"background": {
|
||||||
"scripts": ["background.js"],
|
"scripts": ["background.js"]
|
||||||
"service_worker": "background.js"
|
|
||||||
},
|
},
|
||||||
"action": {
|
"browser_action": {
|
||||||
"default_title": "KeePassGO Browser",
|
"default_title": "KeePassGO Browser",
|
||||||
"default_popup": "popup.html"
|
"default_popup": "popup.html"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
const extOptions = globalThis.browser ?? globalThis.chrome;
|
const extOptions = globalThis.browser ?? globalThis.chrome;
|
||||||
|
const usePromiseAPI = typeof globalThis.browser !== "undefined";
|
||||||
|
|
||||||
function runtimeSend(message) {
|
function runtimeSend(message) {
|
||||||
|
if (usePromiseAPI) {
|
||||||
|
return extOptions.runtime.sendMessage(message);
|
||||||
|
}
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
extOptions.runtime.sendMessage(message, (response) => {
|
extOptions.runtime.sendMessage(message, (response) => {
|
||||||
const error = extOptions.runtime.lastError;
|
const error = extOptions.runtime.lastError;
|
||||||
|
|||||||
@@ -19,6 +19,7 @@
|
|||||||
<strong id="status-title">Loading</strong>
|
<strong id="status-title">Loading</strong>
|
||||||
<p id="status-message" class="subtle">Checking KeePassGO.</p>
|
<p id="status-message" class="subtle">Checking KeePassGO.</p>
|
||||||
</section>
|
</section>
|
||||||
|
<p id="page-hint" class="inline-hint subtle">Loading page state.</p>
|
||||||
<section>
|
<section>
|
||||||
<h2>Matches</h2>
|
<h2>Matches</h2>
|
||||||
<div id="matches" class="match-list"></div>
|
<div id="matches" class="match-list"></div>
|
||||||
|
|||||||
+86
-14
@@ -1,6 +1,10 @@
|
|||||||
const extPopup = globalThis.browser ?? globalThis.chrome;
|
const extPopup = globalThis.browser ?? globalThis.chrome;
|
||||||
|
const usePromiseAPI = typeof globalThis.browser !== "undefined";
|
||||||
|
|
||||||
function runtimeSend(message) {
|
function runtimeSend(message) {
|
||||||
|
if (usePromiseAPI) {
|
||||||
|
return extPopup.runtime.sendMessage(message);
|
||||||
|
}
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
extPopup.runtime.sendMessage(message, (response) => {
|
extPopup.runtime.sendMessage(message, (response) => {
|
||||||
const error = extPopup.runtime.lastError;
|
const error = extPopup.runtime.lastError;
|
||||||
@@ -28,13 +32,27 @@ function setStatus(title, message, tone) {
|
|||||||
document.getElementById("status-message").textContent = message;
|
document.getElementById("status-message").textContent = message;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function matchSubtitle(match) {
|
||||||
|
const parts = [];
|
||||||
|
if (match.username) {
|
||||||
|
parts.push(match.username);
|
||||||
|
}
|
||||||
|
if (Array.isArray(match.path) && match.path.length !== 0) {
|
||||||
|
parts.push(match.path.join(" / "));
|
||||||
|
}
|
||||||
|
return parts.join(" · ") || "No username";
|
||||||
|
}
|
||||||
|
|
||||||
function renderMatches(state) {
|
function renderMatches(state) {
|
||||||
const root = document.getElementById("matches");
|
const root = document.getElementById("matches");
|
||||||
|
const targetTabID = popupTabID();
|
||||||
root.textContent = "";
|
root.textContent = "";
|
||||||
if (!Array.isArray(state.matches) || state.matches.length === 0) {
|
if (!Array.isArray(state.matches) || state.matches.length === 0) {
|
||||||
const empty = document.createElement("p");
|
const empty = document.createElement("p");
|
||||||
empty.className = "subtle";
|
empty.className = "subtle";
|
||||||
empty.textContent = "No matching entries for this page.";
|
empty.textContent = state.pageHasLoginForm
|
||||||
|
? "No matching entries for this page."
|
||||||
|
: "No login fields detected on this page.";
|
||||||
root.appendChild(empty);
|
root.appendChild(empty);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -43,17 +61,29 @@ function renderMatches(state) {
|
|||||||
const row = document.createElement("button");
|
const row = document.createElement("button");
|
||||||
row.type = "button";
|
row.type = "button";
|
||||||
row.className = "match-row";
|
row.className = "match-row";
|
||||||
row.innerHTML = `
|
const main = document.createElement("span");
|
||||||
<span class="match-main">
|
main.className = "match-main";
|
||||||
<strong>${match.title}</strong>
|
const title = document.createElement("strong");
|
||||||
<span class="subtle">${match.username || "No username"}</span>
|
title.textContent = match.title;
|
||||||
</span>
|
const subtitle = document.createElement("span");
|
||||||
<span class="quality">${match.quality || ""}</span>
|
subtitle.className = "subtle";
|
||||||
`;
|
subtitle.textContent = matchSubtitle(match);
|
||||||
|
const quality = document.createElement("span");
|
||||||
|
quality.className = "quality";
|
||||||
|
quality.textContent = match.quality || "";
|
||||||
|
main.appendChild(title);
|
||||||
|
main.appendChild(subtitle);
|
||||||
|
row.appendChild(main);
|
||||||
|
row.appendChild(quality);
|
||||||
row.addEventListener("click", async () => {
|
row.addEventListener("click", async () => {
|
||||||
row.disabled = true;
|
row.disabled = true;
|
||||||
|
setStatus("Approval may be required", "KeePassGO will prompt if this token needs approval before fill.", "warning");
|
||||||
try {
|
try {
|
||||||
const result = await runtimeSend({ type: "keepassgo-fill-entry", entryId: match.id });
|
const result = await runtimeSend({
|
||||||
|
type: "keepassgo-fill-entry",
|
||||||
|
entryId: match.id,
|
||||||
|
tabId: targetTabID
|
||||||
|
});
|
||||||
if (!result?.success) {
|
if (!result?.success) {
|
||||||
throw new Error(result?.error || "Fill failed.");
|
throw new Error(result?.error || "Fill failed.");
|
||||||
}
|
}
|
||||||
@@ -68,29 +98,71 @@ function renderMatches(state) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function renderPageHint(state) {
|
||||||
|
const hint = document.getElementById("page-hint");
|
||||||
|
if (state.pendingFill) {
|
||||||
|
hint.textContent = "Approval is pending in KeePassGO.";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (state.pageHasLoginForm && Array.isArray(state.matches) && state.matches.length > 0) {
|
||||||
|
hint.textContent = "Inline KeePassGO suggestions are available on the page.";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (state.pageHasLoginForm) {
|
||||||
|
hint.textContent = "KeePassGO checked this login form already.";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
hint.textContent = "Open a sign-in page to see KeePassGO suggestions here.";
|
||||||
|
}
|
||||||
|
|
||||||
|
function popupTabID() {
|
||||||
|
const rawValue = new URLSearchParams(window.location.search).get("tabId");
|
||||||
|
if (rawValue === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const parsed = Number.parseInt(rawValue, 10);
|
||||||
|
return Number.isInteger(parsed) ? parsed : null;
|
||||||
|
}
|
||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
try {
|
try {
|
||||||
const state = await runtimeSend({ type: "keepassgo-popup-state" });
|
const state = await runtimeSend({
|
||||||
|
type: "keepassgo-popup-state",
|
||||||
|
force: true,
|
||||||
|
tabId: popupTabID()
|
||||||
|
});
|
||||||
document.getElementById("page-host").textContent = hostFromURL(state.pageUrl || "");
|
document.getElementById("page-host").textContent = hostFromURL(state.pageUrl || "");
|
||||||
|
renderPageHint(state);
|
||||||
|
|
||||||
if (!state.configured) {
|
if (!state.configured) {
|
||||||
setStatus("Configure access", state.error || "Set the API token in extension settings.", "warning");
|
setStatus("Configure access", state.error || "Set the API token in extension settings.", "warning");
|
||||||
renderMatches({ matches: [] });
|
renderMatches({ matches: [] });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (state.pendingFill) {
|
||||||
|
setStatus("Approval needed", state.pendingMessage || "Approve or deny the fill request in KeePassGO.", "warning");
|
||||||
|
renderMatches(state);
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (!state.success) {
|
if (!state.success) {
|
||||||
setStatus("KeePassGO unavailable", state.error || "The native host could not reach KeePassGO.", "error");
|
setStatus("KeePassGO unavailable", state.error || "The native host could not reach KeePassGO.", "error");
|
||||||
renderMatches({ matches: [] });
|
renderMatches(state);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (state.status?.locked) {
|
if (state.status?.locked) {
|
||||||
setStatus("Vault locked", "Unlock KeePassGO, then open the popup again.", "warning");
|
setStatus("Vault locked", "Unlock KeePassGO, then try the page again.", "warning");
|
||||||
renderMatches({ matches: [] });
|
renderMatches(state);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const count = Array.isArray(state.matches) ? state.matches.length : 0;
|
const count = Array.isArray(state.matches) ? state.matches.length : 0;
|
||||||
setStatus("Ready", count === 0 ? "KeePassGO is connected." : `${count} matching entr${count === 1 ? "y" : "ies"} found.`, "ready");
|
if (!state.pageHasLoginForm) {
|
||||||
|
setStatus("Ready", "KeePassGO is connected. Open a login form to check for matches.", "ready");
|
||||||
|
} else if (count === 0) {
|
||||||
|
setStatus("Checked this page", "KeePassGO did not find a matching login for this form.", "ready");
|
||||||
|
} else {
|
||||||
|
setStatus("Page suggestions ready", count === 1 ? "1 matching entry is ready on this page." : `${count} matching entries are ready on this page.`, "ready");
|
||||||
|
}
|
||||||
renderMatches(state);
|
renderMatches(state);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setStatus("Error", error instanceof Error ? error.message : String(error), "error");
|
setStatus("Error", error instanceof Error ? error.message : String(error), "error");
|
||||||
|
|||||||
@@ -86,6 +86,10 @@ h2 {
|
|||||||
background: #fcf1f1;
|
background: #fcf1f1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.inline-hint {
|
||||||
|
margin: -6px 0 16px;
|
||||||
|
}
|
||||||
|
|
||||||
.match-list {
|
.match-list {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|||||||
@@ -6,8 +6,6 @@ import (
|
|||||||
"flag"
|
"flag"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
|
||||||
"path/filepath"
|
|
||||||
"runtime"
|
"runtime"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
@@ -126,15 +124,7 @@ func runNativeMessage() error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func defaultBinaryPath() (string, error) {
|
func defaultBinaryPath() (string, error) {
|
||||||
self, err := os.Executable()
|
return browserbridge.ResolveBridgeBinaryPath("")
|
||||||
if err == nil && strings.TrimSpace(self) != "" {
|
|
||||||
return self, nil
|
|
||||||
}
|
|
||||||
self, err = exec.LookPath("keepassgo-browser-bridge")
|
|
||||||
if err == nil {
|
|
||||||
return self, nil
|
|
||||||
}
|
|
||||||
return filepath.Abs("./keepassgo-browser-bridge")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func fail(err error) {
|
func fail(err error) {
|
||||||
|
|||||||
@@ -50,6 +50,8 @@ Build the bridge:
|
|||||||
go build ./cmd/keepassgo-browser-bridge
|
go build ./cmd/keepassgo-browser-bridge
|
||||||
```
|
```
|
||||||
|
|
||||||
|
On Linux desktop builds, KeePassGO now refreshes the user-scoped native messaging manifests on launch. That automatic update always installs the Firefox manifest and also installs Chrome or Chromium manifests when it finds an installed `KeePassGO Browser` extension in that browser profile. The Arch package also ships the extension assets under `/usr/share/keepassgo/browser-extension/`.
|
||||||
|
|
||||||
Install a Firefox native messaging manifest:
|
Install a Firefox native messaging manifest:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -81,10 +83,38 @@ Firefox:
|
|||||||
|
|
||||||
Chromium / Chrome:
|
Chromium / Chrome:
|
||||||
|
|
||||||
1. Load `browser/extension/` with `manifest.chromium.json`.
|
1. Load a Chromium manifest based on `browser/extension/manifest.chromium.json`, or install the published extension when that distribution exists.
|
||||||
2. Note the extension id the browser assigns.
|
2. Start KeePassGO once so it can refresh the native host manifest for the discovered extension id.
|
||||||
3. Install the native host manifest with that extension id.
|
3. Configure the gRPC address and API token in the extension settings page.
|
||||||
4. Configure the gRPC address and API token in the extension settings page.
|
|
||||||
|
## Current Browser Flow
|
||||||
|
|
||||||
|
- The extension checks sign-in pages in the background and caches per-tab match state instead of waiting for the popup to be opened first.
|
||||||
|
- The toolbar badge shows when KeePassGO found matches for the current page.
|
||||||
|
- 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.
|
||||||
|
|
||||||
|
For extension-side regression checks, run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
node --test browser/extension/background.test.cjs browser/extension/content.test.cjs
|
||||||
|
```
|
||||||
|
|
||||||
|
For a reproducible real-browser Chromium validation harness, run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make browser-extension-validate
|
||||||
|
```
|
||||||
|
|
||||||
|
That target:
|
||||||
|
|
||||||
|
- validates the Firefox flow by default with a temporary addon install
|
||||||
|
- can also validate Chromium with `make browser-extension-validate BROWSER=chromium`
|
||||||
|
- builds the native messaging bridge
|
||||||
|
- starts a stub KeePassGO gRPC server and a local login page
|
||||||
|
- drives the browser through inline match discovery, approval visibility, and fill completion
|
||||||
|
|
||||||
|
If validation fails, the script preserves its temporary workspace path so the captured HTML, screenshots, logs, and native-host files can be inspected.
|
||||||
|
|
||||||
## Required Token Scope
|
## Required Token Scope
|
||||||
|
|
||||||
|
|||||||
+15
-4
@@ -109,16 +109,27 @@ func (s *Server) SetSessionState(model vault.Model, locked, dirty bool) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) GetSessionStatus(ctx context.Context, _ *keepassgov1.GetSessionStatusRequest) (*keepassgov1.GetSessionStatusResponse, error) {
|
func (s *Server) GetSessionStatus(ctx context.Context, _ *keepassgov1.GetSessionStatusRequest) (*keepassgov1.GetSessionStatusResponse, error) {
|
||||||
if _, err := s.authenticateRequest(ctx); err != nil {
|
token, err := s.authenticateRequest(ctx)
|
||||||
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
s.mu.RLock()
|
s.mu.RLock()
|
||||||
defer s.mu.RUnlock()
|
defer s.mu.RUnlock()
|
||||||
|
|
||||||
|
pendingApprovals := s.approvals.Pending()
|
||||||
|
var tokenPending uint32
|
||||||
|
for _, pending := range pendingApprovals {
|
||||||
|
if pending.TokenID == token.ID {
|
||||||
|
tokenPending++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return &keepassgov1.GetSessionStatusResponse{
|
return &keepassgov1.GetSessionStatusResponse{
|
||||||
Locked: s.locked,
|
Locked: s.locked,
|
||||||
Dirty: s.dirty,
|
Dirty: s.dirty,
|
||||||
EntryCount: uint32(len(s.model.Entries)),
|
EntryCount: uint32(len(s.model.Entries)),
|
||||||
|
PendingApprovalCount: uint32(len(pendingApprovals)),
|
||||||
|
TokenPendingApprovalCount: tokenPending,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"crypto/sha256"
|
"crypto/sha256"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
|
"errors"
|
||||||
"net"
|
"net"
|
||||||
"os"
|
"os"
|
||||||
"slices"
|
"slices"
|
||||||
@@ -121,6 +122,78 @@ func TestVaultServiceAllowsSessionStatusWithoutManageVault(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestVaultServiceSessionStatusIncludesPendingApprovalsForCurrentToken(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
token, secret, err := apitokens.Issue("Browser Token", "browser-extension", nil, time.Date(2026, 4, 11, 12, 0, 0, 0, time.UTC))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Issue() error = %v", err)
|
||||||
|
}
|
||||||
|
token.SecretHash = hashSecretForTest(secret)
|
||||||
|
otherToken, otherSecret, err := apitokens.Issue("Other Token", "automation-client", nil, time.Date(2026, 4, 11, 12, 1, 0, 0, time.UTC))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Issue() other error = %v", err)
|
||||||
|
}
|
||||||
|
otherToken.SecretHash = hashSecretForTest(otherSecret)
|
||||||
|
|
||||||
|
client, _, service, cleanup := newTestHarnessForModel(t, vault.Model{
|
||||||
|
Entries: []vault.Entry{
|
||||||
|
token.Entry([]string{"Root", "API Tokens"}),
|
||||||
|
otherToken.Entry([]string{"Root", "API Tokens"}),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
service.approvals = apiapproval.NewBroker(time.Minute)
|
||||||
|
ctx, cancel := context.WithCancel(tokenContext(secret))
|
||||||
|
defer cancel()
|
||||||
|
waiting := make(chan error, 1)
|
||||||
|
go func() {
|
||||||
|
_, err := service.approvals.Request(ctx, token, apitokens.OperationCopyPassword, apitokens.Resource{
|
||||||
|
Kind: apitokens.ResourceEntry,
|
||||||
|
EntryID: "vault-console",
|
||||||
|
Path: []string{"Root", "Internet"},
|
||||||
|
})
|
||||||
|
waiting <- err
|
||||||
|
}()
|
||||||
|
|
||||||
|
otherCtx, otherCancel := context.WithCancel(tokenContext(otherSecret))
|
||||||
|
defer otherCancel()
|
||||||
|
otherWaiting := make(chan error, 1)
|
||||||
|
go func() {
|
||||||
|
_, err := service.approvals.Request(otherCtx, otherToken, apitokens.OperationListEntries, apitokens.Resource{
|
||||||
|
Kind: apitokens.ResourceGroup,
|
||||||
|
Path: []string{"Root", "Shared"},
|
||||||
|
})
|
||||||
|
otherWaiting <- err
|
||||||
|
}()
|
||||||
|
|
||||||
|
waitForServerPendingApproval(t, service, 2)
|
||||||
|
|
||||||
|
resp, err := client.GetSessionStatus(tokenContext(secret), &keepassgov1.GetSessionStatusRequest{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetSessionStatus() error = %v", err)
|
||||||
|
}
|
||||||
|
if got := resp.GetPendingApprovalCount(); got != 2 {
|
||||||
|
t.Fatalf("GetSessionStatus().PendingApprovalCount = %d, want 2", got)
|
||||||
|
}
|
||||||
|
if got := resp.GetTokenPendingApprovalCount(); got != 1 {
|
||||||
|
t.Fatalf("GetSessionStatus().TokenPendingApprovalCount = %d, want 1", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, pending := range waitForServerPendingApproval(t, service, 2) {
|
||||||
|
if _, _, err := service.ResolveApproval(pending.ID, apiapproval.OutcomeCancel); err != nil {
|
||||||
|
t.Fatalf("ResolveApproval(%q) error = %v", pending.ID, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := <-waiting; !errors.Is(err, apiapproval.ErrRequestCanceled) {
|
||||||
|
t.Fatalf("Request(token) error = %v, want %v", err, apiapproval.ErrRequestCanceled)
|
||||||
|
}
|
||||||
|
if err := <-otherWaiting; !errors.Is(err, apiapproval.ErrRequestCanceled) {
|
||||||
|
t.Fatalf("Request(otherToken) error = %v, want %v", err, apiapproval.ErrRequestCanceled)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestVaultServiceRejectsUnauthorizedTemplateMutation(t *testing.T) {
|
func TestVaultServiceRejectsUnauthorizedTemplateMutation(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import (
|
|||||||
"git.julianfamily.org/keepassgo/internal/apiapproval"
|
"git.julianfamily.org/keepassgo/internal/apiapproval"
|
||||||
"git.julianfamily.org/keepassgo/internal/apitokens"
|
"git.julianfamily.org/keepassgo/internal/apitokens"
|
||||||
"git.julianfamily.org/keepassgo/internal/appui/platform"
|
"git.julianfamily.org/keepassgo/internal/appui/platform"
|
||||||
|
"git.julianfamily.org/keepassgo/internal/browserbridge"
|
||||||
"git.julianfamily.org/keepassgo/internal/grpcaddr"
|
"git.julianfamily.org/keepassgo/internal/grpcaddr"
|
||||||
"git.julianfamily.org/keepassgo/internal/passwords"
|
"git.julianfamily.org/keepassgo/internal/passwords"
|
||||||
"git.julianfamily.org/keepassgo/internal/session"
|
"git.julianfamily.org/keepassgo/internal/session"
|
||||||
@@ -61,6 +62,7 @@ func defaultGRPCAddr(goos string) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func run(w *app.Window, mode string, paths statePaths, grpcAddr string) error {
|
func run(w *app.Window, mode string, paths statePaths, grpcAddr string) error {
|
||||||
|
ensureBrowserNativeHosts()
|
||||||
var ops op.Ops
|
var ops op.Ops
|
||||||
manager := &session.Manager{}
|
manager := &session.Manager{}
|
||||||
ui := newUIWithSession(mode, manager, paths)
|
ui := newUIWithSession(mode, manager, paths)
|
||||||
@@ -99,6 +101,17 @@ func run(w *app.Window, mode string, paths statePaths, grpcAddr string) error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func ensureBrowserNativeHosts() {
|
||||||
|
if runtime.GOOS != "linux" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
appBinaryPath, err := os.Executable()
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_ = browserbridge.EnsureNativeHostManifests(appBinaryPath)
|
||||||
|
}
|
||||||
|
|
||||||
type uiApprovalManager struct {
|
type uiApprovalManager struct {
|
||||||
server *api.Server
|
server *api.Server
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -42,11 +42,13 @@ type Response struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type Status struct {
|
type Status struct {
|
||||||
Connected bool `json:"connected"`
|
Connected bool `json:"connected"`
|
||||||
Locked bool `json:"locked"`
|
Locked bool `json:"locked"`
|
||||||
Dirty bool `json:"dirty,omitempty"`
|
Dirty bool `json:"dirty,omitempty"`
|
||||||
EntryCount uint32 `json:"entryCount,omitempty"`
|
EntryCount uint32 `json:"entryCount,omitempty"`
|
||||||
GRPCAddress string `json:"grpcAddress,omitempty"`
|
PendingApprovalCount uint32 `json:"pendingApprovalCount,omitempty"`
|
||||||
|
TokenPendingApprovalCount uint32 `json:"tokenPendingApprovalCount,omitempty"`
|
||||||
|
GRPCAddress string `json:"grpcAddress,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type Match struct {
|
type Match struct {
|
||||||
@@ -202,11 +204,13 @@ func statusResponse(ctx context.Context, client Client, addr string) (*Status, e
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return &Status{
|
return &Status{
|
||||||
Connected: true,
|
Connected: true,
|
||||||
Locked: resp.GetLocked(),
|
Locked: resp.GetLocked(),
|
||||||
Dirty: resp.GetDirty(),
|
Dirty: resp.GetDirty(),
|
||||||
EntryCount: resp.GetEntryCount(),
|
EntryCount: resp.GetEntryCount(),
|
||||||
GRPCAddress: strings.TrimSpace(addr),
|
PendingApprovalCount: resp.GetPendingApprovalCount(),
|
||||||
|
TokenPendingApprovalCount: resp.GetTokenPendingApprovalCount(),
|
||||||
|
GRPCAddress: strings.TrimSpace(addr),
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -324,27 +328,5 @@ func DefaultManifestPath(browser Browser) (string, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func InstallManifest(browser Browser, binaryPath, extensionID, outputPath string) (string, error) {
|
func InstallManifest(browser Browser, binaryPath, extensionID, outputPath string) (string, error) {
|
||||||
manifest, err := Manifest(browser, binaryPath, extensionID)
|
return InstallManifestSet(browser, binaryPath, []string{strings.TrimSpace(extensionID)}, outputPath)
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
path := strings.TrimSpace(outputPath)
|
|
||||||
if path == "" {
|
|
||||||
path, err = DefaultManifestPath(browser)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
|
||||||
return "", fmt.Errorf("create native host manifest dir: %w", err)
|
|
||||||
}
|
|
||||||
data, err := json.MarshalIndent(manifest, "", " ")
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("encode native host manifest: %w", err)
|
|
||||||
}
|
|
||||||
data = append(data, '\n')
|
|
||||||
if err := os.WriteFile(path, data, 0o644); err != nil {
|
|
||||||
return "", fmt.Errorf("write native host manifest: %w", err)
|
|
||||||
}
|
|
||||||
return path, nil
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"runtime"
|
"runtime"
|
||||||
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
@@ -88,6 +89,35 @@ func TestHandleRequestFindLogins(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestHandleRequestStatusIncludesPendingApprovalCounts(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
client := fakeClient{
|
||||||
|
status: &keepassgov1.GetSessionStatusResponse{
|
||||||
|
Locked: false,
|
||||||
|
EntryCount: 2,
|
||||||
|
PendingApprovalCount: 3,
|
||||||
|
TokenPendingApprovalCount: 1,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
resp := HandleRequest(context.Background(), Request{
|
||||||
|
Action: "status",
|
||||||
|
BearerToken: "secret",
|
||||||
|
}, client)
|
||||||
|
if !resp.Success {
|
||||||
|
t.Fatalf("HandleRequest(status) success = false, error = %q", resp.Error)
|
||||||
|
}
|
||||||
|
if resp.Status == nil {
|
||||||
|
t.Fatal("HandleRequest(status).Status = nil, want status")
|
||||||
|
}
|
||||||
|
if got := resp.Status.PendingApprovalCount; got != 3 {
|
||||||
|
t.Fatalf("HandleRequest(status).PendingApprovalCount = %d, want 3", got)
|
||||||
|
}
|
||||||
|
if got := resp.Status.TokenPendingApprovalCount; got != 1 {
|
||||||
|
t.Fatalf("HandleRequest(status).TokenPendingApprovalCount = %d, want 1", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestHandleRequestGetLogin(t *testing.T) {
|
func TestHandleRequestGetLogin(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -181,6 +211,76 @@ func TestChromiumExtensionIDFromManifestKey(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestManifestSetChromiumIncludesAllOrigins(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
manifest, err := ManifestSet(BrowserChromium, "/tmp/keepassgo-browser-bridge", []string{
|
||||||
|
"mjlnpdomnblnbblhacolncflebbgafhj",
|
||||||
|
"ddfbfpcgdjkffmjnialjpookcoedahcn",
|
||||||
|
"mjlnpdomnblnbblhacolncflebbgafhj",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ManifestSet() error = %v", err)
|
||||||
|
}
|
||||||
|
want := []string{
|
||||||
|
"chrome-extension://ddfbfpcgdjkffmjnialjpookcoedahcn/",
|
||||||
|
"chrome-extension://mjlnpdomnblnbblhacolncflebbgafhj/",
|
||||||
|
}
|
||||||
|
if !slices.Equal(manifest.AllowedOrigins, want) {
|
||||||
|
t.Fatalf("ManifestSet().AllowedOrigins = %#v, want %#v", manifest.AllowedOrigins, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDiscoverInstalledExtensionIDsInRoot(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
root := t.TempDir()
|
||||||
|
writeExtensionManifest(t, filepath.Join(root, "Default", "Extensions", "mjlnpdomnblnbblhacolncflebbgafhj", "1.0.0", "manifest.json"), browserExtensionName)
|
||||||
|
writeExtensionManifest(t, filepath.Join(root, "Profile 1", "Extensions", "ddfbfpcgdjkffmjnialjpookcoedahcn", "1.2.0", "manifest.json"), browserExtensionName)
|
||||||
|
writeExtensionManifest(t, filepath.Join(root, "Profile 2", "Extensions", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", "3.4.5", "manifest.json"), "Bellagio Notes")
|
||||||
|
writeExtensionManifest(t, filepath.Join(root, "Profile 3", "Extensions", "mjlnpdomnblnbblhacolncflebbgafhj", "1.1.0", "manifest.json"), browserExtensionName)
|
||||||
|
|
||||||
|
got, err := DiscoverInstalledExtensionIDsInRoot(root)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("DiscoverInstalledExtensionIDsInRoot() error = %v", err)
|
||||||
|
}
|
||||||
|
want := []string{
|
||||||
|
"ddfbfpcgdjkffmjnialjpookcoedahcn",
|
||||||
|
"mjlnpdomnblnbblhacolncflebbgafhj",
|
||||||
|
}
|
||||||
|
if !slices.Equal(got, want) {
|
||||||
|
t.Fatalf("DiscoverInstalledExtensionIDsInRoot() = %#v, want %#v", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEnsureNativeHostManifestsInstallsFirefoxAndDiscoveredChromium(t *testing.T) {
|
||||||
|
tmp := t.TempDir()
|
||||||
|
t.Setenv("HOME", filepath.Join(tmp, "home"))
|
||||||
|
appDir := filepath.Join(tmp, "app")
|
||||||
|
if err := os.MkdirAll(appDir, 0o755); err != nil {
|
||||||
|
t.Fatalf("MkdirAll(appDir) error = %v", err)
|
||||||
|
}
|
||||||
|
appBinaryPath := filepath.Join(appDir, "keepassgo")
|
||||||
|
if err := os.WriteFile(appBinaryPath, []byte("#!/bin/sh\n"), 0o755); err != nil {
|
||||||
|
t.Fatalf("WriteFile(appBinaryPath) error = %v", err)
|
||||||
|
}
|
||||||
|
bridgeBinaryPath := filepath.Join(appDir, "keepassgo-browser-bridge")
|
||||||
|
if err := os.WriteFile(bridgeBinaryPath, []byte("#!/bin/sh\n"), 0o755); err != nil {
|
||||||
|
t.Fatalf("WriteFile(bridgeBinaryPath) error = %v", err)
|
||||||
|
}
|
||||||
|
home := filepath.Join(tmp, "home")
|
||||||
|
writeExtensionManifest(t, filepath.Join(home, ".config", "chromium", "Default", "Extensions", "mjlnpdomnblnbblhacolncflebbgafhj", "1.0.0", "manifest.json"), browserExtensionName)
|
||||||
|
writeExtensionManifest(t, filepath.Join(home, ".config", "google-chrome", "Profile 7", "Extensions", "ddfbfpcgdjkffmjnialjpookcoedahcn", "1.0.0", "manifest.json"), browserExtensionName)
|
||||||
|
|
||||||
|
if err := EnsureNativeHostManifests(appBinaryPath); err != nil {
|
||||||
|
t.Fatalf("EnsureNativeHostManifests() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
assertManifestContainsExtension(t, filepath.Join(home, ".mozilla", "native-messaging-hosts", NativeHostName+".json"), "allowed_extensions", DefaultFirefoxExtensionID())
|
||||||
|
assertManifestContainsExtension(t, filepath.Join(home, ".config", "chromium", "NativeMessagingHosts", NativeHostName+".json"), "allowed_origins", "chrome-extension://mjlnpdomnblnbblhacolncflebbgafhj/")
|
||||||
|
assertManifestContainsExtension(t, filepath.Join(home, ".config", "google-chrome", "NativeMessagingHosts", NativeHostName+".json"), "allowed_origins", "chrome-extension://ddfbfpcgdjkffmjnialjpookcoedahcn/")
|
||||||
|
}
|
||||||
|
|
||||||
type fakeClient struct {
|
type fakeClient struct {
|
||||||
status *keepassgov1.GetSessionStatusResponse
|
status *keepassgov1.GetSessionStatusResponse
|
||||||
matches []*keepassgov1.BrowserLoginMatch
|
matches []*keepassgov1.BrowserLoginMatch
|
||||||
@@ -188,6 +288,51 @@ type fakeClient struct {
|
|||||||
err error
|
err error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func writeExtensionManifest(t *testing.T, path, name string) {
|
||||||
|
t.Helper()
|
||||||
|
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||||
|
t.Fatalf("MkdirAll(%q) error = %v", filepath.Dir(path), err)
|
||||||
|
}
|
||||||
|
data, err := json.Marshal(map[string]string{"name": name})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Marshal(manifest %q) error = %v", path, err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(path, append(data, '\n'), 0o644); err != nil {
|
||||||
|
t.Fatalf("WriteFile(%q) error = %v", path, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func assertManifestContainsExtension(t *testing.T, path, field, want string) {
|
||||||
|
t.Helper()
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ReadFile(%q) error = %v", path, err)
|
||||||
|
}
|
||||||
|
var manifest map[string]any
|
||||||
|
if err := json.Unmarshal(data, &manifest); err != nil {
|
||||||
|
t.Fatalf("Unmarshal(%q) error = %v", path, err)
|
||||||
|
}
|
||||||
|
valuesAny, ok := manifest[field]
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("manifest %q missing field %q", path, field)
|
||||||
|
}
|
||||||
|
valuesRaw, ok := valuesAny.([]any)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("manifest %q field %q = %#v, want []any", path, field, valuesAny)
|
||||||
|
}
|
||||||
|
values := make([]string, 0, len(valuesRaw))
|
||||||
|
for _, raw := range valuesRaw {
|
||||||
|
text, ok := raw.(string)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("manifest %q field %q value = %#v, want string", path, field, raw)
|
||||||
|
}
|
||||||
|
values = append(values, text)
|
||||||
|
}
|
||||||
|
if !slices.Contains(values, want) {
|
||||||
|
t.Fatalf("manifest %q field %q = %#v, want to contain %q", path, field, values, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (f fakeClient) Status(context.Context) (*keepassgov1.GetSessionStatusResponse, error) {
|
func (f fakeClient) Status(context.Context) (*keepassgov1.GetSessionStatusResponse, error) {
|
||||||
if f.err != nil {
|
if f.err != nil {
|
||||||
return nil, f.err
|
return nil, f.err
|
||||||
|
|||||||
@@ -0,0 +1,210 @@
|
|||||||
|
package browserbridge
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"slices"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
const browserExtensionName = "KeePassGO Browser"
|
||||||
|
|
||||||
|
type extensionManifestMetadata struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func ResolveBridgeBinaryPath(appBinaryPath string) (string, error) {
|
||||||
|
path := strings.TrimSpace(appBinaryPath)
|
||||||
|
if path == "" {
|
||||||
|
var err error
|
||||||
|
path, err = os.Executable()
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("resolve app executable: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(path) == "" {
|
||||||
|
return "", fmt.Errorf("app executable path is required")
|
||||||
|
}
|
||||||
|
if filepath.Base(path) == "keepassgo-browser-bridge" {
|
||||||
|
return path, nil
|
||||||
|
}
|
||||||
|
candidate := filepath.Join(filepath.Dir(path), "keepassgo-browser-bridge")
|
||||||
|
if info, err := os.Stat(candidate); err == nil && !info.IsDir() {
|
||||||
|
return candidate, nil
|
||||||
|
}
|
||||||
|
resolved, err := exec.LookPath("keepassgo-browser-bridge")
|
||||||
|
if err == nil {
|
||||||
|
return resolved, nil
|
||||||
|
}
|
||||||
|
return "", fmt.Errorf("locate keepassgo-browser-bridge next to %q or in PATH: %w", path, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func EnsureNativeHostManifests(appBinaryPath string) error {
|
||||||
|
bridgePath, err := ResolveBridgeBinaryPath(appBinaryPath)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
var errs []error
|
||||||
|
if _, err := InstallManifest(BrowserFirefox, bridgePath, "", ""); err != nil {
|
||||||
|
errs = append(errs, fmt.Errorf("install firefox native host: %w", err))
|
||||||
|
}
|
||||||
|
for _, browser := range []Browser{BrowserChrome, BrowserChromium} {
|
||||||
|
ids, err := DiscoverInstalledExtensionIDs(browser)
|
||||||
|
if err != nil {
|
||||||
|
errs = append(errs, fmt.Errorf("discover %s extension ids: %w", browser, err))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if len(ids) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, err := InstallManifestSet(browser, bridgePath, ids, ""); err != nil {
|
||||||
|
errs = append(errs, fmt.Errorf("install %s native host: %w", browser, err))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return errors.Join(errs...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func DiscoverInstalledExtensionIDs(browser Browser) ([]string, error) {
|
||||||
|
root, err := defaultBrowserProfileRoot(browser)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return DiscoverInstalledExtensionIDsInRoot(root)
|
||||||
|
}
|
||||||
|
|
||||||
|
func DiscoverInstalledExtensionIDsInRoot(root string) ([]string, error) {
|
||||||
|
base := strings.TrimSpace(root)
|
||||||
|
if base == "" {
|
||||||
|
return nil, fmt.Errorf("browser profile root is required")
|
||||||
|
}
|
||||||
|
pattern := filepath.Join(base, "*", "Extensions", "*", "*", "manifest.json")
|
||||||
|
paths, err := filepath.Glob(pattern)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("glob browser extensions: %w", err)
|
||||||
|
}
|
||||||
|
ids := make(map[string]struct{}, len(paths))
|
||||||
|
for _, path := range paths {
|
||||||
|
ok, err := isKeePassGOExtensionManifest(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
id := filepath.Base(filepath.Dir(filepath.Dir(path)))
|
||||||
|
if strings.TrimSpace(id) == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
ids[id] = struct{}{}
|
||||||
|
}
|
||||||
|
out := make([]string, 0, len(ids))
|
||||||
|
for id := range ids {
|
||||||
|
out = append(out, id)
|
||||||
|
}
|
||||||
|
slices.Sort(out)
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func InstallManifestSet(browser Browser, binaryPath string, extensionIDs []string, outputPath string) (string, error) {
|
||||||
|
manifest, err := ManifestSet(browser, binaryPath, extensionIDs)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
path := strings.TrimSpace(outputPath)
|
||||||
|
if path == "" {
|
||||||
|
path, err = DefaultManifestPath(browser)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||||
|
return "", fmt.Errorf("create native host manifest dir: %w", err)
|
||||||
|
}
|
||||||
|
data, err := json.MarshalIndent(manifest, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("encode native host manifest: %w", err)
|
||||||
|
}
|
||||||
|
data = append(data, '\n')
|
||||||
|
if err := os.WriteFile(path, data, 0o644); err != nil {
|
||||||
|
return "", fmt.Errorf("write native host manifest: %w", err)
|
||||||
|
}
|
||||||
|
return path, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func ManifestSet(browser Browser, binaryPath string, extensionIDs []string) (NativeHostManifest, error) {
|
||||||
|
path := strings.TrimSpace(binaryPath)
|
||||||
|
if path == "" {
|
||||||
|
return NativeHostManifest{}, fmt.Errorf("native host binary path is required")
|
||||||
|
}
|
||||||
|
switch browser {
|
||||||
|
case BrowserFirefox:
|
||||||
|
return Manifest(browser, path, "")
|
||||||
|
case BrowserChrome, BrowserChromium:
|
||||||
|
ids := normalizedExtensionIDs(extensionIDs)
|
||||||
|
if len(ids) == 0 {
|
||||||
|
return NativeHostManifest{}, fmt.Errorf("%s extension id is required", browser)
|
||||||
|
}
|
||||||
|
origins := make([]string, 0, len(ids))
|
||||||
|
for _, id := range ids {
|
||||||
|
origins = append(origins, "chrome-extension://"+id+"/")
|
||||||
|
}
|
||||||
|
return NativeHostManifest{
|
||||||
|
Name: NativeHostName,
|
||||||
|
Description: "KeePassGO browser bridge",
|
||||||
|
Path: path,
|
||||||
|
Type: "stdio",
|
||||||
|
AllowedOrigins: origins,
|
||||||
|
}, nil
|
||||||
|
default:
|
||||||
|
return NativeHostManifest{}, fmt.Errorf("unsupported browser %q", browser)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func defaultBrowserProfileRoot(browser Browser) (string, error) {
|
||||||
|
home, err := os.UserHomeDir()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
switch browser {
|
||||||
|
case BrowserChrome:
|
||||||
|
return filepath.Join(home, ".config", "google-chrome"), nil
|
||||||
|
case BrowserChromium:
|
||||||
|
return filepath.Join(home, ".config", "chromium"), nil
|
||||||
|
default:
|
||||||
|
return "", fmt.Errorf("installed extension discovery is unsupported for %q", browser)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func isKeePassGOExtensionManifest(path string) (bool, error) {
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return false, fmt.Errorf("read extension manifest %q: %w", path, err)
|
||||||
|
}
|
||||||
|
var metadata extensionManifestMetadata
|
||||||
|
if err := json.Unmarshal(data, &metadata); err != nil {
|
||||||
|
return false, fmt.Errorf("decode extension manifest %q: %w", path, err)
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(metadata.Name) == browserExtensionName, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizedExtensionIDs(ids []string) []string {
|
||||||
|
seen := make(map[string]struct{}, len(ids))
|
||||||
|
out := make([]string, 0, len(ids))
|
||||||
|
for _, raw := range ids {
|
||||||
|
id := strings.TrimSpace(raw)
|
||||||
|
if id == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, ok := seen[id]; ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[id] = struct{}{}
|
||||||
|
out = append(out, id)
|
||||||
|
}
|
||||||
|
slices.Sort(out)
|
||||||
|
return out
|
||||||
|
}
|
||||||
@@ -50,6 +50,26 @@ package() {
|
|||||||
|
|
||||||
install -Dm755 keepassgo "${pkgdir}/usr/bin/keepassgo"
|
install -Dm755 keepassgo "${pkgdir}/usr/bin/keepassgo"
|
||||||
install -Dm755 keepassgo-browser-bridge "${pkgdir}/usr/bin/keepassgo-browser-bridge"
|
install -Dm755 keepassgo-browser-bridge "${pkgdir}/usr/bin/keepassgo-browser-bridge"
|
||||||
|
install -Dm644 browser/extension/README.md \
|
||||||
|
"${pkgdir}/usr/share/keepassgo/browser-extension/README.md"
|
||||||
|
install -Dm644 browser/extension/background.js \
|
||||||
|
"${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/manifest.chromium.json \
|
||||||
|
"${pkgdir}/usr/share/keepassgo/browser-extension/manifest.chromium.json"
|
||||||
|
install -Dm644 browser/extension/manifest.firefox.json \
|
||||||
|
"${pkgdir}/usr/share/keepassgo/browser-extension/manifest.firefox.json"
|
||||||
|
install -Dm644 browser/extension/options.html \
|
||||||
|
"${pkgdir}/usr/share/keepassgo/browser-extension/options.html"
|
||||||
|
install -Dm644 browser/extension/options.js \
|
||||||
|
"${pkgdir}/usr/share/keepassgo/browser-extension/options.js"
|
||||||
|
install -Dm644 browser/extension/popup.html \
|
||||||
|
"${pkgdir}/usr/share/keepassgo/browser-extension/popup.html"
|
||||||
|
install -Dm644 browser/extension/popup.js \
|
||||||
|
"${pkgdir}/usr/share/keepassgo/browser-extension/popup.js"
|
||||||
|
install -Dm644 browser/extension/style.css \
|
||||||
|
"${pkgdir}/usr/share/keepassgo/browser-extension/style.css"
|
||||||
install -Dm644 internal/assets/keepassgo-icon.png \
|
install -Dm644 internal/assets/keepassgo-icon.png \
|
||||||
"${pkgdir}/usr/share/icons/hicolor/512x512/apps/keepassgo.png"
|
"${pkgdir}/usr/share/icons/hicolor/512x512/apps/keepassgo.png"
|
||||||
install -Dm644 internal/assets/keepassgo-icon.svg \
|
install -Dm644 internal/assets/keepassgo-icon.svg \
|
||||||
|
|||||||
@@ -58,12 +58,14 @@ func (*GetSessionStatusRequest) Descriptor() ([]byte, []int) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type GetSessionStatusResponse struct {
|
type GetSessionStatusResponse struct {
|
||||||
state protoimpl.MessageState `protogen:"open.v1"`
|
state protoimpl.MessageState `protogen:"open.v1"`
|
||||||
Locked bool `protobuf:"varint,1,opt,name=locked,proto3" json:"locked,omitempty"`
|
Locked bool `protobuf:"varint,1,opt,name=locked,proto3" json:"locked,omitempty"`
|
||||||
Dirty bool `protobuf:"varint,2,opt,name=dirty,proto3" json:"dirty,omitempty"`
|
Dirty bool `protobuf:"varint,2,opt,name=dirty,proto3" json:"dirty,omitempty"`
|
||||||
EntryCount uint32 `protobuf:"varint,3,opt,name=entry_count,json=entryCount,proto3" json:"entry_count,omitempty"`
|
EntryCount uint32 `protobuf:"varint,3,opt,name=entry_count,json=entryCount,proto3" json:"entry_count,omitempty"`
|
||||||
unknownFields protoimpl.UnknownFields
|
PendingApprovalCount uint32 `protobuf:"varint,4,opt,name=pending_approval_count,json=pendingApprovalCount,proto3" json:"pending_approval_count,omitempty"`
|
||||||
sizeCache protoimpl.SizeCache
|
TokenPendingApprovalCount uint32 `protobuf:"varint,5,opt,name=token_pending_approval_count,json=tokenPendingApprovalCount,proto3" json:"token_pending_approval_count,omitempty"`
|
||||||
|
unknownFields protoimpl.UnknownFields
|
||||||
|
sizeCache protoimpl.SizeCache
|
||||||
}
|
}
|
||||||
|
|
||||||
func (x *GetSessionStatusResponse) Reset() {
|
func (x *GetSessionStatusResponse) Reset() {
|
||||||
@@ -117,6 +119,20 @@ func (x *GetSessionStatusResponse) GetEntryCount() uint32 {
|
|||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (x *GetSessionStatusResponse) GetPendingApprovalCount() uint32 {
|
||||||
|
if x != nil {
|
||||||
|
return x.PendingApprovalCount
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *GetSessionStatusResponse) GetTokenPendingApprovalCount() uint32 {
|
||||||
|
if x != nil {
|
||||||
|
return x.TokenPendingApprovalCount
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
type OpenVaultRequest struct {
|
type OpenVaultRequest struct {
|
||||||
state protoimpl.MessageState `protogen:"open.v1"`
|
state protoimpl.MessageState `protogen:"open.v1"`
|
||||||
Path string `protobuf:"bytes,1,opt,name=path,proto3" json:"path,omitempty"`
|
Path string `protobuf:"bytes,1,opt,name=path,proto3" json:"path,omitempty"`
|
||||||
@@ -2738,12 +2754,14 @@ var File_proto_keepassgo_v1_keepassgo_proto protoreflect.FileDescriptor
|
|||||||
const file_proto_keepassgo_v1_keepassgo_proto_rawDesc = "" +
|
const file_proto_keepassgo_v1_keepassgo_proto_rawDesc = "" +
|
||||||
"\n" +
|
"\n" +
|
||||||
"\"proto/keepassgo/v1/keepassgo.proto\x12\fkeepassgo.v1\"\x19\n" +
|
"\"proto/keepassgo/v1/keepassgo.proto\x12\fkeepassgo.v1\"\x19\n" +
|
||||||
"\x17GetSessionStatusRequest\"i\n" +
|
"\x17GetSessionStatusRequest\"\xe0\x01\n" +
|
||||||
"\x18GetSessionStatusResponse\x12\x16\n" +
|
"\x18GetSessionStatusResponse\x12\x16\n" +
|
||||||
"\x06locked\x18\x01 \x01(\bR\x06locked\x12\x14\n" +
|
"\x06locked\x18\x01 \x01(\bR\x06locked\x12\x14\n" +
|
||||||
"\x05dirty\x18\x02 \x01(\bR\x05dirty\x12\x1f\n" +
|
"\x05dirty\x18\x02 \x01(\bR\x05dirty\x12\x1f\n" +
|
||||||
"\ventry_count\x18\x03 \x01(\rR\n" +
|
"\ventry_count\x18\x03 \x01(\rR\n" +
|
||||||
"entryCount\"f\n" +
|
"entryCount\x124\n" +
|
||||||
|
"\x16pending_approval_count\x18\x04 \x01(\rR\x14pendingApprovalCount\x12?\n" +
|
||||||
|
"\x1ctoken_pending_approval_count\x18\x05 \x01(\rR\x19tokenPendingApprovalCount\"f\n" +
|
||||||
"\x10OpenVaultRequest\x12\x12\n" +
|
"\x10OpenVaultRequest\x12\x12\n" +
|
||||||
"\x04path\x18\x01 \x01(\tR\x04path\x12\x1a\n" +
|
"\x04path\x18\x01 \x01(\tR\x04path\x12\x1a\n" +
|
||||||
"\bpassword\x18\x02 \x01(\tR\bpassword\x12\"\n" +
|
"\bpassword\x18\x02 \x01(\tR\bpassword\x12\"\n" +
|
||||||
|
|||||||
@@ -41,6 +41,8 @@ message GetSessionStatusResponse {
|
|||||||
bool locked = 1;
|
bool locked = 1;
|
||||||
bool dirty = 2;
|
bool dirty = 2;
|
||||||
uint32 entry_count = 3;
|
uint32 entry_count = 3;
|
||||||
|
uint32 pending_approval_count = 4;
|
||||||
|
uint32 token_pending_approval_count = 5;
|
||||||
}
|
}
|
||||||
|
|
||||||
message OpenVaultRequest {
|
message OpenVaultRequest {
|
||||||
|
|||||||
@@ -0,0 +1,109 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
keepassgov1 "git.julianfamily.org/keepassgo/proto/keepassgo/v1"
|
||||||
|
"google.golang.org/grpc"
|
||||||
|
)
|
||||||
|
|
||||||
|
type validationServer struct {
|
||||||
|
keepassgov1.UnimplementedVaultServiceServer
|
||||||
|
statePath string
|
||||||
|
pageURL string
|
||||||
|
}
|
||||||
|
|
||||||
|
func readState(path string) string {
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return "idle"
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(string(data))
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeState(path, value string) {
|
||||||
|
_ = os.WriteFile(path, []byte(value), 0o644)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *validationServer) GetSessionStatus(context.Context, *keepassgov1.GetSessionStatusRequest) (*keepassgov1.GetSessionStatusResponse, error) {
|
||||||
|
pending := uint32(0)
|
||||||
|
if readState(s.statePath) == "pending" {
|
||||||
|
pending = 1
|
||||||
|
}
|
||||||
|
return &keepassgov1.GetSessionStatusResponse{
|
||||||
|
Locked: false,
|
||||||
|
EntryCount: 1,
|
||||||
|
PendingApprovalCount: pending,
|
||||||
|
TokenPendingApprovalCount: pending,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *validationServer) FindBrowserLogins(context.Context, *keepassgov1.FindBrowserLoginsRequest) (*keepassgov1.FindBrowserLoginsResponse, error) {
|
||||||
|
return &keepassgov1.FindBrowserLoginsResponse{
|
||||||
|
Matches: []*keepassgov1.BrowserLoginMatch{
|
||||||
|
{
|
||||||
|
Id: "vault-console",
|
||||||
|
Title: "Vault Console",
|
||||||
|
Username: "dannyocean",
|
||||||
|
Url: s.pageURL,
|
||||||
|
Path: []string{"Root", "Crew"},
|
||||||
|
Quality: "exact-host",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *validationServer) GetBrowserCredential(ctx context.Context, req *keepassgov1.GetBrowserCredentialRequest) (*keepassgov1.GetBrowserCredentialResponse, error) {
|
||||||
|
writeState(s.statePath, "pending")
|
||||||
|
ticker := time.NewTicker(200 * time.Millisecond)
|
||||||
|
defer ticker.Stop()
|
||||||
|
timeout := time.After(20 * time.Second)
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return nil, ctx.Err()
|
||||||
|
case <-timeout:
|
||||||
|
return nil, fmt.Errorf("timed out waiting for browser-approval state")
|
||||||
|
case <-ticker.C:
|
||||||
|
if readState(s.statePath) == "approved" {
|
||||||
|
writeState(s.statePath, "done")
|
||||||
|
return &keepassgov1.GetBrowserCredentialResponse{
|
||||||
|
Id: req.GetId(),
|
||||||
|
Username: "dannyocean",
|
||||||
|
Password: "token-1",
|
||||||
|
Url: s.pageURL,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
listenAddr := flag.String("listen", "127.0.0.1:47779", "listen address")
|
||||||
|
statePath := flag.String("state", "", "path to mutable validation state file")
|
||||||
|
pageURL := flag.String("page-url", "http://127.0.0.1:18080/login.html", "login page URL returned by the stub")
|
||||||
|
flag.Parse()
|
||||||
|
|
||||||
|
if strings.TrimSpace(*statePath) == "" {
|
||||||
|
panic("validation state file is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
listener, err := net.Listen("tcp", strings.TrimSpace(*listenAddr))
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
server := grpc.NewServer()
|
||||||
|
keepassgov1.RegisterVaultServiceServer(server, &validationServer{
|
||||||
|
statePath: strings.TrimSpace(*statePath),
|
||||||
|
pageURL: strings.TrimSpace(*pageURL),
|
||||||
|
})
|
||||||
|
if err := server.Serve(listener); err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,611 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import base64
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import shutil
|
||||||
|
import socket
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import textwrap
|
||||||
|
import time
|
||||||
|
import zipfile
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
EXTENSION_SOURCE = REPO_ROOT / "browser" / "extension"
|
||||||
|
STUB_SERVER = REPO_ROOT / "scripts" / "browser_extension_validation_server.go"
|
||||||
|
TOKEN = "test-token"
|
||||||
|
ORIGINAL_HOME = Path(os.environ.get("HOME", ""))
|
||||||
|
|
||||||
|
|
||||||
|
def run(cmd, *, cwd=None, env=None, check=True):
|
||||||
|
result = subprocess.run(cmd, cwd=cwd, env=env, text=True, capture_output=True)
|
||||||
|
if check and result.returncode != 0:
|
||||||
|
raise RuntimeError(
|
||||||
|
f"command failed ({result.returncode}): {' '.join(cmd)}\n"
|
||||||
|
f"stdout:\n{result.stdout}\n"
|
||||||
|
f"stderr:\n{result.stderr}"
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_selenium_venv(venv_dir: Path):
|
||||||
|
python_bin = venv_dir / "bin" / "python"
|
||||||
|
if not python_bin.exists():
|
||||||
|
run([sys.executable, "-m", "venv", str(venv_dir)])
|
||||||
|
run([str(python_bin), "-m", "pip", "install", "selenium"])
|
||||||
|
return python_bin
|
||||||
|
|
||||||
|
|
||||||
|
def require_binary(name):
|
||||||
|
path = shutil.which(name)
|
||||||
|
if not path:
|
||||||
|
raise RuntimeError(f"required binary {name!r} was not found in PATH")
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
def find_geckodriver():
|
||||||
|
direct = shutil.which("geckodriver")
|
||||||
|
if direct:
|
||||||
|
return direct
|
||||||
|
cache_root = ORIGINAL_HOME / ".cache" / "selenium" / "geckodriver"
|
||||||
|
if cache_root.exists():
|
||||||
|
candidates = sorted(cache_root.glob("**/geckodriver"))
|
||||||
|
if candidates:
|
||||||
|
return str(candidates[-1])
|
||||||
|
raise RuntimeError("required binary 'geckodriver' was not found in PATH or Selenium cache")
|
||||||
|
|
||||||
|
|
||||||
|
def write_login_fixture(path: Path):
|
||||||
|
path.write_text(
|
||||||
|
textwrap.dedent(
|
||||||
|
"""\
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<body>
|
||||||
|
<form id="heist-login">
|
||||||
|
<label>Username <input id="username" type="text" autocomplete="username"></label>
|
||||||
|
<label>Password <input id="password" type="password" autocomplete="current-password"></label>
|
||||||
|
<button type="submit">Open Vault</button>
|
||||||
|
</form>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
"""
|
||||||
|
),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def build_bridge(binary_path: Path):
|
||||||
|
run(["go", "build", "-o", str(binary_path), "./cmd/keepassgo-browser-bridge"], cwd=REPO_ROOT)
|
||||||
|
|
||||||
|
|
||||||
|
def patch_validation_defaults(background_js: Path, grpc_addr: str):
|
||||||
|
data = background_js.read_text(encoding="utf-8")
|
||||||
|
data = data.replace('grpcAddress: "",', f'grpcAddress: "{grpc_addr}",', 1)
|
||||||
|
data = data.replace('bearerToken: ""', f'bearerToken: "{TOKEN}"', 1)
|
||||||
|
data += textwrap.dedent(
|
||||||
|
"""
|
||||||
|
|
||||||
|
;((api) => {
|
||||||
|
api.runtime.onMessage.addListener((message, _sender, sendResponse) => {
|
||||||
|
if (message?.type === "keepassgo-validation-ping") {
|
||||||
|
sendResponse({ ok: true });
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (message?.type === "keepassgo-validation-status") {
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
const settings = await loadSettings();
|
||||||
|
const status = await connectNative({
|
||||||
|
action: "status",
|
||||||
|
grpcAddress: settings.grpcAddress,
|
||||||
|
bearerToken: settings.bearerToken
|
||||||
|
});
|
||||||
|
sendResponse({ ok: true, settings, status });
|
||||||
|
} catch (error) {
|
||||||
|
sendResponse({ ok: false, error: String(error) });
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
})(globalThis.browser ?? globalThis.chrome);
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
background_js.write_text(data, encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def patch_validation_content(content_js: Path):
|
||||||
|
data = content_js.read_text(encoding="utf-8")
|
||||||
|
data += textwrap.dedent(
|
||||||
|
"""
|
||||||
|
|
||||||
|
;(() => {
|
||||||
|
const set = (name, value) => {
|
||||||
|
document.documentElement.setAttribute(name, String(value));
|
||||||
|
};
|
||||||
|
const api = globalThis.browser ?? globalThis.chrome;
|
||||||
|
set("data-keepassgo-validation-runtime-id", api?.runtime?.id || "");
|
||||||
|
const username = document.getElementById("username");
|
||||||
|
const focusTarget = username ? {
|
||||||
|
role: "username",
|
||||||
|
formIndex: 0,
|
||||||
|
fieldIndex: 0,
|
||||||
|
id: "username",
|
||||||
|
name: "",
|
||||||
|
autocomplete: "username"
|
||||||
|
} : null;
|
||||||
|
document.documentElement.setAttribute("data-keepassgo-validation-content", "loaded");
|
||||||
|
try {
|
||||||
|
if (api?.runtime?.sendMessage) {
|
||||||
|
Promise.resolve(api.runtime.sendMessage({ type: "keepassgo-validation-ping" }))
|
||||||
|
.then((response) => {
|
||||||
|
if (response?.ok) {
|
||||||
|
set("data-keepassgo-validation-background", "ok");
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
set("data-keepassgo-validation-background", String(error));
|
||||||
|
});
|
||||||
|
Promise.resolve(api.runtime.sendMessage({ type: "keepassgo-validation-status" }))
|
||||||
|
.then((response) => {
|
||||||
|
if (response?.ok) {
|
||||||
|
set("data-keepassgo-validation-native", JSON.stringify(response.status || {}));
|
||||||
|
set("data-keepassgo-validation-settings", JSON.stringify(response.settings || {}));
|
||||||
|
} else {
|
||||||
|
set("data-keepassgo-validation-native-error", response?.error || "unknown");
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
set("data-keepassgo-validation-native-error", String(error));
|
||||||
|
});
|
||||||
|
Promise.resolve(api.runtime.sendMessage({
|
||||||
|
type: "keepassgo-page-ready",
|
||||||
|
force: true,
|
||||||
|
pageHasLoginForm: true,
|
||||||
|
focusTarget,
|
||||||
|
signature: "validation"
|
||||||
|
}))
|
||||||
|
.then((response) => {
|
||||||
|
set("data-keepassgo-validation-page-ready", JSON.stringify(response || {}));
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
set("data-keepassgo-validation-page-ready-error", String(error));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
set("data-keepassgo-validation-background", String(error));
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
content_js.write_text(data, encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def prepare_chromium_extension(workspace: Path, grpc_addr: str):
|
||||||
|
ext_dir = workspace / "extension-chromium"
|
||||||
|
shutil.copytree(EXTENSION_SOURCE, ext_dir)
|
||||||
|
patch_validation_defaults(ext_dir / "background.js", grpc_addr)
|
||||||
|
patch_validation_content(ext_dir / "content.js")
|
||||||
|
|
||||||
|
key_pem = workspace / "extension-key.pem"
|
||||||
|
key_b64 = workspace / "extension-key.b64"
|
||||||
|
run(["openssl", "genrsa", "-out", str(key_pem), "2048"])
|
||||||
|
der = subprocess.check_output(["openssl", "rsa", "-in", str(key_pem), "-pubout", "-outform", "DER"])
|
||||||
|
key_b64.write_text(base64.b64encode(der).decode("utf-8"), encoding="utf-8")
|
||||||
|
|
||||||
|
manifest = json.loads((ext_dir / "manifest.chromium.json").read_text(encoding="utf-8"))
|
||||||
|
manifest["key"] = key_b64.read_text(encoding="utf-8").strip()
|
||||||
|
(ext_dir / "manifest.json").write_text(json.dumps(manifest, indent=2) + "\n", encoding="utf-8")
|
||||||
|
return ext_dir, key_b64
|
||||||
|
|
||||||
|
|
||||||
|
def prepare_firefox_extension(workspace: Path, grpc_addr: str):
|
||||||
|
ext_dir = workspace / "extension-firefox"
|
||||||
|
shutil.copytree(EXTENSION_SOURCE, ext_dir)
|
||||||
|
patch_validation_defaults(ext_dir / "background.js", grpc_addr)
|
||||||
|
patch_validation_content(ext_dir / "content.js")
|
||||||
|
manifest = json.loads((ext_dir / "manifest.firefox.json").read_text(encoding="utf-8"))
|
||||||
|
(ext_dir / "manifest.json").write_text(json.dumps(manifest, indent=2) + "\n", encoding="utf-8")
|
||||||
|
xpi_path = workspace / "keepassgo-firefox.xpi"
|
||||||
|
with zipfile.ZipFile(xpi_path, "w") as zf:
|
||||||
|
for path in ext_dir.iterdir():
|
||||||
|
if path.is_file() and path.name != "manifest.firefox.json":
|
||||||
|
zf.write(path, arcname=path.name)
|
||||||
|
return xpi_path
|
||||||
|
|
||||||
|
|
||||||
|
def install_chromium_native_host(workspace: Path, bridge_binary: Path, key_b64: Path):
|
||||||
|
home_dir = workspace / "home"
|
||||||
|
home_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
env = os.environ.copy()
|
||||||
|
env["HOME"] = str(home_dir)
|
||||||
|
env["XDG_CONFIG_HOME"] = str(home_dir / ".config")
|
||||||
|
result = run(
|
||||||
|
[
|
||||||
|
str(bridge_binary),
|
||||||
|
"install-native-host",
|
||||||
|
"--browser",
|
||||||
|
"chromium",
|
||||||
|
"--binary",
|
||||||
|
str(bridge_binary),
|
||||||
|
"--extension-key-file",
|
||||||
|
str(key_b64),
|
||||||
|
],
|
||||||
|
env=env,
|
||||||
|
)
|
||||||
|
manifest_path = Path(result.stdout.strip())
|
||||||
|
for mirror in [
|
||||||
|
home_dir / ".config" / "google-chrome" / "NativeMessagingHosts" / "com.keepassgo.browser.json",
|
||||||
|
home_dir / ".config" / "chromium-browser" / "NativeMessagingHosts" / "com.keepassgo.browser.json",
|
||||||
|
home_dir / ".config" / "chromium" / "chromium" / "NativeMessagingHosts" / "com.keepassgo.browser.json",
|
||||||
|
workspace / "chromium-profile" / "NativeMessagingHosts" / "com.keepassgo.browser.json",
|
||||||
|
]:
|
||||||
|
mirror.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
shutil.copyfile(manifest_path, mirror)
|
||||||
|
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
|
||||||
|
origin = manifest["allowed_origins"][0]
|
||||||
|
extension_id = re.search(r"chrome-extension://([^/]+)/", origin).group(1)
|
||||||
|
return extension_id, home_dir
|
||||||
|
|
||||||
|
|
||||||
|
def install_firefox_native_host(workspace: Path, bridge_binary: Path):
|
||||||
|
home_dir = workspace / "home"
|
||||||
|
home_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
env = os.environ.copy()
|
||||||
|
env["HOME"] = str(home_dir)
|
||||||
|
run(
|
||||||
|
[
|
||||||
|
str(bridge_binary),
|
||||||
|
"install-native-host",
|
||||||
|
"--browser",
|
||||||
|
"firefox",
|
||||||
|
"--binary",
|
||||||
|
str(bridge_binary),
|
||||||
|
],
|
||||||
|
env=env,
|
||||||
|
)
|
||||||
|
return home_dir
|
||||||
|
|
||||||
|
|
||||||
|
def launch_process(cmd, *, cwd=None, env=None, log_path=None):
|
||||||
|
handle = open(log_path, "w", encoding="utf-8") if log_path else subprocess.DEVNULL
|
||||||
|
return subprocess.Popen(cmd, cwd=cwd, env=env, stdout=handle, stderr=handle, text=True)
|
||||||
|
|
||||||
|
|
||||||
|
def wait_for_http(url: str, timeout: float = 10.0):
|
||||||
|
import urllib.request
|
||||||
|
|
||||||
|
deadline = time.time() + timeout
|
||||||
|
while time.time() < deadline:
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(url, timeout=1) as response:
|
||||||
|
if response.status == 200:
|
||||||
|
return
|
||||||
|
except Exception:
|
||||||
|
time.sleep(0.2)
|
||||||
|
raise RuntimeError(f"timed out waiting for HTTP endpoint {url}")
|
||||||
|
|
||||||
|
|
||||||
|
def wait_for_tcp(host: str, port: int, *, timeout: float = 20.0, process=None, log_path: Path | None = None, name: str = "TCP endpoint"):
|
||||||
|
deadline = time.time() + timeout
|
||||||
|
while time.time() < deadline:
|
||||||
|
if process is not None and process.poll() is not None:
|
||||||
|
details = ""
|
||||||
|
if log_path and log_path.exists():
|
||||||
|
details = f"\nlog:\n{log_path.read_text(encoding='utf-8')}"
|
||||||
|
raise RuntimeError(f"{name} exited before becoming ready{details}")
|
||||||
|
try:
|
||||||
|
with socket.create_connection((host, port), timeout=1):
|
||||||
|
return
|
||||||
|
except OSError:
|
||||||
|
time.sleep(0.2)
|
||||||
|
details = ""
|
||||||
|
if log_path and log_path.exists():
|
||||||
|
details = f"\nlog:\n{log_path.read_text(encoding='utf-8')}"
|
||||||
|
raise RuntimeError(f"timed out waiting for {name} on {host}:{port}{details}")
|
||||||
|
|
||||||
|
|
||||||
|
def save_artifact(path: Path, content: str):
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
path.write_text(content, encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def find_free_port() -> int:
|
||||||
|
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
|
||||||
|
sock.bind(("127.0.0.1", 0))
|
||||||
|
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||||||
|
return int(sock.getsockname()[1])
|
||||||
|
|
||||||
|
|
||||||
|
def run_chromium_flow(workspace: Path, extension_id: str, login_url: str):
|
||||||
|
from selenium import webdriver
|
||||||
|
from selenium.webdriver.chrome.options import Options
|
||||||
|
from selenium.webdriver.chrome.service import Service
|
||||||
|
from selenium.webdriver.common.by import By
|
||||||
|
from selenium.webdriver.support.ui import WebDriverWait
|
||||||
|
from selenium.webdriver.support import expected_conditions as EC
|
||||||
|
|
||||||
|
ext_dir = workspace / "extension-chromium"
|
||||||
|
options = Options()
|
||||||
|
options.binary_location = require_binary("chromium")
|
||||||
|
options.set_capability("goog:loggingPrefs", {"browser": "ALL"})
|
||||||
|
for arg in [
|
||||||
|
f"--user-data-dir={workspace / 'chromium-profile'}",
|
||||||
|
f"--load-extension={ext_dir}",
|
||||||
|
f"--disable-extensions-except={ext_dir}",
|
||||||
|
"--no-first-run",
|
||||||
|
"--no-default-browser-check",
|
||||||
|
"--disable-search-engine-choice-screen",
|
||||||
|
"--disable-gpu",
|
||||||
|
"--no-sandbox",
|
||||||
|
"--disable-dev-shm-usage",
|
||||||
|
]:
|
||||||
|
options.add_argument(arg)
|
||||||
|
driver = webdriver.Chrome(service=Service(require_binary("chromedriver")), options=options)
|
||||||
|
wait = WebDriverWait(driver, 25)
|
||||||
|
stage = "launch browser"
|
||||||
|
|
||||||
|
try:
|
||||||
|
stage = "open login page"
|
||||||
|
driver.get(login_url)
|
||||||
|
wait.until(EC.element_to_be_clickable((By.ID, "username"))).click()
|
||||||
|
stage = "wait for inline root"
|
||||||
|
wait.until(lambda d: d.execute_script("return !!document.querySelector('#keepassgo-inline-root')"))
|
||||||
|
stage = "wait for inline dock"
|
||||||
|
wait.until(
|
||||||
|
lambda d: d.execute_script(
|
||||||
|
"const root=document.querySelector('#keepassgo-inline-root');"
|
||||||
|
"const dock=root?.shadowRoot?.querySelector('.dock');"
|
||||||
|
"return !!(dock && getComputedStyle(dock).display !== 'none');"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
stage = "open inline chooser"
|
||||||
|
driver.execute_script(
|
||||||
|
"document.querySelector('#keepassgo-inline-root').shadowRoot.querySelector('.trigger').click()"
|
||||||
|
)
|
||||||
|
stage = "wait for chooser matches"
|
||||||
|
wait.until(
|
||||||
|
lambda d: d.execute_script(
|
||||||
|
"const root=document.querySelector('#keepassgo-inline-root');"
|
||||||
|
"return root.shadowRoot.querySelectorAll('.match').length || 0;"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
stage = "request browser fill"
|
||||||
|
driver.execute_script(
|
||||||
|
"document.querySelector('#keepassgo-inline-root').shadowRoot.querySelector('.match').click()"
|
||||||
|
)
|
||||||
|
stage = "wait for page approval prompt"
|
||||||
|
wait.until(
|
||||||
|
lambda d: "Approve or deny"
|
||||||
|
in d.execute_script(
|
||||||
|
"const root=document.querySelector('#keepassgo-inline-root');"
|
||||||
|
"return root.shadowRoot.querySelector('.match-list').textContent;"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
state_path = workspace / "state.txt"
|
||||||
|
deadline = time.time() + 10
|
||||||
|
while time.time() < deadline:
|
||||||
|
if state_path.read_text(encoding="utf-8").strip() == "pending":
|
||||||
|
break
|
||||||
|
time.sleep(0.2)
|
||||||
|
else:
|
||||||
|
raise RuntimeError("stub server never observed a pending approval state")
|
||||||
|
|
||||||
|
stage = "verify popup approval state"
|
||||||
|
target_tab_id = driver.execute_script(
|
||||||
|
"const raw = document.documentElement.getAttribute('data-keepassgo-validation-page-ready');"
|
||||||
|
"return raw ? JSON.parse(raw).tabId : null;"
|
||||||
|
)
|
||||||
|
if not target_tab_id:
|
||||||
|
raise RuntimeError("validation page did not expose a target tab id for popup state checks")
|
||||||
|
driver.switch_to.new_window("tab")
|
||||||
|
driver.get(f"chrome-extension://{extension_id}/popup.html?tabId={int(target_tab_id)}")
|
||||||
|
wait.until(lambda d: "Approval needed" in d.find_element(By.ID, "status-title").text)
|
||||||
|
|
||||||
|
stage = "approve fill and wait for completion"
|
||||||
|
state_path.write_text("approved", encoding="utf-8")
|
||||||
|
driver.switch_to.window(driver.window_handles[0])
|
||||||
|
wait.until(lambda d: d.find_element(By.ID, "username").get_attribute("value") == "dannyocean")
|
||||||
|
wait.until(lambda d: d.find_element(By.ID, "password").get_attribute("value") == "token-1")
|
||||||
|
return True
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
artifacts = workspace / "artifacts"
|
||||||
|
save_artifact(artifacts / "chromium-page.html", driver.page_source)
|
||||||
|
try:
|
||||||
|
driver.save_screenshot(str(artifacts / "chromium-page.png"))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
save_artifact(artifacts / "chromium-browser.log", json.dumps(driver.get_log("browser"), indent=2))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
raise RuntimeError(f"chromium validation failed during {stage}: {type(exc).__name__}: {exc}") from exc
|
||||||
|
finally:
|
||||||
|
driver.quit()
|
||||||
|
|
||||||
|
|
||||||
|
def run_firefox_flow(workspace: Path, login_url: str):
|
||||||
|
from selenium import webdriver
|
||||||
|
from selenium.webdriver.firefox.options import Options
|
||||||
|
from selenium.webdriver.firefox.service import Service
|
||||||
|
from selenium.webdriver.common.by import By
|
||||||
|
from selenium.webdriver.support.ui import WebDriverWait
|
||||||
|
from selenium.webdriver.support import expected_conditions as EC
|
||||||
|
|
||||||
|
xpi_path = workspace / "keepassgo-firefox.xpi"
|
||||||
|
options = Options()
|
||||||
|
options.binary_location = require_binary("firefox")
|
||||||
|
options.add_argument("-headless")
|
||||||
|
service = Service(find_geckodriver())
|
||||||
|
driver = webdriver.Firefox(service=service, options=options)
|
||||||
|
wait = WebDriverWait(driver, 25)
|
||||||
|
stage = "launch firefox"
|
||||||
|
try:
|
||||||
|
stage = "install temporary addon"
|
||||||
|
addon_id = driver.install_addon(str(xpi_path), temporary=True)
|
||||||
|
if addon_id != "browser@keepassgo.com":
|
||||||
|
raise RuntimeError(f"unexpected addon id {addon_id!r}")
|
||||||
|
stage = "open login page"
|
||||||
|
driver.get(login_url)
|
||||||
|
wait.until(EC.element_to_be_clickable((By.ID, "username"))).click()
|
||||||
|
stage = "wait for inline root"
|
||||||
|
wait.until(lambda d: d.execute_script("return !!document.querySelector('#keepassgo-inline-root')"))
|
||||||
|
stage = "wait for inline dock"
|
||||||
|
wait.until(
|
||||||
|
lambda d: d.execute_script(
|
||||||
|
"const root=document.querySelector('#keepassgo-inline-root');"
|
||||||
|
"const dock=root?.shadowRoot?.querySelector('.dock');"
|
||||||
|
"return !!(dock && getComputedStyle(dock).display !== 'none');"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
stage = "open inline chooser"
|
||||||
|
driver.execute_script(
|
||||||
|
"document.querySelector('#keepassgo-inline-root').shadowRoot.querySelector('.trigger').click()"
|
||||||
|
)
|
||||||
|
stage = "wait for chooser matches"
|
||||||
|
wait.until(
|
||||||
|
lambda d: d.execute_script(
|
||||||
|
"const root=document.querySelector('#keepassgo-inline-root');"
|
||||||
|
"return root.shadowRoot.querySelectorAll('.match').length || 0;"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
stage = "request browser fill"
|
||||||
|
driver.execute_script(
|
||||||
|
"document.querySelector('#keepassgo-inline-root').shadowRoot.querySelector('.match').click()"
|
||||||
|
)
|
||||||
|
stage = "wait for page approval prompt"
|
||||||
|
wait.until(
|
||||||
|
lambda d: "Approve or deny"
|
||||||
|
in d.execute_script(
|
||||||
|
"const root=document.querySelector('#keepassgo-inline-root');"
|
||||||
|
"return root.shadowRoot.querySelector('.match-list').textContent;"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
state_path = workspace / "state.txt"
|
||||||
|
deadline = time.time() + 10
|
||||||
|
while time.time() < deadline:
|
||||||
|
if state_path.read_text(encoding="utf-8").strip() == "pending":
|
||||||
|
break
|
||||||
|
time.sleep(0.2)
|
||||||
|
else:
|
||||||
|
raise RuntimeError("stub server never observed a pending approval state")
|
||||||
|
stage = "approve fill and wait for completion"
|
||||||
|
state_path.write_text("approved", encoding="utf-8")
|
||||||
|
wait.until(lambda d: d.find_element(By.ID, "username").get_attribute("value") == "dannyocean")
|
||||||
|
wait.until(lambda d: d.find_element(By.ID, "password").get_attribute("value") == "token-1")
|
||||||
|
return True
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
artifacts = workspace / "artifacts"
|
||||||
|
save_artifact(artifacts / "firefox-page.html", driver.page_source)
|
||||||
|
try:
|
||||||
|
driver.save_screenshot(str(artifacts / "firefox-page.png"))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
raise RuntimeError(f"firefox validation failed during {stage}: {type(exc).__name__}: {exc}") from exc
|
||||||
|
finally:
|
||||||
|
driver.quit()
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser(description="Validate the browser-extension flow with isolated real-browser harnesses.")
|
||||||
|
parser.add_argument("--browser", choices=["firefox", "chromium", "both"], default="firefox")
|
||||||
|
parser.add_argument("--keep-workspace", action="store_true")
|
||||||
|
parser.add_argument("--workspace", help=argparse.SUPPRESS)
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
workspace = Path(args.workspace) if args.workspace else Path(tempfile.mkdtemp(prefix="keepassgo-browser-validate."))
|
||||||
|
workspace.joinpath("home").mkdir(parents=True, exist_ok=True)
|
||||||
|
workspace.joinpath("web").mkdir(parents=True, exist_ok=True)
|
||||||
|
if not args.workspace:
|
||||||
|
workspace.joinpath("state.txt").write_text("idle", encoding="utf-8")
|
||||||
|
write_login_fixture(workspace / "web" / "login.html")
|
||||||
|
|
||||||
|
python_bin = ensure_selenium_venv(workspace / "venv")
|
||||||
|
if Path(sys.executable) != python_bin:
|
||||||
|
cmd = [str(python_bin), str(Path(__file__).resolve()), "--workspace", str(workspace), "--browser", args.browser]
|
||||||
|
if args.keep_workspace:
|
||||||
|
cmd.append("--keep-workspace")
|
||||||
|
raise SystemExit(subprocess.run(cmd, cwd=REPO_ROOT).returncode)
|
||||||
|
|
||||||
|
bridge_binary = workspace / "keepassgo-browser-bridge"
|
||||||
|
build_bridge(bridge_binary)
|
||||||
|
http_port = find_free_port()
|
||||||
|
grpc_port = find_free_port()
|
||||||
|
login_url = f"http://127.0.0.1:{http_port}/login.html"
|
||||||
|
grpc_addr = f"tcp://127.0.0.1:{grpc_port}"
|
||||||
|
ext_dir_chromium = xpi_path = None
|
||||||
|
if args.browser in {"chromium", "both"}:
|
||||||
|
ext_dir_chromium, key_b64 = prepare_chromium_extension(workspace, grpc_addr)
|
||||||
|
chromium_id, chromium_home = install_chromium_native_host(workspace, bridge_binary, key_b64)
|
||||||
|
if args.browser in {"firefox", "both"}:
|
||||||
|
xpi_path = prepare_firefox_extension(workspace, grpc_addr)
|
||||||
|
firefox_home = install_firefox_native_host(workspace, bridge_binary)
|
||||||
|
|
||||||
|
home_dir = workspace / "home"
|
||||||
|
env = os.environ.copy()
|
||||||
|
env["HOME"] = str(home_dir)
|
||||||
|
env["XDG_CONFIG_HOME"] = str(home_dir / ".config")
|
||||||
|
env["CHROME_CONFIG_HOME"] = str(home_dir / ".config")
|
||||||
|
os.environ["HOME"] = str(home_dir)
|
||||||
|
os.environ["XDG_CONFIG_HOME"] = env["XDG_CONFIG_HOME"]
|
||||||
|
os.environ["CHROME_CONFIG_HOME"] = env["CHROME_CONFIG_HOME"]
|
||||||
|
http_server = launch_process(
|
||||||
|
[sys.executable, "-m", "http.server", str(http_port)],
|
||||||
|
cwd=workspace / "web",
|
||||||
|
env=env,
|
||||||
|
log_path=workspace / "http.log",
|
||||||
|
)
|
||||||
|
stub_server = launch_process(
|
||||||
|
[
|
||||||
|
"go",
|
||||||
|
"run",
|
||||||
|
str(STUB_SERVER),
|
||||||
|
"--listen",
|
||||||
|
f"127.0.0.1:{grpc_port}",
|
||||||
|
"--state",
|
||||||
|
str(workspace / "state.txt"),
|
||||||
|
"--page-url",
|
||||||
|
login_url,
|
||||||
|
],
|
||||||
|
cwd=REPO_ROOT,
|
||||||
|
env=env,
|
||||||
|
log_path=workspace / "stub.log",
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
wait_for_http(login_url)
|
||||||
|
wait_for_tcp("127.0.0.1", grpc_port, process=stub_server, log_path=workspace / "stub.log", name="validation gRPC server")
|
||||||
|
browser_results = []
|
||||||
|
if args.browser in {"firefox", "both"}:
|
||||||
|
browser_results.append("firefox")
|
||||||
|
run_firefox_flow(workspace, login_url)
|
||||||
|
workspace.joinpath("state.txt").write_text("idle", encoding="utf-8")
|
||||||
|
if args.browser in {"chromium", "both"}:
|
||||||
|
browser_results.append("chromium")
|
||||||
|
run_chromium_flow(workspace, chromium_id, login_url)
|
||||||
|
print(f"browser validation passed for {', '.join(browser_results)}; workspace: {workspace}", flush=True)
|
||||||
|
if not args.keep_workspace:
|
||||||
|
shutil.rmtree(workspace, ignore_errors=True)
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
print(f"{exc}\nworkspace preserved at {workspace}", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
finally:
|
||||||
|
for process in [stub_server, http_server]:
|
||||||
|
if process.poll() is None:
|
||||||
|
process.terminate()
|
||||||
|
try:
|
||||||
|
process.wait(timeout=3)
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
process.kill()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
Reference in New Issue
Block a user