From d522af7d51f54c55fd9942fa4c92d33010e04e53 Mon Sep 17 00:00:00 2001 From: Joe Julian Date: Sat, 11 Apr 2026 23:45:48 -0700 Subject: [PATCH] Complete browser extension gRPC flow --- Makefile | 8 +- browser/extension/README.md | 20 +- browser/extension/background.js | 719 ++++++++++++++++-- browser/extension/background.test.cjs | 54 ++ browser/extension/content.js | 641 +++++++++++++++- browser/extension/content.test.cjs | 33 + browser/extension/manifest.firefox.json | 17 +- browser/extension/options.js | 4 + browser/extension/popup.html | 1 + browser/extension/popup.js | 100 ++- browser/extension/style.css | 4 + cmd/keepassgo-browser-bridge/main.go | 12 +- docs/browser-extension.md | 38 +- internal/api/server.go | 19 +- internal/api/server_test.go | 73 ++ internal/appui/runtime.go | 13 + internal/browserbridge/bridge.go | 48 +- internal/browserbridge/bridge_test.go | 145 ++++ internal/browserbridge/nativehost_autoreg.go | 210 +++++ .../archlinux/keepassgo-git/PKGBUILD.tmpl | 20 + proto/keepassgo/v1/keepassgo.pb.go | 34 +- proto/keepassgo/v1/keepassgo.proto | 2 + .../browser_extension_validation_server.go | 109 +++ scripts/validate_browser_extension.py | 611 +++++++++++++++ 24 files changed, 2744 insertions(+), 191 deletions(-) create mode 100644 browser/extension/background.test.cjs create mode 100644 browser/extension/content.test.cjs create mode 100644 internal/browserbridge/nativehost_autoreg.go create mode 100644 scripts/browser_extension_validation_server.go create mode 100644 scripts/validate_browser_extension.py diff --git a/Makefile b/Makefile index b16aa4f..320b7b8 100644 --- a/Makefile +++ b/Makefile @@ -25,7 +25,7 @@ ifneq ($(strip $(SIGNPASS)),) GOGIO_SIGN_FLAGS += -signpass $(SIGNPASS) endif -.PHONY: apk archlinux-pkgbuild browser-bridge +.PHONY: apk archlinux-pkgbuild browser-bridge browser-extension-validate apk: android/keepassgo-android.jar @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; } @@ -71,3 +71,9 @@ archlinux-pkgbuild: $(ARCH_PKG_TMPL) Makefile 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),) diff --git a/browser/extension/README.md b/browser/extension/README.md index 0da7df8..9d63b9c 100644 --- a/browser/extension/README.md +++ b/browser/extension/README.md @@ -2,10 +2,26 @@ 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.chromium.json` is the Chromium/Chrome manifest template -- `background.js` talks to the native messaging host `com.keepassgo.browser` -- `content.js` fills username and password fields on the current page +- `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, 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 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. diff --git a/browser/extension/background.js b/browser/extension/background.js index d77862f..0ba7afd 100644 --- a/browser/extension/background.js +++ b/browser/extension/background.js @@ -1,11 +1,22 @@ const ext = globalThis.browser ?? globalThis.chrome; const nativeHost = "com.keepassgo.browser"; +const isNodeTestEnv = typeof module !== "undefined" && module.exports; +const usePromiseAPI = typeof globalThis.browser !== "undefined"; const defaultSettings = { grpcAddress: "", 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) { + if (usePromiseAPI) { + return ext.storage.local.get(keys); + } return new Promise((resolve, reject) => { ext.storage.local.get(keys, (value) => { const error = ext.runtime.lastError; @@ -19,6 +30,9 @@ function storageGet(keys) { } function storageSet(value) { + if (usePromiseAPI) { + return ext.storage.local.set(value); + } return new Promise((resolve, reject) => { ext.storage.local.set(value, () => { 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) { + if (usePromiseAPI) { + return ext.tabs.query(query); + } return new Promise((resolve, reject) => { ext.tabs.query(query, (tabs) => { 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) { + if (usePromiseAPI) { + return ext.tabs.sendMessage(tabId, message); + } return new Promise((resolve, reject) => { ext.tabs.sendMessage(tabId, message, (response) => { const error = ext.runtime.lastError; @@ -58,6 +158,9 @@ function tabsSendMessage(tabId, message) { } function connectNative(message) { + if (usePromiseAPI) { + return ext.runtime.sendNativeMessage(nativeHost, message); + } return new Promise((resolve, reject) => { ext.runtime.sendNativeMessage(nativeHost, message, (response) => { const error = ext.runtime.lastError; @@ -74,122 +177,574 @@ async function loadSettings() { const stored = await storageGet(["grpcAddress", "bearerToken"]); return { 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() { const [tab] = await tabsQuery({ active: true, currentWindow: true }); return { - tabId: tab?.id ?? null, + tabId: Number.isInteger(tab?.id) ? tab.id : null, url: typeof tab?.url === "string" ? tab.url : "" }; } -async function statusForPage() { - const settings = await loadSettings(); - const page = await activePageContext(); +async function scanTabForLoginForm(tabId) { + if (!Number.isInteger(tabId)) { + 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) { return { success: false, configured: false, status: null, - pageUrl: page.url, - matches: [], error: "Set an API token in extension settings." }; } - const status = await connectNative({ action: "status", grpcAddress: settings.grpcAddress, bearerToken: settings.bearerToken }); - if (!status.success || status.status?.locked || !page.url.startsWith("http")) { - return { - success: status.success, + return { + success: Boolean(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, - status: status.status ?? null, - pageUrl: page.url, - matches: [], - error: status.error ?? "" + status: null, + error: describeError(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 : "" }; } - - const matches = await connectNative({ - action: "find-logins", - grpcAddress: settings.grpcAddress, - bearerToken: settings.bearerToken, - url: page.url - }); - return { - success: matches.success, - configured: true, - status: matches.status ?? status.status ?? null, - pageUrl: page.url, - matches: matches.matches ?? [], - error: matches.error ?? "" - }; + if (page.tabId == null) { + return defaultPageState(null, page.url); + } + if (!options.force) { + const cached = await getPageState(page.tabId, page.url); + if (cached.pageUrl === page.url && cached.updatedAt && !cached.pendingFill) { + return cached; + } + } + return refreshPageState(page.tabId, page.url, options); } -async function fillLogin(entryId) { - const settings = await loadSettings(); - const page = await activePageContext(); - if (!settings.bearerToken) { - throw new Error("API token is not configured."); - } - if (page.tabId == null) { +async function fillLogin(tabId, entryId) { + if (!Number.isInteger(tabId)) { throw new Error("No active tab is available."); } - - const response = await connectNative({ - action: "get-login", - grpcAddress: settings.grpcAddress, - bearerToken: settings.bearerToken, - entryId, - url: page.url - }); - if (!response.success || !response.credential) { - throw new Error(response.error || "KeePassGO did not return a credential."); + const tab = await tabsGet(tabId); + const pageUrl = typeof tab?.url === "string" ? tab.url : ""; + if (!supportsPageStateURL(pageUrl)) { + throw new Error("This page cannot be filled."); } - const fillResponse = await tabsSendMessage(page.tabId, { - type: "keepassgo-fill-credential", - credential: response.credential + let state = await getPageState(tabId, pageUrl); + state = await setPageState(tabId, { + ...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) { - throw new Error(fillResponse?.error || "The current page could not be filled."); + schedulePendingPoll(tabId, pageUrl); + + 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 () => { - switch (message?.type) { - case "keepassgo-popup-state": - sendResponse(await statusForPage()); - return; - case "keepassgo-fill-entry": - sendResponse({ success: true, ...(await fillLogin(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() - }); - sendResponse({ success: true }); - return; - default: - sendResponse({ success: false, error: `Unsupported message ${message?.type || ""}`.trim() }); - } - })().catch((error) => { - sendResponse({ success: false, error: error instanceof Error ? error.message : String(error) }); +async function refreshActivePage(options = {}) { + const page = await activePageContext(); + if (page.tabId == null) { + return defaultPageState(null, page.url); + } + return refreshPageState(page.tabId, page.url, options); +} + +const backgroundTestExports = { + normalizePageState, + actionPresentationForState, + shouldReuseMatches, + tokenPendingApprovalCount, + defaultSettings +}; + +if (isNodeTestEnv) { + module.exports = backgroundTestExports; +} else { + ext.runtime.onMessage.addListener((message, sender, sendResponse) => { + (async () => { + switch (message?.type) { + case "keepassgo-popup-state": + 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); + }); +} diff --git a/browser/extension/background.test.cjs b/browser/extension/background.test.cjs new file mode 100644 index 0000000..c6d7e4e --- /dev/null +++ b/browser/extension/background.test.cjs @@ -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, ""); +}); diff --git a/browser/extension/content.js b/browser/extension/content.js index abc3e53..e56b31a 100644 --- a/browser/extension/content.js +++ b/browser/extension/content.js @@ -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) { if (!(input instanceof HTMLInputElement)) { return false; @@ -12,6 +32,38 @@ function isVisibleInput(input) { 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) { if (typeof InputEvent === "function") { input.dispatchEvent(new InputEvent("input", { bubbles: true, data: input.value, inputType: "insertText" })); @@ -31,31 +83,145 @@ function setInputValue(input, value) { input.value = value; } -function findPasswordInput() { - return Array.from(document.querySelectorAll('input[type="password"]')).find(isVisibleInput) || null; +function visibleInputs(scope) { + return Array.from(scope.querySelectorAll("input")).filter(isVisibleInput); } -function findUsernameInput(passwordInput) { - const form = passwordInput?.form || null; +function resolveFormInputs(anchorInput) { + 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 candidates = Array.from(scope.querySelectorAll('input[type="text"], input[type="email"], input:not([type]), input[autocomplete="username"], input[autocomplete="email"]')) - .filter(isVisibleInput); - if (passwordInput) { - const sameForm = candidates.filter((input) => input.form === passwordInput.form); - if (sameForm.length !== 0) { - const priorSibling = sameForm.find((input) => - typeof input.compareDocumentPosition === "function" && - Boolean(input.compareDocumentPosition(passwordInput) & Node.DOCUMENT_POSITION_FOLLOWING) - ); - return priorSibling || sameForm[0]; + const inputs = visibleInputs(scope); + const fieldIndex = inputs.indexOf(input); + const forms = Array.from(document.forms || []); + return { + role: normalizedRole, + formIndex: form ? forms.indexOf(form) : -1, + fieldIndex, + id: String(input.id || ""), + name: String(input.name || ""), + 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) { - const passwordInput = findPasswordInput(); - const usernameInput = findUsernameInput(passwordInput); +function chooseFillTargets(targetDescriptor) { + const anchorInput = resolveFieldDescriptor(targetDescriptor) || (document.activeElement instanceof HTMLInputElement ? document.activeElement : null); + 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) { usernameInput.focus(); @@ -74,14 +240,437 @@ function fillCredential(credential) { return { ok: true }; } -(globalThis.browser ?? globalThis.chrome).runtime.onMessage.addListener((message, _sender, sendResponse) => { - if (message?.type !== "keepassgo-fill-credential") { +function domainLabel(rawURL) { + 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; } - try { - sendResponse(fillCredential(message.credential || {})); - } catch (error) { - sendResponse({ ok: false, error: error instanceof Error ? error.message : String(error) }); + return Boolean( + state?.pageHasLoginForm && + ( + 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 = ` + +
+ +
+
+
KeePassGO suggestions
+
Select a matching login for this field.
+
+
+
+
+ `; + + 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); +} diff --git a/browser/extension/content.test.cjs b/browser/extension/content.test.cjs new file mode 100644 index 0000000..bffc359 --- /dev/null +++ b/browser/extension/content.test.cjs @@ -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); +}); diff --git a/browser/extension/manifest.firefox.json b/browser/extension/manifest.firefox.json index 5612408..c0ffb49 100644 --- a/browser/extension/manifest.firefox.json +++ b/browser/extension/manifest.firefox.json @@ -1,15 +1,20 @@ { - "manifest_version": 3, + "manifest_version": 2, "name": "KeePassGO Browser", "version": "0.1.0", "description": "Fill credentials from KeePassGO over the local gRPC API.", - "permissions": ["activeTab", "nativeMessaging", "storage", "tabs"], - "host_permissions": ["http://*/*", "https://*/*"], + "permissions": [ + "activeTab", + "nativeMessaging", + "storage", + "tabs", + "http://*/*", + "https://*/*" + ], "background": { - "scripts": ["background.js"], - "service_worker": "background.js" + "scripts": ["background.js"] }, - "action": { + "browser_action": { "default_title": "KeePassGO Browser", "default_popup": "popup.html" }, diff --git a/browser/extension/options.js b/browser/extension/options.js index ef472c8..7185c17 100644 --- a/browser/extension/options.js +++ b/browser/extension/options.js @@ -1,6 +1,10 @@ const extOptions = globalThis.browser ?? globalThis.chrome; +const usePromiseAPI = typeof globalThis.browser !== "undefined"; function runtimeSend(message) { + if (usePromiseAPI) { + return extOptions.runtime.sendMessage(message); + } return new Promise((resolve, reject) => { extOptions.runtime.sendMessage(message, (response) => { const error = extOptions.runtime.lastError; diff --git a/browser/extension/popup.html b/browser/extension/popup.html index b5a6c2c..8df2a02 100644 --- a/browser/extension/popup.html +++ b/browser/extension/popup.html @@ -19,6 +19,7 @@ Loading

Checking KeePassGO.

+

Loading page state.

Matches

diff --git a/browser/extension/popup.js b/browser/extension/popup.js index b6bd51e..71c01b8 100644 --- a/browser/extension/popup.js +++ b/browser/extension/popup.js @@ -1,6 +1,10 @@ const extPopup = globalThis.browser ?? globalThis.chrome; +const usePromiseAPI = typeof globalThis.browser !== "undefined"; function runtimeSend(message) { + if (usePromiseAPI) { + return extPopup.runtime.sendMessage(message); + } return new Promise((resolve, reject) => { extPopup.runtime.sendMessage(message, (response) => { const error = extPopup.runtime.lastError; @@ -28,13 +32,27 @@ function setStatus(title, message, tone) { 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) { const root = document.getElementById("matches"); + const targetTabID = popupTabID(); root.textContent = ""; if (!Array.isArray(state.matches) || state.matches.length === 0) { const empty = document.createElement("p"); 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); return; } @@ -43,17 +61,29 @@ function renderMatches(state) { const row = document.createElement("button"); row.type = "button"; row.className = "match-row"; - row.innerHTML = ` - - ${match.title} - ${match.username || "No username"} - - ${match.quality || ""} - `; + const main = document.createElement("span"); + main.className = "match-main"; + const title = document.createElement("strong"); + title.textContent = match.title; + const subtitle = document.createElement("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.disabled = true; + setStatus("Approval may be required", "KeePassGO will prompt if this token needs approval before fill.", "warning"); try { - const result = await runtimeSend({ type: "keepassgo-fill-entry", entryId: match.id }); + const result = await runtimeSend({ + type: "keepassgo-fill-entry", + entryId: match.id, + tabId: targetTabID + }); if (!result?.success) { 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() { 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 || ""); + renderPageHint(state); if (!state.configured) { setStatus("Configure access", state.error || "Set the API token in extension settings.", "warning"); renderMatches({ matches: [] }); return; } + if (state.pendingFill) { + setStatus("Approval needed", state.pendingMessage || "Approve or deny the fill request in KeePassGO.", "warning"); + renderMatches(state); + return; + } if (!state.success) { setStatus("KeePassGO unavailable", state.error || "The native host could not reach KeePassGO.", "error"); - renderMatches({ matches: [] }); + renderMatches(state); return; } if (state.status?.locked) { - setStatus("Vault locked", "Unlock KeePassGO, then open the popup again.", "warning"); - renderMatches({ matches: [] }); + setStatus("Vault locked", "Unlock KeePassGO, then try the page again.", "warning"); + renderMatches(state); return; } 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); } catch (error) { setStatus("Error", error instanceof Error ? error.message : String(error), "error"); diff --git a/browser/extension/style.css b/browser/extension/style.css index 3fc7536..63228d5 100644 --- a/browser/extension/style.css +++ b/browser/extension/style.css @@ -86,6 +86,10 @@ h2 { background: #fcf1f1; } +.inline-hint { + margin: -6px 0 16px; +} + .match-list { display: flex; flex-direction: column; diff --git a/cmd/keepassgo-browser-bridge/main.go b/cmd/keepassgo-browser-bridge/main.go index d20795b..d350a5b 100644 --- a/cmd/keepassgo-browser-bridge/main.go +++ b/cmd/keepassgo-browser-bridge/main.go @@ -6,8 +6,6 @@ import ( "flag" "fmt" "os" - "os/exec" - "path/filepath" "runtime" "strings" @@ -126,15 +124,7 @@ func runNativeMessage() error { } func defaultBinaryPath() (string, error) { - self, err := os.Executable() - 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") + return browserbridge.ResolveBridgeBinaryPath("") } func fail(err error) { diff --git a/docs/browser-extension.md b/docs/browser-extension.md index 76c3e68..def0d0d 100644 --- a/docs/browser-extension.md +++ b/docs/browser-extension.md @@ -50,6 +50,8 @@ Build the 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: ```bash @@ -81,10 +83,38 @@ Firefox: Chromium / Chrome: -1. Load `browser/extension/` with `manifest.chromium.json`. -2. Note the extension id the browser assigns. -3. Install the native host manifest with that extension id. -4. Configure the gRPC address and API token in the extension settings page. +1. Load a Chromium manifest based on `browser/extension/manifest.chromium.json`, or install the published extension when that distribution exists. +2. Start KeePassGO once so it can refresh the native host manifest for the discovered extension id. +3. 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 diff --git a/internal/api/server.go b/internal/api/server.go index 1973917..2d42c54 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -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) { - if _, err := s.authenticateRequest(ctx); err != nil { + token, err := s.authenticateRequest(ctx) + if err != nil { return nil, err } s.mu.RLock() defer s.mu.RUnlock() + pendingApprovals := s.approvals.Pending() + var tokenPending uint32 + for _, pending := range pendingApprovals { + if pending.TokenID == token.ID { + tokenPending++ + } + } + return &keepassgov1.GetSessionStatusResponse{ - Locked: s.locked, - Dirty: s.dirty, - EntryCount: uint32(len(s.model.Entries)), + Locked: s.locked, + Dirty: s.dirty, + EntryCount: uint32(len(s.model.Entries)), + PendingApprovalCount: uint32(len(pendingApprovals)), + TokenPendingApprovalCount: tokenPending, }, nil } diff --git a/internal/api/server_test.go b/internal/api/server_test.go index 9f39597..fe5fa28 100644 --- a/internal/api/server_test.go +++ b/internal/api/server_test.go @@ -5,6 +5,7 @@ import ( "context" "crypto/sha256" "encoding/hex" + "errors" "net" "os" "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) { t.Parallel() diff --git a/internal/appui/runtime.go b/internal/appui/runtime.go index 612e287..9f6b474 100644 --- a/internal/appui/runtime.go +++ b/internal/appui/runtime.go @@ -16,6 +16,7 @@ import ( "git.julianfamily.org/keepassgo/internal/apiapproval" "git.julianfamily.org/keepassgo/internal/apitokens" "git.julianfamily.org/keepassgo/internal/appui/platform" + "git.julianfamily.org/keepassgo/internal/browserbridge" "git.julianfamily.org/keepassgo/internal/grpcaddr" "git.julianfamily.org/keepassgo/internal/passwords" "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 { + ensureBrowserNativeHosts() var ops op.Ops manager := &session.Manager{} 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 { server *api.Server } diff --git a/internal/browserbridge/bridge.go b/internal/browserbridge/bridge.go index d85ec1e..0bc7e8e 100644 --- a/internal/browserbridge/bridge.go +++ b/internal/browserbridge/bridge.go @@ -42,11 +42,13 @@ type Response struct { } type Status struct { - Connected bool `json:"connected"` - Locked bool `json:"locked"` - Dirty bool `json:"dirty,omitempty"` - EntryCount uint32 `json:"entryCount,omitempty"` - GRPCAddress string `json:"grpcAddress,omitempty"` + Connected bool `json:"connected"` + Locked bool `json:"locked"` + Dirty bool `json:"dirty,omitempty"` + EntryCount uint32 `json:"entryCount,omitempty"` + PendingApprovalCount uint32 `json:"pendingApprovalCount,omitempty"` + TokenPendingApprovalCount uint32 `json:"tokenPendingApprovalCount,omitempty"` + GRPCAddress string `json:"grpcAddress,omitempty"` } type Match struct { @@ -202,11 +204,13 @@ func statusResponse(ctx context.Context, client Client, addr string) (*Status, e return nil, err } return &Status{ - Connected: true, - Locked: resp.GetLocked(), - Dirty: resp.GetDirty(), - EntryCount: resp.GetEntryCount(), - GRPCAddress: strings.TrimSpace(addr), + Connected: true, + Locked: resp.GetLocked(), + Dirty: resp.GetDirty(), + EntryCount: resp.GetEntryCount(), + PendingApprovalCount: resp.GetPendingApprovalCount(), + TokenPendingApprovalCount: resp.GetTokenPendingApprovalCount(), + GRPCAddress: strings.TrimSpace(addr), }, nil } @@ -324,27 +328,5 @@ func DefaultManifestPath(browser Browser) (string, error) { } func InstallManifest(browser Browser, binaryPath, extensionID, outputPath string) (string, error) { - manifest, err := Manifest(browser, binaryPath, extensionID) - 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 + return InstallManifestSet(browser, binaryPath, []string{strings.TrimSpace(extensionID)}, outputPath) } diff --git a/internal/browserbridge/bridge_test.go b/internal/browserbridge/bridge_test.go index b929bff..15c79e8 100644 --- a/internal/browserbridge/bridge_test.go +++ b/internal/browserbridge/bridge_test.go @@ -8,6 +8,7 @@ import ( "os" "path/filepath" "runtime" + "slices" "strings" "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) { 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 { status *keepassgov1.GetSessionStatusResponse matches []*keepassgov1.BrowserLoginMatch @@ -188,6 +288,51 @@ type fakeClient struct { 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) { if f.err != nil { return nil, f.err diff --git a/internal/browserbridge/nativehost_autoreg.go b/internal/browserbridge/nativehost_autoreg.go new file mode 100644 index 0000000..f1c99b4 --- /dev/null +++ b/internal/browserbridge/nativehost_autoreg.go @@ -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 +} diff --git a/packaging/archlinux/keepassgo-git/PKGBUILD.tmpl b/packaging/archlinux/keepassgo-git/PKGBUILD.tmpl index 9bd7a05..ed9e2e2 100644 --- a/packaging/archlinux/keepassgo-git/PKGBUILD.tmpl +++ b/packaging/archlinux/keepassgo-git/PKGBUILD.tmpl @@ -50,6 +50,26 @@ package() { install -Dm755 keepassgo "${pkgdir}/usr/bin/keepassgo" 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 \ "${pkgdir}/usr/share/icons/hicolor/512x512/apps/keepassgo.png" install -Dm644 internal/assets/keepassgo-icon.svg \ diff --git a/proto/keepassgo/v1/keepassgo.pb.go b/proto/keepassgo/v1/keepassgo.pb.go index c798612..0b3bcbc 100644 --- a/proto/keepassgo/v1/keepassgo.pb.go +++ b/proto/keepassgo/v1/keepassgo.pb.go @@ -58,12 +58,14 @@ func (*GetSessionStatusRequest) Descriptor() ([]byte, []int) { } type GetSessionStatusResponse struct { - state protoimpl.MessageState `protogen:"open.v1"` - Locked bool `protobuf:"varint,1,opt,name=locked,proto3" json:"locked,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"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + state protoimpl.MessageState `protogen:"open.v1"` + Locked bool `protobuf:"varint,1,opt,name=locked,proto3" json:"locked,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"` + PendingApprovalCount uint32 `protobuf:"varint,4,opt,name=pending_approval_count,json=pendingApprovalCount,proto3" json:"pending_approval_count,omitempty"` + 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() { @@ -117,6 +119,20 @@ func (x *GetSessionStatusResponse) GetEntryCount() uint32 { 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 { state protoimpl.MessageState `protogen:"open.v1"` 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 = "" + "\n" + "\"proto/keepassgo/v1/keepassgo.proto\x12\fkeepassgo.v1\"\x19\n" + - "\x17GetSessionStatusRequest\"i\n" + + "\x17GetSessionStatusRequest\"\xe0\x01\n" + "\x18GetSessionStatusResponse\x12\x16\n" + "\x06locked\x18\x01 \x01(\bR\x06locked\x12\x14\n" + "\x05dirty\x18\x02 \x01(\bR\x05dirty\x12\x1f\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" + "\x04path\x18\x01 \x01(\tR\x04path\x12\x1a\n" + "\bpassword\x18\x02 \x01(\tR\bpassword\x12\"\n" + diff --git a/proto/keepassgo/v1/keepassgo.proto b/proto/keepassgo/v1/keepassgo.proto index 5b3208f..7a5b190 100644 --- a/proto/keepassgo/v1/keepassgo.proto +++ b/proto/keepassgo/v1/keepassgo.proto @@ -41,6 +41,8 @@ message GetSessionStatusResponse { bool locked = 1; bool dirty = 2; uint32 entry_count = 3; + uint32 pending_approval_count = 4; + uint32 token_pending_approval_count = 5; } message OpenVaultRequest { diff --git a/scripts/browser_extension_validation_server.go b/scripts/browser_extension_validation_server.go new file mode 100644 index 0000000..1217242 --- /dev/null +++ b/scripts/browser_extension_validation_server.go @@ -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) + } +} diff --git a/scripts/validate_browser_extension.py b/scripts/validate_browser_extension.py new file mode 100644 index 0000000..4fd87ed --- /dev/null +++ b/scripts/validate_browser_extension.py @@ -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( + """\ + + + +
+ + + +
+ + + """ + ), + 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()