Compare commits

...

9 Commits

Author SHA1 Message Date
Joe Julian d522af7d51 Complete browser extension gRPC flow 2026-04-11 23:45:48 -07:00
Joe Julian 2f2338f6f2 update todo 2026-04-11 17:13:06 -07:00
Joe Julian 12796ef639 Allow scoped tokens to read session status 2026-04-11 16:51:24 -07:00
Joe Julian e16067b345 Fix hidden root navigation and browser fill matching 2026-04-11 11:53:42 -07:00
Joe Julian c8f91b300b Share hidden vault root logic across UI and API 2026-04-11 11:26:00 -07:00
Joe Julian ebb8d4f4ff Hide synthetic vault root beside recycle bin 2026-04-11 11:18:32 -07:00
Joe Julian 83bd1334d0 Fix API token policy action buttons 2026-04-11 11:14:00 -07:00
Joe Julian 675aeebdeb Fix scoped gRPC persistence and autosave behavior 2026-04-11 11:03:05 -07:00
Joe Julian 0de682a3af Sync API mutations into shared session state 2026-04-11 10:26:55 -07:00
39 changed files with 3884 additions and 325 deletions
+1
View File
@@ -1,6 +1,7 @@
build/
*.apk
/keepassgo
/keepassgo-browser-bridge
android/keepassgo-android.jar
packaging/archlinux/keepassgo-git/*.pkg.tar.zst
packaging/archlinux/keepassgo-git/PKGBUILD
+7 -1
View File
@@ -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),)
+14
View File
@@ -45,6 +45,16 @@ feeling like the same application rather than three related UIs.
These should remain in the main user flow rather than being hidden behind a settings gear.
- Browser extension:
make browser autofill page-driven rather than popup-driven so the user does not need to manually open the extension to discover or use matches.
- Browser extension:
add per-page or per-tab match detection and visible state so the browser can indicate when KeePassGO has a candidate for the current login form.
- Browser extension:
persist page match state so opening the extension popup is not required multiple times for the same page and does not repeat the whole discovery workflow unnecessarily.
- Browser extension:
show inline field affordances on detected username and password inputs, with a candidate chooser anchored to the field, so browser autofill is selectable from the form itself like Android autofill.
- Browser extension:
treat missing visibility of a pending authorization request during browser autofill as a workflow failure and fix approval surfacing before expanding extension scope further.
- Local open flow:
make the start screen primarily about opening a vault, not configuring one.
- Local open flow:
@@ -97,6 +107,10 @@ These should remain in the main user flow rather than being hidden behind a sett
keep the split-button pattern, but reduce the visual weight of the sync controls and make advanced sync affordances clearer.
- Synchronize:
avoid layout-shifting success banners and keep noncritical notifications ephemeral.
- Synchronize:
define exact local-versus-remote merge semantics for cases where both sides changed, and make the user-facing action names describe the real behavior instead of ambiguous `push`/`pull` labels if those actions perform two-way reconciliation.
- Synchronize:
choose sync wording and defaults that maximize user comprehension and safety, especially around merge, overwrite, conflict, and retry behavior.
- Phone layout:
continue reducing header and control density so content appears sooner.
- Mobile reliability:
+18 -2
View File
@@ -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.
+637 -82
View File
@@ -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);
});
}
+54
View File
@@ -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, "");
});
+632 -25
View File
@@ -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,41 +32,205 @@ 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) {
input.dispatchEvent(new Event("input", { bubbles: true }));
if (typeof InputEvent === "function") {
input.dispatchEvent(new InputEvent("input", { bubbles: true, data: input.value, inputType: "insertText" }));
} else {
input.dispatchEvent(new Event("input", { bubbles: true }));
}
input.dispatchEvent(new Event("change", { bubbles: true }));
}
function findPasswordInput() {
return Array.from(document.querySelectorAll('input[type="password"]')).find(isVisibleInput) || null;
function setInputValue(input, value) {
const prototype = Object.getPrototypeOf(input);
const descriptor = prototype ? Object.getOwnPropertyDescriptor(prototype, "value") : null;
if (descriptor?.set) {
descriptor.set.call(input, value);
return;
}
input.value = value;
}
function findUsernameInput(passwordInput) {
const form = passwordInput?.form || null;
function visibleInputs(scope) {
return Array.from(scope.querySelectorAll("input")).filter(isVisibleInput);
}
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])'))
.filter(isVisibleInput);
if (passwordInput) {
const sameForm = candidates.find((input) => input.form === passwordInput.form);
if (sameForm) {
return sameForm;
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();
usernameInput.value = credential.username;
setInputValue(usernameInput, credential.username);
dispatchFillEvents(usernameInput);
}
if (passwordInput && credential.password) {
passwordInput.focus();
passwordInput.value = credential.password;
setInputValue(passwordInput, credential.password);
dispatchFillEvents(passwordInput);
}
@@ -56,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 = `
<style>
:host {
all: initial;
}
.dock {
position: fixed;
z-index: 2147483647;
display: none;
min-width: 220px;
max-width: min(340px, calc(100vw - 24px));
font: 13px/1.35 "Noto Sans", "Liberation Sans", sans-serif;
color: #214f44;
}
.dock[data-open="true"] .panel {
display: block;
}
.trigger {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
border: 1px solid #c6d8cf;
border-radius: 999px;
background: linear-gradient(180deg, #ffffff, #edf5f0);
color: #214f44;
box-shadow: 0 12px 26px rgba(33, 79, 68, 0.16);
cursor: pointer;
}
.trigger[data-tone="warning"] {
border-color: #e4d0ae;
background: linear-gradient(180deg, #fff8ed, #f9edd6);
color: #7f4b09;
}
.trigger[data-tone="error"] {
border-color: #e4bcbc;
background: linear-gradient(180deg, #fff5f5, #f9e7e7);
color: #8c2f2f;
}
.brand {
font-weight: 700;
letter-spacing: 0.02em;
}
.meta {
color: #4d6d66;
font-size: 12px;
}
.panel {
display: none;
margin-top: 8px;
border: 1px solid #d7e3dc;
border-radius: 16px;
background: #fffdfa;
box-shadow: 0 18px 42px rgba(33, 79, 68, 0.22);
overflow: hidden;
}
.panel-header {
padding: 12px 14px 10px;
border-bottom: 1px solid #e6efea;
background: linear-gradient(180deg, #f8fbf8, #f1f6f3);
}
.panel-title {
font-weight: 700;
}
.panel-copy {
margin-top: 4px;
color: #4d6d66;
font-size: 12px;
}
.match-list {
display: grid;
gap: 0;
max-height: 280px;
overflow: auto;
}
.match {
display: grid;
gap: 3px;
width: 100%;
padding: 12px 14px;
border: 0;
border-top: 1px solid #eef4f0;
background: #fffdfa;
color: #214f44;
text-align: left;
cursor: pointer;
}
.match:hover,
.match:focus-visible {
background: #edf5f0;
outline: none;
}
.match strong {
font-size: 13px;
}
.pill {
display: inline-flex;
width: fit-content;
padding: 2px 6px;
border-radius: 999px;
background: #e7f1eb;
color: #255f4a;
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.subtle {
color: #4d6d66;
font-size: 12px;
}
.empty {
padding: 12px 14px;
color: #4d6d66;
font-size: 12px;
}
</style>
<div class="dock" data-open="false">
<button type="button" class="trigger" data-tone="ready">
<span class="brand">KeePassGO</span>
<span class="meta">Checking this form</span>
</button>
<div class="panel">
<div class="panel-header">
<div class="panel-title">KeePassGO suggestions</div>
<div class="panel-copy">Select a matching login for this field.</div>
</div>
<div class="match-list"></div>
</div>
</div>
`;
const dock = shadow.querySelector(".dock");
const trigger = shadow.querySelector(".trigger");
const meta = shadow.querySelector(".meta");
const matchList = shadow.querySelector(".match-list");
const panelCopy = shadow.querySelector(".panel-copy");
function ensureRootMounted() {
if (!root.isConnected) {
document.documentElement.appendChild(root);
}
}
return false;
});
function currentTarget() {
return chooseFillTargets(pageState.focusTarget).anchorInput;
}
function hideDock() {
chooserOpen = false;
dock.style.display = "none";
dock.dataset.open = "false";
}
function positionDock() {
const anchor = currentTarget();
if (!anchor || dock.style.display === "none") {
return;
}
const rect = anchor.getBoundingClientRect();
const width = Math.min(340, Math.max(220, rect.width));
const left = Math.min(window.innerWidth - width - 12, Math.max(12, rect.left));
const top = Math.min(window.innerHeight - 16, rect.bottom + 8);
dock.style.left = `${left}px`;
dock.style.top = `${top}px`;
dock.style.width = `${width}px`;
}
function renderMatches() {
matchList.textContent = "";
if (pageState.pendingFill) {
const pending = document.createElement("div");
pending.className = "empty";
pending.textContent = pageState.pendingMessage || "Approve or deny the fill request in KeePassGO.";
matchList.appendChild(pending);
return;
}
if (!Array.isArray(pageState.matches) || pageState.matches.length === 0) {
const empty = document.createElement("div");
empty.className = "empty";
empty.textContent = pageState.error || "No matching entries were found for this page.";
matchList.appendChild(empty);
return;
}
for (const match of pageState.matches) {
const row = document.createElement("button");
row.type = "button";
row.className = "match";
const title = document.createElement("strong");
title.textContent = match.title;
const summary = document.createElement("span");
summary.className = "subtle";
summary.textContent = inlineMatchSummary(match);
const quality = document.createElement("span");
quality.className = "pill";
quality.textContent = match.quality || "Candidate";
row.appendChild(title);
row.appendChild(summary);
row.appendChild(quality);
row.addEventListener("click", async () => {
row.disabled = true;
inlineSuppressed = true;
hideDock();
try {
await runtimeSend({
type: "keepassgo-fill-entry",
entryId: match.id,
target: pageState.focusTarget
});
} catch (_error) {
pageState = {
...pageState,
pendingFill: false,
error: "KeePassGO could not fill this page."
};
renderInlineState();
} finally {
row.disabled = false;
}
});
matchList.appendChild(row);
}
}
function renderInlineState() {
const target = currentTarget();
const shouldShow = shouldShowInlineOverlay(pageState, Boolean(target), inlineSuppressed);
if (!shouldShow) {
hideDock();
return;
}
ensureRootMounted();
dock.style.display = "block";
trigger.dataset.tone = pageState.pendingFill ? "warning" : (pageState.error ? "error" : "ready");
if (pageState.pendingFill) {
meta.textContent = "Approval needed in KeePassGO";
panelCopy.textContent = pageState.pendingMessage || "Approve or deny the fill request in KeePassGO.";
} else {
const count = Array.isArray(pageState.matches) ? pageState.matches.length : 0;
meta.textContent = count === 1 ? "1 login ready" : `${count} logins ready`;
panelCopy.textContent = "Select a matching login for this field.";
}
dock.dataset.open = chooserOpen ? "true" : "false";
renderMatches();
positionDock();
}
function reportFieldState(force) {
const scan = scanLoginFields();
pageState = {
...pageState,
pageHasLoginForm: scan.pageHasLoginForm,
focusTarget: scan.focusTarget
};
renderInlineState();
const nextTarget = JSON.stringify(scan.focusTarget || null);
if (!force && scan.signature === lastReportedSignature && nextTarget === lastReportedTarget) {
return;
}
lastReportedSignature = scan.signature;
lastReportedTarget = nextTarget;
void runtimeSend({
type: "keepassgo-page-ready",
force: Boolean(force),
pageHasLoginForm: scan.pageHasLoginForm,
focusTarget: scan.focusTarget,
signature: scan.signature
}).then((response) => {
if (response && typeof response === "object" && !("success" in response && response.success === false)) {
pageState = {
...pageState,
...response,
pageHasLoginForm: Boolean(response.pageHasLoginForm),
focusTarget: response.focusTarget || pageState.focusTarget
};
renderInlineState();
}
}).catch(() => null);
}
function scheduleRefresh(force) {
if (refreshTimer !== null) {
clearTimeout(refreshTimer);
}
refreshTimer = window.setTimeout(() => {
refreshTimer = null;
reportFieldState(force);
}, force ? 0 : 120);
}
trigger.addEventListener("click", () => {
chooserOpen = !chooserOpen;
renderInlineState();
});
document.addEventListener("focusin", () => {
scheduleRefresh(false);
});
document.addEventListener("input", () => {
scheduleRefresh(false);
}, true);
document.addEventListener("click", (event) => {
if (!root.contains(event.target)) {
chooserOpen = false;
renderInlineState();
}
});
window.addEventListener("scroll", () => {
positionDock();
}, true);
window.addEventListener("resize", () => {
positionDock();
});
const observer = new MutationObserver((records) => {
if (records.some((record) => record.type === "childList")) {
scheduleRefresh(false);
}
});
observer.observe(document.documentElement, {
childList: true,
subtree: true
});
ext.runtime.onMessage.addListener((message, _sender, sendResponse) => {
if (message?.type === "keepassgo-fill-credential") {
try {
sendResponse(fillCredential(message.credential || {}, message.target || pageState.focusTarget));
} catch (error) {
sendResponse({ ok: false, error: error instanceof Error ? error.message : String(error) });
}
return false;
}
if (message?.type === "keepassgo-page-state") {
pageState = {
...pageState,
...(message.state || {})
};
renderInlineState();
sendResponse({ ok: true });
return false;
}
if (message?.type === "keepassgo-page-scan") {
const scan = scanLoginFields();
sendResponse({
pageHasLoginForm: scan.pageHasLoginForm,
focusTarget: scan.focusTarget,
signature: scan.signature
});
return false;
}
return false;
});
reportFieldState(true);
}
+33
View File
@@ -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);
});
+11 -5
View File
@@ -1,14 +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": {
"service_worker": "background.js"
"scripts": ["background.js"]
},
"action": {
"browser_action": {
"default_title": "KeePassGO Browser",
"default_popup": "popup.html"
},
+4
View File
@@ -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;
+1
View File
@@ -19,6 +19,7 @@
<strong id="status-title">Loading</strong>
<p id="status-message" class="subtle">Checking KeePassGO.</p>
</section>
<p id="page-hint" class="inline-hint subtle">Loading page state.</p>
<section>
<h2>Matches</h2>
<div id="matches" class="match-list"></div>
+86 -14
View File
@@ -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 = `
<span class="match-main">
<strong>${match.title}</strong>
<span class="subtle">${match.username || "No username"}</span>
</span>
<span class="quality">${match.quality || ""}</span>
`;
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");
+4
View File
@@ -86,6 +86,10 @@ h2 {
background: #fcf1f1;
}
.inline-hint {
margin: -6px 0 16px;
}
.match-list {
display: flex;
flex-direction: column;
+1 -11
View File
@@ -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) {
+34 -4
View File
@@ -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
+237 -66
View File
@@ -19,6 +19,7 @@ import (
"git.julianfamily.org/keepassgo/internal/passwords"
"git.julianfamily.org/keepassgo/internal/session"
"git.julianfamily.org/keepassgo/internal/vault"
"git.julianfamily.org/keepassgo/internal/vaultview"
"git.julianfamily.org/keepassgo/internal/webdav"
keepassgov1 "git.julianfamily.org/keepassgo/proto/keepassgo/v1"
"google.golang.org/grpc/codes"
@@ -38,6 +39,7 @@ type Server struct {
clipboard clipboard.Writer
approvals *apiapproval.Broker
audit *apiaudit.Log
notify func()
}
type lifecycleBackend interface {
@@ -54,6 +56,13 @@ type modelReplaceableLifecycle interface {
Replace(vault.Model)
}
type rankedBrowserMatch struct {
match *keepassgov1.BrowserLoginMatch
score int
resource apitokens.Resource
decision apitokens.Decision
}
func NewServer(model vault.Model, profiles map[string]passwords.Profile, clipboardWriter clipboard.Writer) *Server {
return &Server{
model: model,
@@ -78,6 +87,15 @@ func (s *Server) AuditLog() *apiaudit.Log {
return s.audit
}
func (s *Server) SetChangeNotifier(notify func()) {
if s == nil {
return
}
s.mu.Lock()
defer s.mu.Unlock()
s.notify = notify
}
func (s *Server) ResolveApproval(id string, outcome apiapproval.Outcome) (apiapproval.Request, *apitokens.PolicyRule, error) {
return s.approvals.Resolve(id, outcome)
}
@@ -91,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.authorizeVaultRequest(ctx, apitokens.OperationManageVault); 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
}
@@ -232,7 +261,8 @@ func (s *Server) FindBrowserLogins(ctx context.Context, req *keepassgov1.FindBro
if locked {
return nil, status.Error(codes.FailedPrecondition, "vault is locked")
}
token, err := s.authorizeVaultRequest(ctx, apitokens.OperationListEntries)
displayModel := visibleModel(model)
token, err := s.authenticateRequest(ctx)
if err != nil {
return nil, err
}
@@ -241,17 +271,14 @@ func (s *Server) FindBrowserLogins(ctx context.Context, req *keepassgov1.FindBro
return nil, status.Error(codes.InvalidArgument, err.Error())
}
type rankedMatch struct {
match *keepassgov1.BrowserLoginMatch
score int
}
var matches []rankedMatch
for _, entry := range visibleModel(model).Entries {
var matches []rankedBrowserMatch
for _, entry := range displayModel.Entries {
quality, score := classifyBrowserEntryMatch(pageHost, entry.URL)
if score == 0 {
continue
}
matches = append(matches, rankedMatch{
resource := apitokens.Resource{Kind: apitokens.ResourceGroup, Path: entry.Path}
matches = append(matches, rankedBrowserMatch{
match: &keepassgov1.BrowserLoginMatch{
Id: entry.ID,
Title: entry.Title,
@@ -260,10 +287,12 @@ func (s *Server) FindBrowserLogins(ctx context.Context, req *keepassgov1.FindBro
Path: append([]string(nil), entry.Path...),
Quality: quality,
},
score: score,
score: score,
resource: resource,
decision: apitokens.Evaluate(token, apitokens.OperationListEntries, resource),
})
}
slices.SortFunc(matches, func(a, b rankedMatch) int {
slices.SortFunc(matches, func(a, b rankedBrowserMatch) int {
switch {
case a.score != b.score:
return b.score - a.score
@@ -275,9 +304,9 @@ func (s *Server) FindBrowserLogins(ctx context.Context, req *keepassgov1.FindBro
return strings.Compare(a.match.GetId(), b.match.GetId())
}
})
out := make([]*keepassgov1.BrowserLoginMatch, 0, len(matches))
for _, match := range matches {
out = append(out, match.match)
out, err := s.authorizedBrowserMatches(ctx, token, matches)
if err != nil {
return nil, err
}
switch len(out) {
case 1:
@@ -302,6 +331,41 @@ func (s *Server) FindBrowserLogins(ctx context.Context, req *keepassgov1.FindBro
return &keepassgov1.FindBrowserLoginsResponse{Matches: out}, nil
}
func (s *Server) authorizedBrowserMatches(ctx context.Context, token apitokens.Token, matches []rankedBrowserMatch) ([]*keepassgov1.BrowserLoginMatch, error) {
out := make([]*keepassgov1.BrowserLoginMatch, 0, len(matches))
for _, match := range matches {
if match.decision == apitokens.DecisionAllow {
out = append(out, match.match)
}
}
if len(out) != 0 {
return out, nil
}
for _, match := range matches {
if match.decision != apitokens.DecisionPrompt {
continue
}
if _, err := s.authorizeResourceRequest(ctx, token, apitokens.OperationListEntries, match.resource); err != nil {
return nil, err
}
return browserMatchesWithinPath(matches, match.resource.Path), nil
}
return out, nil
}
func browserMatchesWithinPath(matches []rankedBrowserMatch, path []string) []*keepassgov1.BrowserLoginMatch {
out := make([]*keepassgov1.BrowserLoginMatch, 0, len(matches))
for _, match := range matches {
if len(path) > len(match.resource.Path) {
continue
}
if slices.Equal(path, match.resource.Path[:len(path)]) {
out = append(out, match.match)
}
}
return out
}
func (s *Server) GetBrowserCredential(ctx context.Context, req *keepassgov1.GetBrowserCredentialRequest) (*keepassgov1.GetBrowserCredentialResponse, error) {
model, locked := s.snapshotModel()
if locked {
@@ -374,11 +438,13 @@ func (s *Server) ListEntries(ctx context.Context, req *keepassgov1.ListEntriesRe
if locked {
return nil, status.Error(codes.FailedPrecondition, "vault is locked")
}
if _, err := s.authorizePathRequest(ctx, apitokens.OperationListEntries, req.GetPath()); err != nil {
displayModel := visibleModel(model)
internalPath := expandClientPath(displayModel, req.GetPath())
if _, err := s.authorizePathRequest(ctx, apitokens.OperationListEntries, internalPath); err != nil {
return nil, err
}
model = visibleModel(model)
model = displayModel
var entries []vault.Entry
if strings.TrimSpace(req.GetQuery()) != "" {
results := model.Search(req.GetQuery())
@@ -387,14 +453,14 @@ func (s *Server) ListEntries(ctx context.Context, req *keepassgov1.ListEntriesRe
entries = append(entries, result.Entry)
}
} else {
entries = model.EntriesInPath(req.GetPath())
entries = model.EntriesInPath(internalPath)
}
resp := &keepassgov1.ListEntriesResponse{
Entries: make([]*keepassgov1.Entry, 0, len(entries)),
}
for _, entry := range entries {
resp.Entries = append(resp.Entries, entryToProto(entry))
resp.Entries = append(resp.Entries, entryToProtoWithModel(model, entry))
}
return resp, nil
@@ -405,43 +471,50 @@ func (s *Server) ListGroups(ctx context.Context, req *keepassgov1.ListGroupsRequ
if locked {
return nil, status.Error(codes.FailedPrecondition, "vault is locked")
}
if _, err := s.authorizePathRequest(ctx, apitokens.OperationListGroups, req.GetPath()); err != nil {
displayModel := visibleModel(model)
internalPath := expandClientPath(displayModel, req.GetPath())
if _, err := s.authorizePathRequest(ctx, apitokens.OperationListGroups, internalPath); err != nil {
return nil, err
}
return &keepassgov1.ListGroupsResponse{
Names: visibleModel(model).ChildGroups(req.GetPath()),
Names: displayModel.ChildGroups(internalPath),
}, nil
}
func (s *Server) CreateGroup(ctx context.Context, req *keepassgov1.CreateGroupRequest) (*keepassgov1.CreateGroupResponse, error) {
if _, err := s.authorizePathRequest(ctx, apitokens.OperationMutateGroup, req.GetParentPath()); err != nil {
model, locked := s.snapshotModel()
if locked {
return nil, status.Error(codes.FailedPrecondition, "vault is locked")
}
parentPath := expandClientPath(visibleModel(model), req.GetParentPath())
if _, err := s.authorizePathRequest(ctx, apitokens.OperationMutateGroup, parentPath); err != nil {
return nil, err
}
s.mu.Lock()
defer s.mu.Unlock()
if s.locked {
return nil, status.Error(codes.FailedPrecondition, "vault is locked")
}
s.model.CreateGroup(req.GetParentPath(), req.GetName())
s.model.CreateGroup(parentPath, req.GetName())
s.dirty = true
s.syncMutationLocked()
return &keepassgov1.CreateGroupResponse{}, nil
}
func (s *Server) RenameGroup(ctx context.Context, req *keepassgov1.RenameGroupRequest) (*keepassgov1.RenameGroupResponse, error) {
if _, err := s.authorizePathRequest(ctx, apitokens.OperationMutateGroup, req.GetPath()); err != nil {
model, locked := s.snapshotModel()
if locked {
return nil, status.Error(codes.FailedPrecondition, "vault is locked")
}
groupPath := expandClientPath(visibleModel(model), req.GetPath())
if _, err := s.authorizePathRequest(ctx, apitokens.OperationMutateGroup, groupPath); err != nil {
return nil, err
}
s.mu.Lock()
defer s.mu.Unlock()
if s.locked {
return nil, status.Error(codes.FailedPrecondition, "vault is locked")
}
if err := s.model.RenameGroup(req.GetPath(), req.GetNewName()); err != nil {
if err := s.model.RenameGroup(groupPath, req.GetNewName()); err != nil {
if errors.Is(err, vault.ErrEntryNotFound) {
return nil, status.Error(codes.NotFound, err.Error())
}
@@ -449,21 +522,24 @@ func (s *Server) RenameGroup(ctx context.Context, req *keepassgov1.RenameGroupRe
}
s.dirty = true
s.syncMutationLocked()
return &keepassgov1.RenameGroupResponse{}, nil
}
func (s *Server) DeleteGroup(ctx context.Context, req *keepassgov1.DeleteGroupRequest) (*keepassgov1.DeleteGroupResponse, error) {
if _, err := s.authorizePathRequest(ctx, apitokens.OperationMutateGroup, req.GetPath()); err != nil {
model, locked := s.snapshotModel()
if locked {
return nil, status.Error(codes.FailedPrecondition, "vault is locked")
}
groupPath := expandClientPath(visibleModel(model), req.GetPath())
if _, err := s.authorizePathRequest(ctx, apitokens.OperationMutateGroup, groupPath); err != nil {
return nil, err
}
s.mu.Lock()
defer s.mu.Unlock()
if s.locked {
return nil, status.Error(codes.FailedPrecondition, "vault is locked")
}
if err := s.model.DeleteGroup(req.GetPath()); err != nil {
if err := s.model.DeleteGroup(groupPath); err != nil {
switch {
case errors.Is(err, vault.ErrEntryNotFound):
return nil, status.Error(codes.NotFound, err.Error())
@@ -475,6 +551,7 @@ func (s *Server) DeleteGroup(ctx context.Context, req *keepassgov1.DeleteGroupRe
}
s.dirty = true
s.syncMutationLocked()
return &keepassgov1.DeleteGroupResponse{}, nil
}
@@ -483,21 +560,22 @@ func (s *Server) UpsertEntry(ctx context.Context, req *keepassgov1.UpsertEntryRe
return nil, status.Error(codes.InvalidArgument, "missing entry")
}
entry := entryFromProto(req.GetEntry())
if _, err := s.authorizeEntryRequest(ctx, apitokens.OperationMutateEntry, entry); err != nil {
model, locked := s.snapshotModel()
if locked {
return nil, status.Error(codes.FailedPrecondition, "vault is locked")
}
entry := entryFromProtoWithModel(visibleModel(model), req.GetEntry())
if _, err := s.authorizeUpsertEntryRequest(ctx, entry); err != nil {
return nil, err
}
s.mu.Lock()
if s.locked {
s.mu.Unlock()
return nil, status.Error(codes.FailedPrecondition, "vault is locked")
}
s.model.UpsertEntry(entry)
s.dirty = true
s.syncMutationLocked()
s.mu.Unlock()
return &keepassgov1.UpsertEntryResponse{Entry: entryToProto(entry)}, nil
return &keepassgov1.UpsertEntryResponse{Entry: entryToProtoWithModel(visibleModel(model), entry)}, nil
}
func (s *Server) DeleteEntry(ctx context.Context, req *keepassgov1.DeleteEntryRequest) (*keepassgov1.DeleteEntryResponse, error) {
@@ -522,6 +600,7 @@ func (s *Server) DeleteEntry(ctx context.Context, req *keepassgov1.DeleteEntryRe
}
s.dirty = true
s.syncMutationLocked()
return &keepassgov1.DeleteEntryResponse{}, nil
}
@@ -555,7 +634,8 @@ func (s *Server) RestoreEntry(ctx context.Context, req *keepassgov1.RestoreEntry
}
s.dirty = true
return &keepassgov1.RestoreEntryResponse{Entry: entryToProto(restored)}, nil
s.syncMutationLocked()
return &keepassgov1.RestoreEntryResponse{Entry: entryToProtoWithModel(visibleModel(model), restored)}, nil
}
func (s *Server) ListEntryHistory(ctx context.Context, req *keepassgov1.ListEntryHistoryRequest) (*keepassgov1.ListEntryHistoryResponse, error) {
@@ -576,7 +656,7 @@ func (s *Server) ListEntryHistory(ctx context.Context, req *keepassgov1.ListEntr
Entries: make([]*keepassgov1.Entry, 0, len(entry.History)),
}
for _, historical := range entry.History {
resp.Entries = append(resp.Entries, entryToProto(historical))
resp.Entries = append(resp.Entries, entryToProtoWithModel(visibleModel(model), historical))
}
return resp, nil
}
@@ -608,7 +688,8 @@ func (s *Server) RestoreEntryHistory(ctx context.Context, req *keepassgov1.Resto
return nil, status.Error(codes.NotFound, err.Error())
}
s.dirty = true
return &keepassgov1.RestoreEntryHistoryResponse{Entry: entryToProto(entry)}, nil
s.syncMutationLocked()
return &keepassgov1.RestoreEntryHistoryResponse{Entry: entryToProtoWithModel(visibleModel(s.model), entry)}, nil
}
func (s *Server) ListTemplates(ctx context.Context, _ *keepassgov1.ListTemplatesRequest) (*keepassgov1.ListTemplatesResponse, error) {
@@ -626,7 +707,7 @@ func (s *Server) ListTemplates(ctx context.Context, _ *keepassgov1.ListTemplates
Templates: make([]*keepassgov1.Entry, 0, len(s.model.Templates)),
}
for _, template := range s.model.Templates {
resp.Templates = append(resp.Templates, entryToProto(template))
resp.Templates = append(resp.Templates, entryToProtoWithModel(visibleModel(s.model), template))
}
return resp, nil
@@ -647,11 +728,12 @@ func (s *Server) UpsertTemplate(ctx context.Context, req *keepassgov1.UpsertTemp
return nil, status.Error(codes.FailedPrecondition, "vault is locked")
}
entry := entryFromProto(req.GetTemplate())
entry := entryFromProtoWithModel(visibleModel(s.model), req.GetTemplate())
s.model.UpsertTemplate(entry)
s.dirty = true
s.syncMutationLocked()
return &keepassgov1.UpsertTemplateResponse{Template: entryToProto(entry)}, nil
return &keepassgov1.UpsertTemplateResponse{Template: entryToProtoWithModel(visibleModel(s.model), entry)}, nil
}
func (s *Server) DeleteTemplate(ctx context.Context, req *keepassgov1.DeleteTemplateRequest) (*keepassgov1.DeleteTemplateResponse, error) {
@@ -672,6 +754,7 @@ func (s *Server) DeleteTemplate(ctx context.Context, req *keepassgov1.DeleteTemp
return nil, status.Errorf(codes.Internal, "delete template: %v", err)
}
s.dirty = true
s.syncMutationLocked()
return &keepassgov1.DeleteTemplateResponse{}, nil
}
@@ -683,7 +766,8 @@ func (s *Server) InstantiateTemplate(ctx context.Context, req *keepassgov1.Insta
if _, err := s.authorizeTemplateRequest(ctx, apitokens.OperationListTemplates, req.GetTemplateId()); err != nil {
return nil, err
}
if _, err := s.authorizePathRequest(ctx, apitokens.OperationMutateEntry, req.GetOverrides().GetPath()); err != nil {
overridePath := expandClientPath(visibleModel(s.model), req.GetOverrides().GetPath())
if _, err := s.authorizePathRequest(ctx, apitokens.OperationMutateEntry, overridePath); err != nil {
return nil, err
}
@@ -694,7 +778,8 @@ func (s *Server) InstantiateTemplate(ctx context.Context, req *keepassgov1.Insta
return nil, status.Error(codes.FailedPrecondition, "vault is locked")
}
entry, err := s.model.InstantiateTemplate(req.GetTemplateId(), entryFromProto(req.GetOverrides()))
overrides := entryFromProtoWithModel(visibleModel(s.model), req.GetOverrides())
entry, err := s.model.InstantiateTemplate(req.GetTemplateId(), overrides)
if err != nil {
if errors.Is(err, vault.ErrEntryNotFound) {
return nil, status.Error(codes.NotFound, err.Error())
@@ -703,7 +788,8 @@ func (s *Server) InstantiateTemplate(ctx context.Context, req *keepassgov1.Insta
}
s.dirty = true
return &keepassgov1.InstantiateTemplateResponse{Entry: entryToProto(entry)}, nil
s.syncMutationLocked()
return &keepassgov1.InstantiateTemplateResponse{Entry: entryToProtoWithModel(visibleModel(s.model), entry)}, nil
}
func (s *Server) ListAttachments(ctx context.Context, req *keepassgov1.ListAttachmentsRequest) (*keepassgov1.ListAttachmentsResponse, error) {
@@ -755,6 +841,7 @@ func (s *Server) UploadAttachment(ctx context.Context, req *keepassgov1.UploadAt
entry.Attachments[req.GetName()] = append([]byte(nil), req.GetContent()...)
s.model.Entries[index] = entry
s.dirty = true
s.syncMutationLocked()
return &keepassgov1.UploadAttachmentResponse{}, nil
}
@@ -813,6 +900,7 @@ func (s *Server) DeleteAttachment(ctx context.Context, req *keepassgov1.DeleteAt
}
s.model.Entries[index] = entry
s.dirty = true
s.syncMutationLocked()
return &keepassgov1.DeleteAttachmentResponse{}, nil
}
@@ -869,7 +957,7 @@ func (s *Server) GeneratePassword(ctx context.Context, req *keepassgov1.Generate
return &keepassgov1.GeneratePasswordResponse{Password: password}, nil
}
func entryToProto(entry vault.Entry) *keepassgov1.Entry {
func entryToProtoWithModel(model vault.Model, entry vault.Entry) *keepassgov1.Entry {
return &keepassgov1.Entry{
Id: entry.ID,
Title: entry.Title,
@@ -878,12 +966,12 @@ func entryToProto(entry vault.Entry) *keepassgov1.Entry {
Url: entry.URL,
Notes: entry.Notes,
Tags: append([]string(nil), entry.Tags...),
Path: append([]string(nil), entry.Path...),
Path: collapseInternalPath(model, entry.Path),
Fields: maps.Clone(entry.Fields),
}
}
func entryFromProto(entry *keepassgov1.Entry) vault.Entry {
func entryFromProtoWithModel(model vault.Model, entry *keepassgov1.Entry) vault.Entry {
return vault.Entry{
ID: entry.GetId(),
Title: entry.GetTitle(),
@@ -892,11 +980,33 @@ func entryFromProto(entry *keepassgov1.Entry) vault.Entry {
URL: entry.GetUrl(),
Notes: entry.GetNotes(),
Tags: append([]string(nil), entry.GetTags()...),
Path: append([]string(nil), entry.GetPath()...),
Path: expandClientPath(model, entry.GetPath()),
Fields: maps.Clone(entry.GetFields()),
}
}
func expandClientPath(model vault.Model, path []string) []string {
root := vaultview.HiddenRoot(model)
if root == "" {
return append([]string(nil), path...)
}
if len(path) == 0 {
return []string{root}
}
if path[0] == root {
return append([]string(nil), path...)
}
return append([]string{root}, path...)
}
func collapseInternalPath(model vault.Model, path []string) []string {
root := vaultview.HiddenRoot(model)
if root == "" || len(path) == 0 || path[0] != root {
return append([]string(nil), path...)
}
return append([]string(nil), path[1:]...)
}
func findEntryByID(model vault.Model, id string) (vault.Entry, error) {
for _, entry := range model.Entries {
if entry.ID == id {
@@ -928,12 +1038,28 @@ func normalizedBrowserHost(raw string) (string, error) {
return host, nil
}
func classifyBrowserEntryMatch(pageHost, rawEntryURL string) (string, int) {
parsed, err := url.Parse(strings.TrimSpace(rawEntryURL))
if err != nil {
return "", 0
func normalizedBrowserEntryHost(raw string) string {
raw = strings.TrimSpace(raw)
if raw == "" {
return ""
}
entryHost := strings.ToLower(parsed.Hostname())
parsed, err := url.Parse(raw)
if err == nil {
if host := strings.ToLower(parsed.Hostname()); host != "" {
return host
}
}
if !strings.Contains(raw, "://") {
parsed, err = url.Parse("https://" + raw)
if err == nil {
return strings.ToLower(parsed.Hostname())
}
}
return ""
}
func classifyBrowserEntryMatch(pageHost, rawEntryURL string) (string, int) {
entryHost := normalizedBrowserEntryHost(rawEntryURL)
if entryHost == "" {
return "", 0
}
@@ -1040,6 +1166,44 @@ func (s *Server) authorizeEntryRequest(ctx context.Context, op apitokens.Operati
return s.authorizeResourceRequest(ctx, token, op, apitokens.Resource{Kind: apitokens.ResourceEntry, EntryID: entry.ID, Path: entry.Path})
}
func (s *Server) authorizeUpsertEntryRequest(ctx context.Context, entry vault.Entry) (apitokens.Token, error) {
token, err := s.authenticateRequest(ctx)
if err != nil {
return apitokens.Token{}, err
}
model, locked := s.snapshotModel()
if locked {
return apitokens.Token{}, status.Error(codes.FailedPrecondition, "vault is locked")
}
existing, err := findEntryByID(model, entry.ID)
switch {
case err == nil:
if _, err := s.authorizeResourceRequest(ctx, token, apitokens.OperationMutateEntry, apitokens.Resource{
Kind: apitokens.ResourceEntry,
EntryID: existing.ID,
Path: existing.Path,
}); err != nil {
return apitokens.Token{}, err
}
if !slices.Equal(existing.Path, entry.Path) {
if _, err := s.authorizeResourceRequest(ctx, token, apitokens.OperationMutateEntry, apitokens.Resource{
Kind: apitokens.ResourceGroup,
Path: entry.Path,
}); err != nil {
return apitokens.Token{}, err
}
}
return token, nil
case errors.Is(err, vault.ErrEntryNotFound):
return s.authorizeResourceRequest(ctx, token, apitokens.OperationMutateEntry, apitokens.Resource{
Kind: apitokens.ResourceGroup,
Path: entry.Path,
})
default:
return apitokens.Token{}, status.Errorf(codes.Internal, "lookup existing entry: %v", err)
}
}
func (s *Server) authorizeTemplateRequest(ctx context.Context, op apitokens.Operation, templateID string) (apitokens.Token, error) {
token, err := s.authenticateRequest(ctx)
if err != nil {
@@ -1140,14 +1304,21 @@ func (s *Server) persistApprovalRule(tokenID string, rule apitokens.PolicyRule)
}
s.model.Entries[i] = token.Entry(entry.Path)
s.dirty = true
if lifecycle, ok := s.lifecycle.(modelReplaceableLifecycle); ok {
lifecycle.Replace(s.model)
}
s.syncMutationLocked()
return nil
}
return status.Error(codes.NotFound, "api token entry not found")
}
func (s *Server) syncMutationLocked() {
if lifecycle, ok := s.lifecycle.(modelReplaceableLifecycle); ok {
lifecycle.Replace(s.model)
}
if s.notify != nil {
s.notify()
}
}
func hasPolicyRule(rules []apitokens.PolicyRule, target apitokens.PolicyRule) bool {
for _, rule := range rules {
if rule.Effect != target.Effect || rule.Operation != target.Operation {
+386 -4
View File
@@ -5,8 +5,10 @@ import (
"context"
"crypto/sha256"
"encoding/hex"
"errors"
"net"
"os"
"slices"
"testing"
"time"
@@ -99,7 +101,7 @@ func TestVaultServiceRejectsUnauthorizedEntryAccess(t *testing.T) {
}
}
func TestVaultServiceRejectsUnauthorizedVaultManagement(t *testing.T) {
func TestVaultServiceAllowsSessionStatusWithoutManageVault(t *testing.T) {
t.Parallel()
client, _, cleanup := newTestClientForModel(t, vault.Model{
@@ -111,9 +113,84 @@ func TestVaultServiceRejectsUnauthorizedVaultManagement(t *testing.T) {
})
defer cleanup()
_, err := client.GetSessionStatus(tokenContext(defaultTestTokenSecret), &keepassgov1.GetSessionStatusRequest{})
if status.Code(err) != codes.PermissionDenied {
t.Fatalf("GetSessionStatus() code = %v, want %v", status.Code(err), codes.PermissionDenied)
resp, err := client.GetSessionStatus(tokenContext(defaultTestTokenSecret), &keepassgov1.GetSessionStatusRequest{})
if err != nil {
t.Fatalf("GetSessionStatus() error = %v", err)
}
if resp.GetLocked() {
t.Fatal("GetSessionStatus().Locked = true, want false")
}
}
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)
}
}
@@ -183,6 +260,214 @@ func TestVaultServiceFindsBrowserLoginsForAuthorizedClients(t *testing.T) {
}
}
func TestVaultServiceFindsBrowserLoginsForSchemeLessEntryURLs(t *testing.T) {
t.Parallel()
client, _, cleanup := newTestClientForModel(t, vault.Model{
Entries: []vault.Entry{
{
ID: "gitlab",
Title: "GitLab",
Username: "jjulian",
Password: "secret",
URL: "gitlab.com",
Path: []string{"Root", "Internet"},
},
testAPITokenEntry(t,
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationListEntries, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root"}}},
),
},
})
defer cleanup()
resp, err := client.FindBrowserLogins(tokenContext(defaultTestTokenSecret), &keepassgov1.FindBrowserLoginsRequest{
PageUrl: "https://gitlab.com/users/sign_in",
})
if err != nil {
t.Fatalf("FindBrowserLogins() error = %v", err)
}
if len(resp.Matches) != 1 {
t.Fatalf("len(FindBrowserLogins().Matches) = %d, want 1", len(resp.Matches))
}
if resp.Matches[0].Id != "gitlab" {
t.Fatalf("FindBrowserLogins().Matches[0].Id = %q, want gitlab", resp.Matches[0].Id)
}
}
func TestVaultServiceFindsBrowserLoginsWithinAuthorizedGroupScope(t *testing.T) {
t.Parallel()
client, _, cleanup := newTestClientForModel(t, vault.Model{
Entries: []vault.Entry{
{
ID: "codex-nextcloud",
Title: "Nextcloud (codex)",
Username: "jjulian",
Password: "secret-1",
URL: "https://nextcloud.example.invalid",
Path: []string{"keepass", "Joe", "codex"},
},
{
ID: "joe-nextcloud",
Title: "Nextcloud",
Username: "jjulian",
Password: "secret-2",
URL: "https://nextcloud.example.invalid",
Path: []string{"keepass", "Joe", "Internet"},
},
testAPITokenEntry(t,
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationListEntries, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"keepass", "Joe", "codex"}}},
),
},
})
defer cleanup()
resp, err := client.FindBrowserLogins(tokenContext(defaultTestTokenSecret), &keepassgov1.FindBrowserLoginsRequest{
PageUrl: "https://nextcloud.example.invalid/login",
})
if err != nil {
t.Fatalf("FindBrowserLogins() error = %v", err)
}
if len(resp.Matches) != 1 {
t.Fatalf("len(FindBrowserLogins().Matches) = %d, want 1", len(resp.Matches))
}
if resp.Matches[0].Id != "codex-nextcloud" {
t.Fatalf("FindBrowserLogins().Matches[0].Id = %q, want codex-nextcloud", resp.Matches[0].Id)
}
}
func TestVaultServiceListEntriesHidesSingleInternalVaultRoot(t *testing.T) {
t.Parallel()
client, _, cleanup := newTestClientForModel(t, vault.Model{
Entries: []vault.Entry{
{
ID: "codex-nextcloud",
Title: "Nextcloud (codex)",
Username: "jjulian",
Password: "secret-1",
URL: "https://nextcloud.example.invalid",
Path: []string{"keepass", "Joe", "codex"},
},
testAPITokenEntry(t,
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationListEntries, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"keepass", "Joe", "codex"}}},
),
},
Groups: [][]string{
{"keepass"},
{"keepass", "Joe"},
{"keepass", "Joe", "codex"},
},
})
defer cleanup()
resp, err := client.ListEntries(tokenContext(defaultTestTokenSecret), &keepassgov1.ListEntriesRequest{
Path: []string{"Joe", "codex"},
})
if err != nil {
t.Fatalf("ListEntries() error = %v", err)
}
if len(resp.Entries) != 1 {
t.Fatalf("len(ListEntries().Entries) = %d, want 1", len(resp.Entries))
}
if got := resp.Entries[0].Path; !slices.Equal(got, []string{"Joe", "codex"}) {
t.Fatalf("ListEntries().Entries[0].Path = %v, want [Joe codex]", got)
}
}
func TestVaultServiceListEntriesHidesSingleInternalVaultRootWhenRecycleBinExists(t *testing.T) {
t.Parallel()
client, _, cleanup := newTestClientForModel(t, vault.Model{
Entries: []vault.Entry{
{
ID: "codex-nextcloud",
Title: "Nextcloud (codex)",
Username: "jjulian",
Password: "secret-1",
URL: "https://nextcloud.example.invalid",
Path: []string{"keepass", "Joe", "codex"},
},
testAPITokenEntry(t,
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationListEntries, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"keepass", "Joe", "codex"}}},
),
},
Groups: [][]string{
{"keepass"},
{"keepass", "Joe"},
{"keepass", "Joe", "codex"},
{"Recycle Bin"},
},
})
defer cleanup()
resp, err := client.ListEntries(tokenContext(defaultTestTokenSecret), &keepassgov1.ListEntriesRequest{
Path: []string{"Joe", "codex"},
})
if err != nil {
t.Fatalf("ListEntries() error = %v", err)
}
if len(resp.Entries) != 1 {
t.Fatalf("len(ListEntries().Entries) = %d, want 1", len(resp.Entries))
}
if got := resp.Entries[0].Path; !slices.Equal(got, []string{"Joe", "codex"}) {
t.Fatalf("ListEntries().Entries[0].Path = %v, want [Joe codex]", got)
}
}
func TestVaultServiceListGroupsHidesSingleInternalVaultRoot(t *testing.T) {
t.Parallel()
client, _, cleanup := newTestClientForModel(t, vault.Model{
Entries: []vault.Entry{
testAPITokenEntry(t,
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationListGroups, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"keepass"}}},
),
},
Groups: [][]string{
{"keepass"},
{"keepass", "Joe"},
{"keepass", "Shared"},
},
})
defer cleanup()
resp, err := client.ListGroups(tokenContext(defaultTestTokenSecret), &keepassgov1.ListGroupsRequest{})
if err != nil {
t.Fatalf("ListGroups() error = %v", err)
}
if !slices.Equal(resp.Names, []string{"Joe", "Shared"}) {
t.Fatalf("ListGroups().Names = %v, want [Joe Shared]", resp.Names)
}
}
func TestVaultServiceListGroupsHidesSingleInternalVaultRootWhenRecycleBinExists(t *testing.T) {
t.Parallel()
client, _, cleanup := newTestClientForModel(t, vault.Model{
Entries: []vault.Entry{
testAPITokenEntry(t,
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationListGroups, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"keepass"}}},
),
},
Groups: [][]string{
{"keepass"},
{"keepass", "Joe"},
{"keepass", "Shared"},
{"Recycle Bin"},
},
})
defer cleanup()
resp, err := client.ListGroups(tokenContext(defaultTestTokenSecret), &keepassgov1.ListGroupsRequest{})
if err != nil {
t.Fatalf("ListGroups() error = %v", err)
}
if !slices.Equal(resp.Names, []string{"Joe", "Shared"}) {
t.Fatalf("ListGroups().Names = %v, want [Joe Shared]", resp.Names)
}
}
func TestVaultServiceGetsBrowserCredentialForAuthorizedClients(t *testing.T) {
t.Parallel()
@@ -940,6 +1225,103 @@ func TestVaultServiceUpsertsEntriesForAuthorizedClients(t *testing.T) {
}
}
func TestVaultServiceUpsertEntryUpdatesLifecycleModel(t *testing.T) {
t.Parallel()
model := vault.Model{
Entries: []vault.Entry{
testAPITokenEntry(t,
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationMutateEntry, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root"}}},
),
},
}
lifecycle := &stubLifecycle{model: model}
listener := bufconn.Listen(1024 * 1024)
clipboardWriter := &memoryClipboardWriter{}
service := NewServerWithLifecycle(model, passwords.DefaultProfiles(), clipboardWriter, lifecycle)
server := grpc.NewServer()
keepassgov1.RegisterVaultServiceServer(server, service)
go func() { _ = server.Serve(listener) }()
t.Cleanup(func() {
server.Stop()
_ = listener.Close()
})
conn, err := grpc.NewClient("passthrough:///bufnet",
grpc.WithContextDialer(func(ctx context.Context, _ string) (net.Conn, error) {
return listener.DialContext(ctx)
}),
grpc.WithTransportCredentials(insecure.NewCredentials()),
)
if err != nil {
t.Fatalf("NewClient() error = %v", err)
}
t.Cleanup(func() { _ = conn.Close() })
client := keepassgov1.NewVaultServiceClient(conn)
_, err = client.UpsertEntry(tokenContext(defaultTestTokenSecret), &keepassgov1.UpsertEntryRequest{
Entry: &keepassgov1.Entry{
Id: "lifecycle-visible",
Title: "Lifecycle Visible",
Path: []string{"Root", "Internet"},
},
})
if err != nil {
t.Fatalf("UpsertEntry() error = %v", err)
}
current, err := lifecycle.Current()
if err != nil {
t.Fatalf("Current() error = %v", err)
}
if _, err := current.EntryByID("lifecycle-visible"); err != nil {
t.Fatalf("Current().EntryByID() error = %v, want persisted lifecycle-visible entry", err)
}
}
func TestVaultServiceUpsertsNewEntryWithinAuthorizedGroupScope(t *testing.T) {
t.Parallel()
client, _, cleanup := newTestClientForModel(t, vault.Model{
Entries: []vault.Entry{
testAPITokenEntry(t,
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationMutateEntry, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"keepass", "Joe", "codex"}}},
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationListEntries, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"keepass", "Joe", "codex"}}},
),
},
Groups: [][]string{
{"keepass"},
{"keepass", "Joe"},
{"keepass", "Joe", "codex"},
},
})
defer cleanup()
upserted, err := client.UpsertEntry(tokenContext(defaultTestTokenSecret), &keepassgov1.UpsertEntryRequest{
Entry: &keepassgov1.Entry{
Id: "codex-created",
Title: "Codex Created",
Path: []string{"Joe", "codex"},
},
})
if err != nil {
t.Fatalf("UpsertEntry() error = %v", err)
}
if got := upserted.Entry.Path; !slices.Equal(got, []string{"Joe", "codex"}) {
t.Fatalf("UpsertEntry().Entry.Path = %v, want [Joe codex]", got)
}
listed, err := client.ListEntries(tokenContext(defaultTestTokenSecret), &keepassgov1.ListEntriesRequest{
Path: []string{"Joe", "codex"},
})
if err != nil {
t.Fatalf("ListEntries() error = %v", err)
}
if len(listed.Entries) != 1 || listed.Entries[0].Id != "codex-created" {
t.Fatalf("ListEntries().Entries = %#v, want created codex entry", listed.Entries)
}
}
func TestVaultServiceDeletesAndRestoresEntriesForAuthorizedClients(t *testing.T) {
t.Parallel()
+73 -43
View File
@@ -55,6 +55,15 @@ type SaveableSession interface {
Save() error
}
type AutoSaveableSession interface {
SaveableSession
HasSaveTarget() bool
}
type RemoteAwareSession interface {
IsRemote() bool
}
type SynchronizableSession interface {
CurrentSession
Synchronize() error
@@ -103,6 +112,7 @@ type State struct {
Session CurrentSession
Approvals ApprovalManager
AuditLog *apiaudit.Log
AutoSaveRemote bool
Section Section
CurrentPath []string
SearchQuery string
@@ -196,7 +206,9 @@ func (s *State) IssueAPIToken(name, clientName string, expiresAt *time.Time, now
}
apitokens.Upsert(&model, token)
session.Replace(model)
s.Dirty = true
if err := s.markDirtyAndAutoSave(); err != nil {
return apitokens.Token{}, "", err
}
s.recordTokenAudit(apiaudit.EventTokenIssued, token, "issued API token")
return token, secret, nil
}
@@ -220,7 +232,9 @@ func (s *State) RotateAPIToken(id string, now time.Time) (apitokens.Token, strin
}
apitokens.Upsert(&model, token)
session.Replace(model)
s.Dirty = true
if err := s.markDirtyAndAutoSave(); err != nil {
return apitokens.Token{}, "", err
}
s.recordTokenAudit(apiaudit.EventTokenRotated, token, "rotated API token")
return token, secret, nil
}
@@ -236,7 +250,9 @@ func (s *State) UpsertAPIToken(token apitokens.Token) error {
}
apitokens.Upsert(&model, token)
session.Replace(model)
s.Dirty = true
if err := s.markDirtyAndAutoSave(); err != nil {
return err
}
s.recordTokenAudit(apiaudit.EventTokenUpdated, token, "updated API token")
return nil
}
@@ -257,7 +273,9 @@ func (s *State) DisableAPIToken(id string) error {
token = apitokens.Disable(token)
apitokens.Upsert(&model, token)
session.Replace(model)
s.Dirty = true
if err := s.markDirtyAndAutoSave(); err != nil {
return err
}
s.recordTokenAudit(apiaudit.EventTokenDisabled, token, "disabled API token")
return nil
}
@@ -278,7 +296,9 @@ func (s *State) RevokeAPIToken(id string, when time.Time) error {
token = apitokens.Revoke(token, when)
apitokens.Upsert(&model, token)
session.Replace(model)
s.Dirty = true
if err := s.markDirtyAndAutoSave(); err != nil {
return err
}
s.recordTokenAudit(apiaudit.EventTokenRevoked, token, "revoked API token")
return nil
}
@@ -300,7 +320,9 @@ func (s *State) DeleteAPIToken(id string) error {
return err
}
session.Replace(model)
s.Dirty = true
if err := s.markDirtyAndAutoSave(); err != nil {
return err
}
s.recordTokenAudit(apiaudit.EventTokenDeleted, token, "deleted API token")
return nil
}
@@ -339,8 +361,7 @@ func (s *State) ConfigureSecurity(settings vault.SecuritySettings) error {
if err := security.ConfigureSecurity(settings); err != nil {
return err
}
s.Dirty = true
return nil
return s.markDirtyAndAutoSave()
}
func (s *State) ShowSection(section Section) {
@@ -568,8 +589,7 @@ func (s *State) DeleteSelectedEntry() error {
session.Replace(model)
s.SelectedEntryID = ""
s.Dirty = true
return nil
return s.markDirtyAndAutoSave()
}
func (s *State) RestoreEntry(id string) error {
@@ -588,8 +608,7 @@ func (s *State) RestoreEntry(id string) error {
}
session.Replace(model)
s.Dirty = true
return nil
return s.markDirtyAndAutoSave()
}
func (s *State) UpsertEntry(entry vault.Entry) error {
@@ -606,8 +625,7 @@ func (s *State) UpsertEntry(entry vault.Entry) error {
model.UpsertEntry(entry)
session.Replace(model)
s.SelectedEntryID = entry.ID
s.Dirty = true
return nil
return s.markDirtyAndAutoSave()
}
func (s *State) UpsertTemplate(entry vault.Entry) error {
@@ -624,8 +642,7 @@ func (s *State) UpsertTemplate(entry vault.Entry) error {
model.UpsertTemplate(entry)
session.Replace(model)
s.SelectedEntryID = entry.ID
s.Dirty = true
return nil
return s.markDirtyAndAutoSave()
}
func (s *State) InstantiateTemplate(templateID string, overrides vault.Entry) (vault.Entry, error) {
@@ -646,7 +663,9 @@ func (s *State) InstantiateTemplate(templateID string, overrides vault.Entry) (v
session.Replace(model)
s.SelectedEntryID = entry.ID
s.Dirty = true
if err := s.markDirtyAndAutoSave(); err != nil {
return vault.Entry{}, err
}
return entry, nil
}
@@ -669,8 +688,7 @@ func (s *State) DeleteTemplate(id string) error {
if s.SelectedEntryID == id {
s.SelectedEntryID = ""
}
s.Dirty = true
return nil
return s.markDirtyAndAutoSave()
}
func (s *State) DuplicateSelectedEntry(duplicateID string) (vault.Entry, error) {
@@ -691,7 +709,9 @@ func (s *State) DuplicateSelectedEntry(duplicateID string) (vault.Entry, error)
session.Replace(model)
s.SelectedEntryID = duplicate.ID
s.Dirty = true
if err := s.markDirtyAndAutoSave(); err != nil {
return vault.Entry{}, err
}
return duplicate, nil
}
@@ -711,8 +731,7 @@ func (s *State) RestoreSelectedEntryVersion(historyIndex int) error {
}
session.Replace(model)
s.Dirty = true
return nil
return s.markDirtyAndAutoSave()
}
func (s *State) Lock() error {
@@ -748,8 +767,7 @@ func (s *State) ChangeMasterKey(key vault.MasterKey) error {
return err
}
s.Dirty = true
return nil
return s.markDirtyAndAutoSave()
}
func (s *State) EnterGroup(name string) {
@@ -776,6 +794,25 @@ func (s *State) Save() error {
return nil
}
func (s *State) markDirtyAndAutoSave() error {
s.Dirty = true
session, ok := s.Session.(SaveableSession)
if !ok {
return nil
}
if autosave, ok := s.Session.(AutoSaveableSession); ok && !autosave.HasSaveTarget() {
return nil
}
if remote, ok := s.Session.(RemoteAwareSession); ok && remote.IsRemote() && !s.AutoSaveRemote {
return nil
}
if err := session.Save(); err != nil {
return err
}
s.Dirty = false
return nil
}
func (s *State) Synchronize() error {
session, ok := s.Session.(SynchronizableSession)
if !ok {
@@ -948,7 +985,9 @@ func (s *State) ConfigureRemoteBinding(input RemoteBindingInput) (RemoteBinding,
}
session.Replace(model)
s.Dirty = true
if err := s.markDirtyAndAutoSave(); err != nil {
return RemoteBinding{}, err
}
return binding, nil
}
@@ -968,8 +1007,7 @@ func (s *State) RemoveRemoteBinding(binding RemoteBinding) error {
}
session.Replace(model)
s.Dirty = true
return nil
return s.markDirtyAndAutoSave()
}
func (s *State) CreateGroup(name string) error {
@@ -985,8 +1023,7 @@ func (s *State) CreateGroup(name string) error {
model.CreateGroup(s.CurrentPath, name)
session.Replace(model)
s.Dirty = true
return nil
return s.markDirtyAndAutoSave()
}
func (s *State) MoveCurrentGroup(parent []string) error {
@@ -1006,8 +1043,7 @@ func (s *State) MoveCurrentGroup(parent []string) error {
if len(current) > 0 {
s.CurrentPath = append(append([]string(nil), parent...), current[len(current)-1])
}
s.Dirty = true
return nil
return s.markDirtyAndAutoSave()
}
func (s *State) RenameCurrentGroup(newName string) error {
@@ -1029,8 +1065,7 @@ func (s *State) RenameCurrentGroup(newName string) error {
if len(s.CurrentPath) > 0 {
s.CurrentPath = append(append([]string(nil), s.CurrentPath[:len(s.CurrentPath)-1]...), newName)
}
s.Dirty = true
return nil
return s.markDirtyAndAutoSave()
}
func (s *State) MoveSelectedEntry(path []string) error {
@@ -1049,8 +1084,7 @@ func (s *State) MoveSelectedEntry(path []string) error {
}
session.Replace(model)
s.Dirty = true
return nil
return s.markDirtyAndAutoSave()
}
func (s *State) DeleteCurrentGroup() error {
@@ -1073,8 +1107,7 @@ func (s *State) DeleteCurrentGroup() error {
s.CurrentPath = append([]string(nil), s.CurrentPath[:len(s.CurrentPath)-1]...)
}
s.SelectedEntryID = ""
s.Dirty = true
return nil
return s.markDirtyAndAutoSave()
}
func (s *State) AddAttachmentToSelectedEntry(name string, content []byte) error {
@@ -1100,8 +1133,7 @@ func (s *State) AddAttachmentToSelectedEntry(name string, content []byte) error
}
model.Entries[i].Attachments[name] = append([]byte(nil), content...)
session.Replace(model)
s.Dirty = true
return nil
return s.markDirtyAndAutoSave()
}
return vault.ErrEntryNotFound
@@ -1127,8 +1159,7 @@ func (s *State) ReplaceAttachmentOnSelectedEntry(name string, content []byte) er
}
model.Entries[i].Attachments[name] = append([]byte(nil), content...)
session.Replace(model)
s.Dirty = true
return nil
return s.markDirtyAndAutoSave()
}
return vault.ErrEntryNotFound
@@ -1157,8 +1188,7 @@ func (s *State) DeleteAttachmentFromSelectedEntry(name string) error {
model.Entries[i].Attachments = nil
}
session.Replace(model)
s.Dirty = true
return nil
return s.markDirtyAndAutoSave()
}
return vault.ErrEntryNotFound
+174
View File
@@ -184,6 +184,93 @@ func TestIssueRotateDisableRevokeAndDeleteAPIToken(t *testing.T) {
}
}
func TestIssueAPITokenAutoSavesWhenSessionSupportsSaving(t *testing.T) {
t.Parallel()
session := &mutableSaveableStubSession{model: vault.Model{}, hasSaveTarget: true}
state := State{Session: session}
now := time.Date(2026, 3, 29, 12, 0, 0, 0, time.UTC)
if _, _, err := state.IssueAPIToken("CLI", "grpc-cli", nil, now); err != nil {
t.Fatalf("IssueAPIToken() error = %v", err)
}
if session.saveCalls != 1 {
t.Fatalf("saveCalls = %d, want 1", session.saveCalls)
}
if state.Dirty {
t.Fatal("Dirty = true, want false after autosave")
}
}
func TestIssueAPITokenDoesNotAutoSaveWithoutSaveTarget(t *testing.T) {
t.Parallel()
session := &mutableSaveableStubSession{model: vault.Model{}}
state := State{Session: session}
now := time.Date(2026, 3, 29, 12, 0, 0, 0, time.UTC)
if _, _, err := state.IssueAPIToken("CLI", "grpc-cli", nil, now); err != nil {
t.Fatalf("IssueAPIToken() error = %v", err)
}
if session.saveCalls != 0 {
t.Fatalf("saveCalls = %d, want 0", session.saveCalls)
}
if !state.Dirty {
t.Fatal("Dirty = false, want true when no save target exists")
}
}
func TestIssueAPITokenDoesNotAutoSaveForRemoteSession(t *testing.T) {
t.Parallel()
session := &mutableSaveableStubSession{
model: vault.Model{},
hasSaveTarget: true,
remote: true,
}
state := State{Session: session}
now := time.Date(2026, 3, 29, 12, 0, 0, 0, time.UTC)
if _, _, err := state.IssueAPIToken("CLI", "grpc-cli", nil, now); err != nil {
t.Fatalf("IssueAPIToken() error = %v", err)
}
if session.saveCalls != 0 {
t.Fatalf("saveCalls = %d, want 0", session.saveCalls)
}
if !state.Dirty {
t.Fatal("Dirty = false, want true for remote session")
}
}
func TestIssueAPITokenAutoSavesForRemoteSessionWhenEnabled(t *testing.T) {
t.Parallel()
session := &mutableSaveableStubSession{
model: vault.Model{},
hasSaveTarget: true,
remote: true,
}
state := State{
Session: session,
AutoSaveRemote: true,
}
now := time.Date(2026, 3, 29, 12, 0, 0, 0, time.UTC)
if _, _, err := state.IssueAPIToken("CLI", "grpc-cli", nil, now); err != nil {
t.Fatalf("IssueAPIToken() error = %v", err)
}
if session.saveCalls != 1 {
t.Fatalf("saveCalls = %d, want 1", session.saveCalls)
}
if state.Dirty {
t.Fatal("Dirty = true, want false after remote autosave")
}
}
func TestRemoteProfilesReturnsVaultProfiles(t *testing.T) {
t.Parallel()
@@ -1480,6 +1567,60 @@ func TestCreateGroupPersistsGroupAndMarksDirty(t *testing.T) {
}
}
func TestCreateGroupAutoSavesWhenSessionSupportsSaving(t *testing.T) {
t.Parallel()
sess := &mutableSaveableStubSession{model: testVaultModel(), hasSaveTarget: true}
state := State{
Session: sess,
CurrentPath: []string{"Root"},
}
if err := state.CreateGroup("Finance"); err != nil {
t.Fatalf("CreateGroup() error = %v", err)
}
if sess.saveCalls != 1 {
t.Fatalf("saveCalls = %d, want 1", sess.saveCalls)
}
if state.Dirty {
t.Fatal("Dirty = true, want false after autosave")
}
}
func TestCreateGroupSaveFailureLeavesStateDirty(t *testing.T) {
t.Parallel()
sess := &mutableSaveableStubSession{
model: testVaultModel(),
hasSaveTarget: true,
saveErr: errors.New("save failed"),
}
state := State{
Session: sess,
CurrentPath: []string{"Root"},
}
err := state.CreateGroup("Finance")
if err == nil || err.Error() != "save failed" {
t.Fatalf("CreateGroup() error = %v, want save failed", err)
}
if sess.saveCalls != 1 {
t.Fatalf("saveCalls = %d, want 1", sess.saveCalls)
}
if !state.Dirty {
t.Fatal("Dirty = false, want true after failed autosave")
}
got, childErr := state.ChildGroups()
if childErr != nil {
t.Fatalf("ChildGroups() error = %v", childErr)
}
if !slices.Equal(got, []string{"Finance", "Internet", "Security Office"}) {
t.Fatalf("ChildGroups() = %v, want Finance, Internet, Security Office", got)
}
}
func TestCreateGroupSupportsNestedGroupPath(t *testing.T) {
t.Parallel()
@@ -1816,6 +1957,39 @@ func (s *saveableStubSession) Save() error {
return nil
}
type mutableSaveableStubSession struct {
model vault.Model
err error
saveCalls int
saveErr error
hasSaveTarget bool
remote bool
}
func (s *mutableSaveableStubSession) Current() (vault.Model, error) {
if s.err != nil {
return vault.Model{}, s.err
}
return s.model, nil
}
func (s *mutableSaveableStubSession) Replace(model vault.Model) {
s.model = model
}
func (s *mutableSaveableStubSession) Save() error {
s.saveCalls++
return s.saveErr
}
func (s *mutableSaveableStubSession) HasSaveTarget() bool {
return s.hasSaveTarget
}
func (s *mutableSaveableStubSession) IsRemote() bool {
return s.remote
}
type lifecycleStubSession struct {
createCalls int
model vault.Model
+2 -2
View File
@@ -836,8 +836,8 @@ func (u *ui) auditQuickFilterButton(gtx layout.Context, click *widget.Clickable,
func (u *ui) apiTokenDetailPanel(gtx layout.Context) layout.Dimensions {
token, ok := u.selectedAPIToken()
editClicks := u.ensureAPIPolicyEditClickables(0)
removeClicks := u.ensureAPIPolicyRemoveClickables(0)
var editClicks []widget.Clickable
var removeClicks []widget.Clickable
if ok {
editClicks = u.ensureAPIPolicyEditClickables(len(token.Policies))
removeClicks = u.ensureAPIPolicyRemoveClickables(len(token.Policies))
+17 -2
View File
@@ -380,6 +380,7 @@ type ui struct {
settingsHistory widget.Bool
settingsDenseLayout widget.Bool
settingsDebugHeaderBounds widget.Bool
settingsAutoSaveRemote widget.Bool
entryClicks []widget.Clickable
apiTokenClicks []widget.Clickable
apiPolicyEdits []widget.Clickable
@@ -475,6 +476,7 @@ type ui struct {
editingEntry bool
syncDefaultSourceMode syncSourceMode
syncDefaultDirection syncDirection
autoSaveRemote bool
groupControlsHidden bool
lifecycleAdvancedHidden bool
historyHidden bool
@@ -665,6 +667,7 @@ func newUIWithState(mode string, sess appstate.CurrentSession, paths statePaths)
syncDirection: syncDirectionPull,
syncDefaultSourceMode: syncSourceLocal,
syncDefaultDirection: syncDirectionPull,
autoSaveRemote: false,
apiPolicyGroupScope: true,
autofillNoticePreference: autofillNoticeAll,
vaultSharer: platform.NewVaultSharer(runtime.GOOS),
@@ -678,6 +681,7 @@ func newUIWithState(mode string, sess appstate.CurrentSession, paths statePaths)
u.apiPolicyAllow.Value = true
u.apiPolicyGroupScopeW.Value = true
u.state.Session = sess
u.state.AutoSaveRemote = u.autoSaveRemote
u.phoneSplit.Value = 0.46
u.eyeIcon, _ = widget.NewIcon(icons.ActionVisibility)
u.eyeOffIcon, _ = widget.NewIcon(icons.ActionVisibilityOff)
@@ -1211,6 +1215,17 @@ func (u *ui) securityDialogContent(gtx layout.Context) layout.Dimensions {
return syncDialogSummaryCard(gtx, u.theme, syncDialogPurposeAdvanced, u.settingsDraft.Sync.SourceDefault, u.settingsDraft.Sync.DirectionDefault)
},
layout.Spacer{Height: unit.Dp(8)}.Layout,
func(gtx layout.Context) layout.Dimensions {
check := material.CheckBox(u.theme, &u.settingsAutoSaveRemote, "Auto-save remote vault edits")
return check.Layout(gtx)
},
layout.Spacer{Height: unit.Dp(4)}.Layout,
func(gtx layout.Context) layout.Dimensions {
lbl := material.Label(u.theme, unit.Sp(12), "When enabled, edits to an already-open remote vault save immediately instead of waiting for an explicit remote save.")
lbl.Color = mutedColor
return lbl.Layout(gtx)
},
layout.Spacer{Height: unit.Dp(8)}.Layout,
func(gtx layout.Context) layout.Dimensions {
lbl := material.Label(u.theme, unit.Sp(12), "Conflict handling stays retry-safe: merged entry changes keep history, while remote save conflicts still require reopening the vault and retrying the save.")
lbl.Color = mutedColor
@@ -2856,7 +2871,7 @@ func (u *ui) groupBar(gtx layout.Context) layout.Dimensions {
return layout.Inset{Bottom: unit.Dp(6)}.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
for u.groupClicks[idx].Clicked(gtx) {
u.state.EnterGroup(name)
u.currentPath = append([]string(nil), u.state.CurrentPath...)
u.adoptStateCurrentPath()
u.filter()
}
return tonedButton(gtx, u.theme, &u.groupClicks[idx], name)
@@ -2887,7 +2902,7 @@ func (u *ui) groupBar(gtx layout.Context) layout.Dimensions {
return layout.Inset{Bottom: unit.Dp(6)}.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
for u.groupClicks[idx].Clicked(gtx) {
u.state.EnterGroup(name)
u.currentPath = append([]string(nil), u.state.CurrentPath...)
u.adoptStateCurrentPath()
u.filter()
}
return tonedButton(gtx, u.theme, &u.groupClicks[idx], name)
+1 -2
View File
@@ -275,8 +275,7 @@ func (u *ui) deleteCurrentGroupAction() error {
return err
}
u.clearDeleteGroupConfirmation()
u.currentPath = append([]string(nil), u.state.CurrentPath...)
u.syncedPath = append([]string(nil), u.state.CurrentPath...)
u.adoptStateCurrentPath()
u.filter()
return nil
}
+71 -7
View File
@@ -23,6 +23,7 @@ import (
detaillayout "git.julianfamily.org/keepassgo/internal/appui/detail/layout"
"git.julianfamily.org/keepassgo/internal/clipboard"
"git.julianfamily.org/keepassgo/internal/session"
"git.julianfamily.org/keepassgo/internal/vault"
)
func (u *ui) bannerSurface() uiBanner {
@@ -552,10 +553,72 @@ func (u *ui) setCurrentPath(path []string) {
u.clearDeleteGroupConfirmation()
}
func copyPath(path []string) []string {
return append([]string(nil), path...)
}
func pathExistsInModel(model vault.Model, path []string) bool {
return len(model.EntriesInPath(path)) > 0 || len(model.ChildGroups(path)) > 0 || hasExactGroup(model, path)
}
func normalizeEntriesPathWithoutModel(path []string, root string) []string {
if root == "" {
return copyPath(path)
}
if len(path) == 0 {
return []string{root}
}
if path[0] == "Root" {
return append([]string{root}, path[1:]...)
}
return copyPath(path)
}
func (u *ui) normalizedEntriesPath(path []string) []string {
if u.state.Section != appstate.SectionEntries {
return copyPath(path)
}
root := u.hiddenVaultRoot()
model, err := u.state.Session.Current()
if err != nil {
return normalizeEntriesPathWithoutModel(path, root)
}
if len(path) == 0 {
if root == "" {
return nil
}
return []string{root}
}
if path[0] == "Root" && root != "" {
candidate := append([]string{root}, path[1:]...)
if pathExistsInModel(model, candidate) {
return candidate
}
}
if (len(path) == 1 && root != "" && path[0] == root) || pathExistsInModel(model, path) {
return copyPath(path)
}
if root == "" {
return copyPath(path)
}
return []string{root}
}
func (u *ui) adoptStateCurrentPath() {
path := u.normalizedEntriesPath(u.state.CurrentPath)
u.currentPath = append([]string(nil), path...)
u.state.CurrentPath = append([]string(nil), path...)
u.syncedPath = append([]string(nil), path...)
u.syncPhoneGroupBrowser(path)
if len(u.deleteGroupPath) > 0 && !slices.Equal(u.deleteGroupPath, u.currentPath) {
u.clearDeleteGroupConfirmation()
}
}
func (u *ui) syncCurrentPath() {
switch {
case slices.Equal(u.currentPath, u.syncedPath) && !slices.Equal(u.state.CurrentPath, u.syncedPath):
u.currentPath = append([]string(nil), u.state.CurrentPath...)
u.adoptStateCurrentPath()
case !slices.Equal(u.currentPath, u.syncedPath) && slices.Equal(u.state.CurrentPath, u.syncedPath):
u.state.CurrentPath = append([]string(nil), u.currentPath...)
case !slices.Equal(u.currentPath, u.syncedPath) && !slices.Equal(u.state.CurrentPath, u.syncedPath):
@@ -1007,14 +1070,16 @@ func (u *ui) handleAPIPolicyClicks(gtx layout.Context) {
for u.clearAPIPolicyTarget.Clicked(gtx) {
u.runAction("clear API policy target", u.clearAPIPolicyTargetAction)
}
for i := range u.apiPolicyEdits {
for u.apiPolicyEdits[i].Clicked(gtx) {
editClicks := u.apiPolicyEdits
for i := range editClicks {
for editClicks[i].Clicked(gtx) {
index := i
u.runAction("edit API policy rule", func() error { return u.editAPIPolicyRuleAction(index) })
}
}
for i := range u.apiPolicyRemoves {
for u.apiPolicyRemoves[i].Clicked(gtx) {
removeClicks := u.apiPolicyRemoves
for i := range removeClicks {
for removeClicks[i].Clicked(gtx) {
index := i
u.runAction("remove API policy rule", func() error { return u.removeAPIPolicyRuleAction(index) })
}
@@ -1215,8 +1280,7 @@ func (u *ui) handleGroupClicks(gtx layout.Context) {
for u.moveGroup.Clicked(gtx) {
u.clearDeleteGroupConfirmation()
u.runAction("move group", u.moveCurrentGroupAction)
u.currentPath = append([]string(nil), u.state.CurrentPath...)
u.syncedPath = append([]string(nil), u.state.CurrentPath...)
u.adoptStateCurrentPath()
u.filter()
}
for u.toggleGroupControls.Clicked(gtx) {
+6 -6
View File
@@ -47,7 +47,7 @@ func (u *ui) createVaultAction() error {
u.noteRecentVault(u.saveAsTargetPath())
}
u.resetPasswordPeek()
u.currentPath = append([]string(nil), u.state.CurrentPath...)
u.adoptStateCurrentPath()
u.loadSecuritySettingsFromSession()
u.editingEntry = false
u.filter()
@@ -69,7 +69,7 @@ func (u *ui) openVaultAction() error {
}
u.noteRecentVault(path)
u.resetPasswordPeek()
u.currentPath = append([]string(nil), u.state.CurrentPath...)
u.adoptStateCurrentPath()
u.restoreRecentVaultGroup(path)
u.syncSavedRemoteBindingSelection()
if err := u.synchronizeSelectedRemoteBindingOnOpen(); err != nil {
@@ -111,7 +111,7 @@ func (u *ui) startOpenVaultAction() {
manager.ApplyPreparedLocalOpen(prepared)
u.noteRecentVault(path)
u.resetPasswordPeek()
u.currentPath = append([]string(nil), u.state.CurrentPath...)
u.adoptStateCurrentPath()
u.restoreRecentVaultGroup(path)
u.syncSavedRemoteBindingSelection()
if err := u.synchronizeSelectedRemoteBindingOnOpen(); err != nil {
@@ -329,7 +329,7 @@ func (u *ui) lockAction() error {
return err
}
u.requestMasterPassFocus = true
u.currentPath = append([]string(nil), u.state.CurrentPath...)
u.adoptStateCurrentPath()
u.resetPasswordPeek()
u.editingEntry = false
u.filter()
@@ -346,7 +346,7 @@ func (u *ui) unlockAction() error {
return err
}
u.resetPasswordPeek()
u.currentPath = append([]string(nil), u.state.CurrentPath...)
u.adoptStateCurrentPath()
u.loadSecuritySettingsFromSession()
u.editingEntry = false
u.filter()
@@ -375,7 +375,7 @@ func (u *ui) startUnlockAction() {
return func() error {
manager.ApplyPreparedUnlock(prepared)
u.resetPasswordPeek()
u.currentPath = append([]string(nil), u.state.CurrentPath...)
u.adoptStateCurrentPath()
u.loadSecuritySettingsFromSession()
u.editingEntry = false
u.filter()
+158
View File
@@ -861,6 +861,72 @@ func TestUIAPITokenPolicyRulesCanBeCreatedUpdatedAndRemoved(t *testing.T) {
}
}
func TestUIAPITokenPolicyButtonsRemainClickableAfterPanelLayout(t *testing.T) {
t.Parallel()
u := newUIWithSession("desktop", &session.Manager{}, statePaths{
DefaultSaveAsPath: filepath.Join(t.TempDir(), "vault.kdbx"),
RecentVaultsPath: filepath.Join(t.TempDir(), "recent-vaults.json"),
RecentRemotesPath: filepath.Join(t.TempDir(), "recent-remotes.json"),
UIPreferencesPath: filepath.Join(t.TempDir(), "ui-prefs.json"),
})
u.masterPassword.SetText("correct horse battery staple")
if err := u.createVaultAction(); err != nil {
t.Fatalf("createVaultAction() error = %v", err)
}
u.showAPITokensSection()
u.apiTokenName.SetText("CLI")
u.apiTokenClientName.SetText("grpc-cli")
if err := u.issueAPITokenAction(); err != nil {
t.Fatalf("issueAPITokenAction() error = %v", err)
}
u.apiPolicyOperation.SetText(string(apitokens.OperationListEntries))
u.apiPolicyPath.SetText("Root / Internet")
u.apiPolicyAllow.Value = true
u.apiPolicyGroupScopeW.Value = true
if err := u.addAPIPolicyRuleAction(); err != nil {
t.Fatalf("addAPIPolicyRuleAction() error = %v", err)
}
token, ok := u.selectedAPIToken()
if !ok || len(token.Policies) != 1 {
t.Fatalf("selectedAPIToken().Policies before layout = %#v, want 1 rule", token.Policies)
}
if len(u.apiPolicyEdits) != 1 {
t.Fatalf("len(apiPolicyEdits) before layout = %d, want 1", len(u.apiPolicyEdits))
}
gtx := layout.Context{
Ops: new(op.Ops),
Constraints: layout.Exact(image.Pt(800, 600)),
}
_ = u.apiTokenDetailPanel(gtx)
if len(u.apiPolicyEdits) != 1 {
t.Fatalf("len(apiPolicyEdits) after layout = %d, want 1", len(u.apiPolicyEdits))
}
u.apiPolicyEdits[0].Click()
u.handleAPIPolicyClicks(layout.Context{})
if u.selectedAPIPolicyIndex != 0 {
t.Fatalf("selectedAPIPolicyIndex after rendered edit click = %d, want 0", u.selectedAPIPolicyIndex)
}
u.selectedAPIPolicyIndex = -1
u.loadSelectedAPITokenIntoEditor()
_ = u.apiTokenDetailPanel(gtx)
if len(u.apiPolicyRemoves) != 1 {
t.Fatalf("len(apiPolicyRemoves) after layout = %d, want 1", len(u.apiPolicyRemoves))
}
u.apiPolicyRemoves[0].Click()
u.handleAPIPolicyClicks(layout.Context{})
token, ok = u.selectedAPIToken()
if !ok || len(token.Policies) != 0 {
t.Fatalf("selectedAPIToken().Policies after rendered remove click = %#v, want empty", token.Policies)
}
}
func TestAPITokenStatusSummary(t *testing.T) {
t.Parallel()
@@ -5070,6 +5136,33 @@ func TestUIAutoEntersSingleVaultRootGroupAndDisplaysSlashRoot(t *testing.T) {
}
}
func TestUIAutoEntersSingleVaultRootWhenRecycleBinAlsoExists(t *testing.T) {
t.Parallel()
u := newUIWithModel("desktop", vault.Model{
Entries: []vault.Entry{
{ID: "vault-console", Title: "Vault Console", Path: []string{"keepass", "Crew", "Internet"}},
},
Groups: [][]string{
{"keepass"},
{"keepass", "Crew"},
{"Recycle Bin"},
},
})
u.showEntriesSection()
if got := u.currentPath; !slices.Equal(got, []string{"keepass"}) {
t.Fatalf("currentPath = %v, want [keepass]", got)
}
if got := u.displayPath(); len(got) != 0 {
t.Fatalf("displayPath() = %v, want root slash path", got)
}
if got := u.childGroups(); !slices.Equal(got, []string{"Crew"}) {
t.Fatalf("childGroups() = %v, want [Crew]", got)
}
}
func TestUIShowEntriesSectionRestoresHiddenRootAfterLeavingEntries(t *testing.T) {
t.Parallel()
@@ -5099,6 +5192,37 @@ func TestUIShowEntriesSectionRestoresHiddenRootAfterLeavingEntries(t *testing.T)
}
}
func TestUISyncCurrentPathNormalizesHiddenRootAfterSectionSwitch(t *testing.T) {
t.Parallel()
u := newUIWithModel("desktop", vault.Model{
Entries: []vault.Entry{
{ID: "1", Title: "Vault Console", Path: []string{"keepass", "Crew", "Internet"}},
},
Groups: [][]string{
{"keepass"},
{"keepass", "Crew"},
{"Recycle Bin"},
},
})
u.showEntriesSection()
u.showAPITokensSection()
u.state.Section = appstate.SectionEntries
u.state.CurrentPath = []string{"Root"}
u.currentPath = nil
u.syncedPath = nil
u.syncCurrentPath()
if got := u.currentPath; !slices.Equal(got, []string{"keepass"}) {
t.Fatalf("currentPath after syncCurrentPath() = %v, want [keepass]", got)
}
if got := u.displayPath(); len(got) != 0 {
t.Fatalf("displayPath() after syncCurrentPath() = %v, want root slash path", got)
}
}
func TestUIShowEntriesSectionRestoresEntriesViewState(t *testing.T) {
t.Parallel()
@@ -5618,6 +5742,33 @@ func TestUISyncDefaultsPersistInSettings(t *testing.T) {
}
}
func TestUIRemoteAutosavePersistsInSettings(t *testing.T) {
t.Parallel()
configPath := filepath.Join(t.TempDir(), "settings.json")
first := newUIWithSession("desktop", &session.Manager{}, statePaths{
SettingsPath: configPath,
})
first.autoSaveRemote = true
first.state.AutoSaveRemote = true
first.saveSettings()
second := newUIWithSession("desktop", &session.Manager{}, statePaths{
SettingsPath: configPath,
})
second.autoSaveRemote = false
second.state.AutoSaveRemote = false
second.loadSettings()
if !second.autoSaveRemote {
t.Fatal("autoSaveRemote = false, want true after reload")
}
if !second.state.AutoSaveRemote {
t.Fatal("state.AutoSaveRemote = false, want true after reload")
}
}
func TestUIDebugHeaderBoundsPersistInSettings(t *testing.T) {
t.Parallel()
@@ -5714,6 +5865,7 @@ func TestUISaveSecuritySettingsPersistsSyncDefaults(t *testing.T) {
u.loadSettingsDraft()
u.settingsDraft.Sync.SourceDefault = syncSourceRemote
u.settingsDraft.Sync.DirectionDefault = syncDirectionPush
u.settingsAutoSaveRemote.Value = true
if err := u.saveSecuritySettingsAction(); err != nil {
t.Fatalf("saveSecuritySettingsAction() error = %v", err)
@@ -5730,6 +5882,12 @@ func TestUISaveSecuritySettingsPersistsSyncDefaults(t *testing.T) {
if got := reloaded.syncDefaultDirection; got != syncDirectionPush {
t.Fatalf("reloaded syncDefaultDirection = %q, want push", got)
}
if !reloaded.autoSaveRemote {
t.Fatal("reloaded autoSaveRemote = false, want true")
}
if !reloaded.state.AutoSaveRemote {
t.Fatal("reloaded state.AutoSaveRemote = false, want true")
}
}
func TestUISaveSecuritySettingsPersistsDebugHeaderBounds(t *testing.T) {
+2 -8
View File
@@ -18,6 +18,7 @@ import (
"git.julianfamily.org/keepassgo/internal/autofillcache"
"git.julianfamily.org/keepassgo/internal/session"
"git.julianfamily.org/keepassgo/internal/vault"
"git.julianfamily.org/keepassgo/internal/vaultview"
"git.julianfamily.org/keepassgo/internal/webdav"
)
@@ -1266,14 +1267,7 @@ func (u *ui) hiddenVaultRoot() string {
if err != nil {
return ""
}
if len(model.EntriesInPath(nil)) != 0 {
return ""
}
groups := model.ChildGroups(nil)
if len(groups) != 1 {
return ""
}
return groups[0]
return vaultview.HiddenRoot(model)
}
func (u *ui) enterHiddenVaultRoot() {
+17
View File
@@ -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)
@@ -76,6 +78,10 @@ func run(w *app.Window, mode string, paths statePaths, grpcAddr string) error {
ui.state.AuditLog = ui.auditLog
ui.grpcAddress = host.Address()
ui.state.Approvals = &uiApprovalManager{server: host.Server()}
host.Server().SetChangeNotifier(func() {
ui.state.Dirty = true
ui.invalidate()
})
host.Server().ApprovalBroker().SetChangeNotifier(ui.invalidate)
defer func() { _ = host.Stop() }()
}
@@ -95,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
}
+12
View File
@@ -44,6 +44,7 @@ type settingsFile struct {
type syncSettings struct {
SourceDefault string `json:"sourceDefault,omitempty"`
DirectionDefault string `json:"directionDefault,omitempty"`
AutoSaveRemote bool `json:"autoSaveRemote,omitempty"`
}
type debugSettings struct {
@@ -53,6 +54,7 @@ type debugSettings struct {
type syncSettingsDraft struct {
SourceDefault syncSourceMode
DirectionDefault syncDirection
AutoSaveRemote bool
}
type settingsDraft struct {
@@ -198,12 +200,14 @@ func (u *ui) loadSettingsDraft() {
Sync: syncSettingsDraft{
SourceDefault: u.syncDefaultSourceMode,
DirectionDefault: u.syncDefaultDirection,
AutoSaveRemote: u.autoSaveRemote,
},
Debug: debugSettings{
LogHeaderBounds: u.debugLogHeaderBounds,
},
}
u.settingsDebugHeaderBounds.Value = u.settingsDraft.Debug.LogHeaderBounds
u.settingsAutoSaveRemote.Value = u.settingsDraft.Sync.AutoSaveRemote
}
func (u *ui) saveSecuritySettingsAction() error {
@@ -226,9 +230,12 @@ func (u *ui) applySecuritySettingsLive() error {
u.settingsDraft.Accessibility.DisplayDensity = displayDensityForDenseLayout(u.settingsDenseLayout.Value)
}
u.settingsDraft.Debug.LogHeaderBounds = u.settingsDebugHeaderBounds.Value
u.settingsDraft.Sync.AutoSaveRemote = u.settingsAutoSaveRemote.Value
u.settingsDenseLayout.Value = u.settingsDraft.Accessibility.DisplayDensity == displayDensityDense
u.syncDefaultSourceMode = sanitizeSyncSourceMode(u.settingsDraft.Sync.SourceDefault)
u.syncDefaultDirection = sanitizeSyncDirection(u.settingsDraft.Sync.DirectionDefault)
u.autoSaveRemote = u.settingsDraft.Sync.AutoSaveRemote
u.state.AutoSaveRemote = u.autoSaveRemote
u.debugLogHeaderBounds = u.settingsDraft.Debug.LogHeaderBounds
if !u.debugLogHeaderBounds {
u.lastHeaderBoundsLog = ""
@@ -243,6 +250,7 @@ func (u *ui) applySecuritySettingsLive() error {
func (u *ui) loadSettings() {
u.syncDefaultSourceMode = syncSourceLocal
u.syncDefaultDirection = syncDirectionPull
u.autoSaveRemote = false
if strings.TrimSpace(u.settingsPath) != "" {
content, err := os.ReadFile(u.settingsPath)
@@ -251,6 +259,8 @@ func (u *ui) loadSettings() {
if json.Unmarshal(content, &settings) == nil {
u.syncDefaultSourceMode = sanitizeSyncSourceMode(syncSourceMode(settings.Sync.SourceDefault))
u.syncDefaultDirection = sanitizeSyncDirection(syncDirection(settings.Sync.DirectionDefault))
u.autoSaveRemote = settings.Sync.AutoSaveRemote
u.state.AutoSaveRemote = u.autoSaveRemote
u.debugLogHeaderBounds = settings.Debug.LogHeaderBounds
return
}
@@ -258,6 +268,7 @@ func (u *ui) loadSettings() {
}
u.loadLegacySyncDefaultsFromUIPreferences()
u.state.AutoSaveRemote = u.autoSaveRemote
}
func (u *ui) loadLegacySyncDefaultsFromUIPreferences() {
@@ -287,6 +298,7 @@ func (u *ui) saveSettings() {
Sync: syncSettings{
SourceDefault: string(u.syncDefaultSourceMode),
DirectionDefault: string(u.syncDefaultDirection),
AutoSaveRemote: u.autoSaveRemote,
},
Debug: debugSettings{
LogHeaderBounds: u.debugLogHeaderBounds,
+15 -33
View File
@@ -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)
}
+145
View File
@@ -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
@@ -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
}
+4
View File
@@ -93,6 +93,10 @@ func (m *Manager) HasVault() bool {
return len(m.encoded) > 0 || m.path != "" || m.remotePath != ""
}
func (m *Manager) HasSaveTarget() bool {
return m.path != "" || (m.remoteClient != nil && m.remotePath != "")
}
func (m *Manager) EncodedBytes() []byte {
return append([]byte(nil), m.encoded...)
}
+23
View File
@@ -0,0 +1,23 @@
package vaultview
import "git.julianfamily.org/keepassgo/internal/vault"
// HiddenRoot returns the single synthetic top-level vault group that should be
// treated as an internal storage root rather than as a user-visible group.
func HiddenRoot(model vault.Model) string {
if len(model.EntriesInPath(nil)) != 0 {
return ""
}
groups := model.ChildGroups(nil)
roots := make([]string, 0, len(groups))
for _, group := range groups {
if group == "Recycle Bin" {
continue
}
roots = append(roots, group)
}
if len(roots) != 1 {
return ""
}
return roots[0]
}
+26
View File
@@ -0,0 +1,26 @@
package vaultview
import (
"testing"
"git.julianfamily.org/keepassgo/internal/vault"
)
func TestHiddenRootIgnoresRecycleBin(t *testing.T) {
t.Parallel()
model := vault.Model{
Entries: []vault.Entry{
{ID: "entry-1", Title: "Vault Console", Path: []string{"keepass", "Crew", "Internet"}},
},
Groups: [][]string{
{"keepass"},
{"keepass", "Crew"},
{"Recycle Bin"},
},
}
if got := HiddenRoot(model); got != "keepass" {
t.Fatalf("HiddenRoot() = %q, want %q", got, "keepass")
}
}
@@ -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 \
+26 -8
View File
@@ -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" +
+2
View File
@@ -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 {
@@ -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)
}
}
+611
View File
@@ -0,0 +1,611 @@
#!/usr/bin/env python3
import argparse
import base64
import json
import os
import re
import shutil
import socket
import subprocess
import sys
import tempfile
import textwrap
import time
import zipfile
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parents[1]
EXTENSION_SOURCE = REPO_ROOT / "browser" / "extension"
STUB_SERVER = REPO_ROOT / "scripts" / "browser_extension_validation_server.go"
TOKEN = "test-token"
ORIGINAL_HOME = Path(os.environ.get("HOME", ""))
def run(cmd, *, cwd=None, env=None, check=True):
result = subprocess.run(cmd, cwd=cwd, env=env, text=True, capture_output=True)
if check and result.returncode != 0:
raise RuntimeError(
f"command failed ({result.returncode}): {' '.join(cmd)}\n"
f"stdout:\n{result.stdout}\n"
f"stderr:\n{result.stderr}"
)
return result
def ensure_selenium_venv(venv_dir: Path):
python_bin = venv_dir / "bin" / "python"
if not python_bin.exists():
run([sys.executable, "-m", "venv", str(venv_dir)])
run([str(python_bin), "-m", "pip", "install", "selenium"])
return python_bin
def require_binary(name):
path = shutil.which(name)
if not path:
raise RuntimeError(f"required binary {name!r} was not found in PATH")
return path
def find_geckodriver():
direct = shutil.which("geckodriver")
if direct:
return direct
cache_root = ORIGINAL_HOME / ".cache" / "selenium" / "geckodriver"
if cache_root.exists():
candidates = sorted(cache_root.glob("**/geckodriver"))
if candidates:
return str(candidates[-1])
raise RuntimeError("required binary 'geckodriver' was not found in PATH or Selenium cache")
def write_login_fixture(path: Path):
path.write_text(
textwrap.dedent(
"""\
<!doctype html>
<html lang="en">
<body>
<form id="heist-login">
<label>Username <input id="username" type="text" autocomplete="username"></label>
<label>Password <input id="password" type="password" autocomplete="current-password"></label>
<button type="submit">Open Vault</button>
</form>
</body>
</html>
"""
),
encoding="utf-8",
)
def build_bridge(binary_path: Path):
run(["go", "build", "-o", str(binary_path), "./cmd/keepassgo-browser-bridge"], cwd=REPO_ROOT)
def patch_validation_defaults(background_js: Path, grpc_addr: str):
data = background_js.read_text(encoding="utf-8")
data = data.replace('grpcAddress: "",', f'grpcAddress: "{grpc_addr}",', 1)
data = data.replace('bearerToken: ""', f'bearerToken: "{TOKEN}"', 1)
data += textwrap.dedent(
"""
;((api) => {
api.runtime.onMessage.addListener((message, _sender, sendResponse) => {
if (message?.type === "keepassgo-validation-ping") {
sendResponse({ ok: true });
return false;
}
if (message?.type === "keepassgo-validation-status") {
(async () => {
try {
const settings = await loadSettings();
const status = await connectNative({
action: "status",
grpcAddress: settings.grpcAddress,
bearerToken: settings.bearerToken
});
sendResponse({ ok: true, settings, status });
} catch (error) {
sendResponse({ ok: false, error: String(error) });
}
})();
return true;
}
return false;
});
})(globalThis.browser ?? globalThis.chrome);
"""
)
background_js.write_text(data, encoding="utf-8")
def patch_validation_content(content_js: Path):
data = content_js.read_text(encoding="utf-8")
data += textwrap.dedent(
"""
;(() => {
const set = (name, value) => {
document.documentElement.setAttribute(name, String(value));
};
const api = globalThis.browser ?? globalThis.chrome;
set("data-keepassgo-validation-runtime-id", api?.runtime?.id || "");
const username = document.getElementById("username");
const focusTarget = username ? {
role: "username",
formIndex: 0,
fieldIndex: 0,
id: "username",
name: "",
autocomplete: "username"
} : null;
document.documentElement.setAttribute("data-keepassgo-validation-content", "loaded");
try {
if (api?.runtime?.sendMessage) {
Promise.resolve(api.runtime.sendMessage({ type: "keepassgo-validation-ping" }))
.then((response) => {
if (response?.ok) {
set("data-keepassgo-validation-background", "ok");
}
})
.catch((error) => {
set("data-keepassgo-validation-background", String(error));
});
Promise.resolve(api.runtime.sendMessage({ type: "keepassgo-validation-status" }))
.then((response) => {
if (response?.ok) {
set("data-keepassgo-validation-native", JSON.stringify(response.status || {}));
set("data-keepassgo-validation-settings", JSON.stringify(response.settings || {}));
} else {
set("data-keepassgo-validation-native-error", response?.error || "unknown");
}
})
.catch((error) => {
set("data-keepassgo-validation-native-error", String(error));
});
Promise.resolve(api.runtime.sendMessage({
type: "keepassgo-page-ready",
force: true,
pageHasLoginForm: true,
focusTarget,
signature: "validation"
}))
.then((response) => {
set("data-keepassgo-validation-page-ready", JSON.stringify(response || {}));
})
.catch((error) => {
set("data-keepassgo-validation-page-ready-error", String(error));
});
}
} catch (error) {
set("data-keepassgo-validation-background", String(error));
}
})();
"""
)
content_js.write_text(data, encoding="utf-8")
def prepare_chromium_extension(workspace: Path, grpc_addr: str):
ext_dir = workspace / "extension-chromium"
shutil.copytree(EXTENSION_SOURCE, ext_dir)
patch_validation_defaults(ext_dir / "background.js", grpc_addr)
patch_validation_content(ext_dir / "content.js")
key_pem = workspace / "extension-key.pem"
key_b64 = workspace / "extension-key.b64"
run(["openssl", "genrsa", "-out", str(key_pem), "2048"])
der = subprocess.check_output(["openssl", "rsa", "-in", str(key_pem), "-pubout", "-outform", "DER"])
key_b64.write_text(base64.b64encode(der).decode("utf-8"), encoding="utf-8")
manifest = json.loads((ext_dir / "manifest.chromium.json").read_text(encoding="utf-8"))
manifest["key"] = key_b64.read_text(encoding="utf-8").strip()
(ext_dir / "manifest.json").write_text(json.dumps(manifest, indent=2) + "\n", encoding="utf-8")
return ext_dir, key_b64
def prepare_firefox_extension(workspace: Path, grpc_addr: str):
ext_dir = workspace / "extension-firefox"
shutil.copytree(EXTENSION_SOURCE, ext_dir)
patch_validation_defaults(ext_dir / "background.js", grpc_addr)
patch_validation_content(ext_dir / "content.js")
manifest = json.loads((ext_dir / "manifest.firefox.json").read_text(encoding="utf-8"))
(ext_dir / "manifest.json").write_text(json.dumps(manifest, indent=2) + "\n", encoding="utf-8")
xpi_path = workspace / "keepassgo-firefox.xpi"
with zipfile.ZipFile(xpi_path, "w") as zf:
for path in ext_dir.iterdir():
if path.is_file() and path.name != "manifest.firefox.json":
zf.write(path, arcname=path.name)
return xpi_path
def install_chromium_native_host(workspace: Path, bridge_binary: Path, key_b64: Path):
home_dir = workspace / "home"
home_dir.mkdir(parents=True, exist_ok=True)
env = os.environ.copy()
env["HOME"] = str(home_dir)
env["XDG_CONFIG_HOME"] = str(home_dir / ".config")
result = run(
[
str(bridge_binary),
"install-native-host",
"--browser",
"chromium",
"--binary",
str(bridge_binary),
"--extension-key-file",
str(key_b64),
],
env=env,
)
manifest_path = Path(result.stdout.strip())
for mirror in [
home_dir / ".config" / "google-chrome" / "NativeMessagingHosts" / "com.keepassgo.browser.json",
home_dir / ".config" / "chromium-browser" / "NativeMessagingHosts" / "com.keepassgo.browser.json",
home_dir / ".config" / "chromium" / "chromium" / "NativeMessagingHosts" / "com.keepassgo.browser.json",
workspace / "chromium-profile" / "NativeMessagingHosts" / "com.keepassgo.browser.json",
]:
mirror.parent.mkdir(parents=True, exist_ok=True)
shutil.copyfile(manifest_path, mirror)
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
origin = manifest["allowed_origins"][0]
extension_id = re.search(r"chrome-extension://([^/]+)/", origin).group(1)
return extension_id, home_dir
def install_firefox_native_host(workspace: Path, bridge_binary: Path):
home_dir = workspace / "home"
home_dir.mkdir(parents=True, exist_ok=True)
env = os.environ.copy()
env["HOME"] = str(home_dir)
run(
[
str(bridge_binary),
"install-native-host",
"--browser",
"firefox",
"--binary",
str(bridge_binary),
],
env=env,
)
return home_dir
def launch_process(cmd, *, cwd=None, env=None, log_path=None):
handle = open(log_path, "w", encoding="utf-8") if log_path else subprocess.DEVNULL
return subprocess.Popen(cmd, cwd=cwd, env=env, stdout=handle, stderr=handle, text=True)
def wait_for_http(url: str, timeout: float = 10.0):
import urllib.request
deadline = time.time() + timeout
while time.time() < deadline:
try:
with urllib.request.urlopen(url, timeout=1) as response:
if response.status == 200:
return
except Exception:
time.sleep(0.2)
raise RuntimeError(f"timed out waiting for HTTP endpoint {url}")
def wait_for_tcp(host: str, port: int, *, timeout: float = 20.0, process=None, log_path: Path | None = None, name: str = "TCP endpoint"):
deadline = time.time() + timeout
while time.time() < deadline:
if process is not None and process.poll() is not None:
details = ""
if log_path and log_path.exists():
details = f"\nlog:\n{log_path.read_text(encoding='utf-8')}"
raise RuntimeError(f"{name} exited before becoming ready{details}")
try:
with socket.create_connection((host, port), timeout=1):
return
except OSError:
time.sleep(0.2)
details = ""
if log_path and log_path.exists():
details = f"\nlog:\n{log_path.read_text(encoding='utf-8')}"
raise RuntimeError(f"timed out waiting for {name} on {host}:{port}{details}")
def save_artifact(path: Path, content: str):
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(content, encoding="utf-8")
def find_free_port() -> int:
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
sock.bind(("127.0.0.1", 0))
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
return int(sock.getsockname()[1])
def run_chromium_flow(workspace: Path, extension_id: str, login_url: str):
from selenium import webdriver
from selenium.webdriver.chrome.options import Options
from selenium.webdriver.chrome.service import Service
from selenium.webdriver.common.by import By
from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC
ext_dir = workspace / "extension-chromium"
options = Options()
options.binary_location = require_binary("chromium")
options.set_capability("goog:loggingPrefs", {"browser": "ALL"})
for arg in [
f"--user-data-dir={workspace / 'chromium-profile'}",
f"--load-extension={ext_dir}",
f"--disable-extensions-except={ext_dir}",
"--no-first-run",
"--no-default-browser-check",
"--disable-search-engine-choice-screen",
"--disable-gpu",
"--no-sandbox",
"--disable-dev-shm-usage",
]:
options.add_argument(arg)
driver = webdriver.Chrome(service=Service(require_binary("chromedriver")), options=options)
wait = WebDriverWait(driver, 25)
stage = "launch browser"
try:
stage = "open login page"
driver.get(login_url)
wait.until(EC.element_to_be_clickable((By.ID, "username"))).click()
stage = "wait for inline root"
wait.until(lambda d: d.execute_script("return !!document.querySelector('#keepassgo-inline-root')"))
stage = "wait for inline dock"
wait.until(
lambda d: d.execute_script(
"const root=document.querySelector('#keepassgo-inline-root');"
"const dock=root?.shadowRoot?.querySelector('.dock');"
"return !!(dock && getComputedStyle(dock).display !== 'none');"
)
)
stage = "open inline chooser"
driver.execute_script(
"document.querySelector('#keepassgo-inline-root').shadowRoot.querySelector('.trigger').click()"
)
stage = "wait for chooser matches"
wait.until(
lambda d: d.execute_script(
"const root=document.querySelector('#keepassgo-inline-root');"
"return root.shadowRoot.querySelectorAll('.match').length || 0;"
)
)
stage = "request browser fill"
driver.execute_script(
"document.querySelector('#keepassgo-inline-root').shadowRoot.querySelector('.match').click()"
)
stage = "wait for page approval prompt"
wait.until(
lambda d: "Approve or deny"
in d.execute_script(
"const root=document.querySelector('#keepassgo-inline-root');"
"return root.shadowRoot.querySelector('.match-list').textContent;"
)
)
state_path = workspace / "state.txt"
deadline = time.time() + 10
while time.time() < deadline:
if state_path.read_text(encoding="utf-8").strip() == "pending":
break
time.sleep(0.2)
else:
raise RuntimeError("stub server never observed a pending approval state")
stage = "verify popup approval state"
target_tab_id = driver.execute_script(
"const raw = document.documentElement.getAttribute('data-keepassgo-validation-page-ready');"
"return raw ? JSON.parse(raw).tabId : null;"
)
if not target_tab_id:
raise RuntimeError("validation page did not expose a target tab id for popup state checks")
driver.switch_to.new_window("tab")
driver.get(f"chrome-extension://{extension_id}/popup.html?tabId={int(target_tab_id)}")
wait.until(lambda d: "Approval needed" in d.find_element(By.ID, "status-title").text)
stage = "approve fill and wait for completion"
state_path.write_text("approved", encoding="utf-8")
driver.switch_to.window(driver.window_handles[0])
wait.until(lambda d: d.find_element(By.ID, "username").get_attribute("value") == "dannyocean")
wait.until(lambda d: d.find_element(By.ID, "password").get_attribute("value") == "token-1")
return True
except Exception as exc: # noqa: BLE001
artifacts = workspace / "artifacts"
save_artifact(artifacts / "chromium-page.html", driver.page_source)
try:
driver.save_screenshot(str(artifacts / "chromium-page.png"))
except Exception:
pass
try:
save_artifact(artifacts / "chromium-browser.log", json.dumps(driver.get_log("browser"), indent=2))
except Exception:
pass
raise RuntimeError(f"chromium validation failed during {stage}: {type(exc).__name__}: {exc}") from exc
finally:
driver.quit()
def run_firefox_flow(workspace: Path, login_url: str):
from selenium import webdriver
from selenium.webdriver.firefox.options import Options
from selenium.webdriver.firefox.service import Service
from selenium.webdriver.common.by import By
from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC
xpi_path = workspace / "keepassgo-firefox.xpi"
options = Options()
options.binary_location = require_binary("firefox")
options.add_argument("-headless")
service = Service(find_geckodriver())
driver = webdriver.Firefox(service=service, options=options)
wait = WebDriverWait(driver, 25)
stage = "launch firefox"
try:
stage = "install temporary addon"
addon_id = driver.install_addon(str(xpi_path), temporary=True)
if addon_id != "browser@keepassgo.com":
raise RuntimeError(f"unexpected addon id {addon_id!r}")
stage = "open login page"
driver.get(login_url)
wait.until(EC.element_to_be_clickable((By.ID, "username"))).click()
stage = "wait for inline root"
wait.until(lambda d: d.execute_script("return !!document.querySelector('#keepassgo-inline-root')"))
stage = "wait for inline dock"
wait.until(
lambda d: d.execute_script(
"const root=document.querySelector('#keepassgo-inline-root');"
"const dock=root?.shadowRoot?.querySelector('.dock');"
"return !!(dock && getComputedStyle(dock).display !== 'none');"
)
)
stage = "open inline chooser"
driver.execute_script(
"document.querySelector('#keepassgo-inline-root').shadowRoot.querySelector('.trigger').click()"
)
stage = "wait for chooser matches"
wait.until(
lambda d: d.execute_script(
"const root=document.querySelector('#keepassgo-inline-root');"
"return root.shadowRoot.querySelectorAll('.match').length || 0;"
)
)
stage = "request browser fill"
driver.execute_script(
"document.querySelector('#keepassgo-inline-root').shadowRoot.querySelector('.match').click()"
)
stage = "wait for page approval prompt"
wait.until(
lambda d: "Approve or deny"
in d.execute_script(
"const root=document.querySelector('#keepassgo-inline-root');"
"return root.shadowRoot.querySelector('.match-list').textContent;"
)
)
state_path = workspace / "state.txt"
deadline = time.time() + 10
while time.time() < deadline:
if state_path.read_text(encoding="utf-8").strip() == "pending":
break
time.sleep(0.2)
else:
raise RuntimeError("stub server never observed a pending approval state")
stage = "approve fill and wait for completion"
state_path.write_text("approved", encoding="utf-8")
wait.until(lambda d: d.find_element(By.ID, "username").get_attribute("value") == "dannyocean")
wait.until(lambda d: d.find_element(By.ID, "password").get_attribute("value") == "token-1")
return True
except Exception as exc: # noqa: BLE001
artifacts = workspace / "artifacts"
save_artifact(artifacts / "firefox-page.html", driver.page_source)
try:
driver.save_screenshot(str(artifacts / "firefox-page.png"))
except Exception:
pass
raise RuntimeError(f"firefox validation failed during {stage}: {type(exc).__name__}: {exc}") from exc
finally:
driver.quit()
def main():
parser = argparse.ArgumentParser(description="Validate the browser-extension flow with isolated real-browser harnesses.")
parser.add_argument("--browser", choices=["firefox", "chromium", "both"], default="firefox")
parser.add_argument("--keep-workspace", action="store_true")
parser.add_argument("--workspace", help=argparse.SUPPRESS)
args = parser.parse_args()
workspace = Path(args.workspace) if args.workspace else Path(tempfile.mkdtemp(prefix="keepassgo-browser-validate."))
workspace.joinpath("home").mkdir(parents=True, exist_ok=True)
workspace.joinpath("web").mkdir(parents=True, exist_ok=True)
if not args.workspace:
workspace.joinpath("state.txt").write_text("idle", encoding="utf-8")
write_login_fixture(workspace / "web" / "login.html")
python_bin = ensure_selenium_venv(workspace / "venv")
if Path(sys.executable) != python_bin:
cmd = [str(python_bin), str(Path(__file__).resolve()), "--workspace", str(workspace), "--browser", args.browser]
if args.keep_workspace:
cmd.append("--keep-workspace")
raise SystemExit(subprocess.run(cmd, cwd=REPO_ROOT).returncode)
bridge_binary = workspace / "keepassgo-browser-bridge"
build_bridge(bridge_binary)
http_port = find_free_port()
grpc_port = find_free_port()
login_url = f"http://127.0.0.1:{http_port}/login.html"
grpc_addr = f"tcp://127.0.0.1:{grpc_port}"
ext_dir_chromium = xpi_path = None
if args.browser in {"chromium", "both"}:
ext_dir_chromium, key_b64 = prepare_chromium_extension(workspace, grpc_addr)
chromium_id, chromium_home = install_chromium_native_host(workspace, bridge_binary, key_b64)
if args.browser in {"firefox", "both"}:
xpi_path = prepare_firefox_extension(workspace, grpc_addr)
firefox_home = install_firefox_native_host(workspace, bridge_binary)
home_dir = workspace / "home"
env = os.environ.copy()
env["HOME"] = str(home_dir)
env["XDG_CONFIG_HOME"] = str(home_dir / ".config")
env["CHROME_CONFIG_HOME"] = str(home_dir / ".config")
os.environ["HOME"] = str(home_dir)
os.environ["XDG_CONFIG_HOME"] = env["XDG_CONFIG_HOME"]
os.environ["CHROME_CONFIG_HOME"] = env["CHROME_CONFIG_HOME"]
http_server = launch_process(
[sys.executable, "-m", "http.server", str(http_port)],
cwd=workspace / "web",
env=env,
log_path=workspace / "http.log",
)
stub_server = launch_process(
[
"go",
"run",
str(STUB_SERVER),
"--listen",
f"127.0.0.1:{grpc_port}",
"--state",
str(workspace / "state.txt"),
"--page-url",
login_url,
],
cwd=REPO_ROOT,
env=env,
log_path=workspace / "stub.log",
)
try:
wait_for_http(login_url)
wait_for_tcp("127.0.0.1", grpc_port, process=stub_server, log_path=workspace / "stub.log", name="validation gRPC server")
browser_results = []
if args.browser in {"firefox", "both"}:
browser_results.append("firefox")
run_firefox_flow(workspace, login_url)
workspace.joinpath("state.txt").write_text("idle", encoding="utf-8")
if args.browser in {"chromium", "both"}:
browser_results.append("chromium")
run_chromium_flow(workspace, chromium_id, login_url)
print(f"browser validation passed for {', '.join(browser_results)}; workspace: {workspace}", flush=True)
if not args.keep_workspace:
shutil.rmtree(workspace, ignore_errors=True)
except Exception as exc: # noqa: BLE001
print(f"{exc}\nworkspace preserved at {workspace}", file=sys.stderr)
sys.exit(1)
finally:
for process in [stub_server, http_server]:
if process.poll() is None:
process.terminate()
try:
process.wait(timeout=3)
except subprocess.TimeoutExpired:
process.kill()
if __name__ == "__main__":
main()