Merge pull request 'Complete browser extension gRPC flow' (#4) from feature/browser-extension-grpc into main
Reviewed-on: #4
This commit was merged in pull request #4.
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -25,7 +25,7 @@ ifneq ($(strip $(SIGNPASS)),)
|
||||
GOGIO_SIGN_FLAGS += -signpass $(SIGNPASS)
|
||||
endif
|
||||
|
||||
.PHONY: apk archlinux-pkgbuild
|
||||
.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; }
|
||||
@@ -68,3 +68,12 @@ archlinux-pkgbuild: $(ARCH_PKG_TMPL) Makefile
|
||||
-e 's|@PKGVER@|$(ARCH_PKGVER)|g' \
|
||||
-e 's|@REPO_DIR@|$(ARCH_REPO_DIR)|g' \
|
||||
"$(ARCH_PKG_TMPL)" > "$(ARCH_PKGBUILD)"
|
||||
|
||||
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),)
|
||||
|
||||
@@ -63,6 +63,7 @@ makepkg -si
|
||||
The package installs:
|
||||
|
||||
- `/usr/bin/keepassgo`
|
||||
- `/usr/bin/keepassgo-browser-bridge`
|
||||
- a desktop entry at `/usr/share/applications/keepassgo.desktop`
|
||||
- application icons under the hicolor theme
|
||||
|
||||
@@ -98,3 +99,11 @@ You will need the Android SDK and NDK installed and configured for real device o
|
||||
|
||||
Desktop automation is resolved through the secure gRPC API rather than synthetic auto-type.
|
||||
See [`docs/desktop-automation.md`](./docs/desktop-automation.md).
|
||||
|
||||
On desktop, KeePassGO now listens on a Unix socket by default under the user runtime directory.
|
||||
Set `KEEPASSGO_GRPC_ADDR` or `-grpc-addr` to override it, for example `tcp://127.0.0.1:47777`.
|
||||
|
||||
## Browser Extension
|
||||
|
||||
Firefox and Chromium browser integration is available through the local gRPC API plus a native messaging bridge.
|
||||
See [`docs/browser-extension.md`](./docs/browser-extension.md).
|
||||
|
||||
@@ -45,6 +45,7 @@ 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:
|
||||
- Local open flow:
|
||||
make the start screen primarily about opening a vault, not configuring one.
|
||||
- Local open flow:
|
||||
@@ -97,6 +98,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:
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
# KeePassGO Browser Extension
|
||||
|
||||
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` 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 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.
|
||||
@@ -0,0 +1,744 @@
|
||||
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 = {
|
||||
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;
|
||||
if (error) {
|
||||
reject(new Error(error.message));
|
||||
return;
|
||||
}
|
||||
resolve(value);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
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;
|
||||
if (error) {
|
||||
reject(new Error(error.message));
|
||||
return;
|
||||
}
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
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;
|
||||
if (error) {
|
||||
reject(new Error(error.message));
|
||||
return;
|
||||
}
|
||||
resolve(tabs);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
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;
|
||||
if (error) {
|
||||
reject(new Error(error.message));
|
||||
return;
|
||||
}
|
||||
resolve(response);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
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;
|
||||
if (error) {
|
||||
reject(new Error(error.message));
|
||||
return;
|
||||
}
|
||||
resolve(response);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function loadSettings() {
|
||||
const stored = await storageGet(["bearerToken"]);
|
||||
return {
|
||||
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: Number.isInteger(tab?.id) ? tab.id : null,
|
||||
url: typeof tab?.url === "string" ? tab.url : ""
|
||||
};
|
||||
}
|
||||
|
||||
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,
|
||||
error: "Set an API token in extension settings."
|
||||
};
|
||||
}
|
||||
const status = await connectNative({
|
||||
action: "status",
|
||||
bearerToken: settings.bearerToken
|
||||
});
|
||||
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: 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",
|
||||
bearerToken: settings.bearerToken,
|
||||
url: resolvedURL
|
||||
});
|
||||
|
||||
state = {
|
||||
...state,
|
||||
success: Boolean(matches?.success),
|
||||
status: matches?.status ?? state.status,
|
||||
pendingFill: state.pendingFill || tokenPendingApprovalCount(matches?.status ?? state.status) > 0,
|
||||
pendingMessage: tokenPendingApprovalCount(matches?.status ?? state.status) > 0
|
||||
? approvalHintForState(state) || "Approve or deny the browser fill request in KeePassGO."
|
||||
: "",
|
||||
matches: Array.isArray(matches?.matches) ? matches.matches : [],
|
||||
error: matches?.error ?? "",
|
||||
updatedAt: Date.now()
|
||||
};
|
||||
const saved = await setPageState(tabId, state);
|
||||
if (saved.pendingFill) {
|
||||
schedulePendingPoll(tabId, resolvedURL);
|
||||
} else {
|
||||
clearPendingPoll(tabId);
|
||||
}
|
||||
return saved;
|
||||
})().finally(() => {
|
||||
if (refreshJobs.get(tabId) === job) {
|
||||
refreshJobs.delete(tabId);
|
||||
}
|
||||
});
|
||||
|
||||
refreshJobs.set(tabId, job);
|
||||
return job;
|
||||
}
|
||||
|
||||
async function statusForPage(options = {}) {
|
||||
let page = await activePageContext();
|
||||
if (Number.isInteger(options.tabId)) {
|
||||
const tab = await tabsGet(options.tabId).catch(() => null);
|
||||
page = {
|
||||
tabId: options.tabId,
|
||||
url: typeof tab?.url === "string" ? tab.url : ""
|
||||
};
|
||||
}
|
||||
if (page.tabId == null) {
|
||||
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(tabId, entryId) {
|
||||
if (!Number.isInteger(tabId)) {
|
||||
throw new Error("No active tab is available.");
|
||||
}
|
||||
const tab = await tabsGet(tabId);
|
||||
const pageUrl = typeof tab?.url === "string" ? tab.url : "";
|
||||
if (!supportsPageStateURL(pageUrl)) {
|
||||
throw new Error("This page cannot be filled.");
|
||||
}
|
||||
|
||||
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()
|
||||
});
|
||||
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",
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
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({
|
||||
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;
|
||||
});
|
||||
|
||||
ext.tabs?.onActivated?.addListener(({ tabId }) => {
|
||||
void refreshPageState(tabId, "", { force: false }).catch(() => null);
|
||||
});
|
||||
|
||||
ext.tabs?.onUpdated?.addListener((tabId, changeInfo, tab) => {
|
||||
if (typeof changeInfo.url === "string") {
|
||||
void clearPageState(tabId).catch(() => null);
|
||||
}
|
||||
if (changeInfo.status === "complete") {
|
||||
void refreshPageState(tabId, tab?.url || changeInfo.url || "", { force: false }).catch(() => null);
|
||||
}
|
||||
});
|
||||
|
||||
ext.tabs?.onRemoved?.addListener((tabId) => {
|
||||
void clearPageState(tabId).catch(() => null);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
|
||||
const background = require("./background.js");
|
||||
|
||||
test("normalizePageState preserves focused and pending field targets", () => {
|
||||
const state = background.normalizePageState({
|
||||
tabId: 7,
|
||||
pageUrl: "https://vault.example.invalid/login",
|
||||
focusTarget: { role: "username", formIndex: 0, fieldIndex: 1 },
|
||||
pendingTarget: { role: "password", formIndex: 0, fieldIndex: 2 }
|
||||
});
|
||||
|
||||
assert.deepEqual(state.focusTarget, { role: "username", formIndex: 0, fieldIndex: 1 });
|
||||
assert.deepEqual(state.pendingTarget, { role: "password", formIndex: 0, fieldIndex: 2 });
|
||||
});
|
||||
|
||||
test("shouldReuseMatches only reuses recent non-pending page matches", () => {
|
||||
const recentState = {
|
||||
pageHasLoginForm: true,
|
||||
matches: [{ id: "vault-console" }],
|
||||
pendingFill: false,
|
||||
updatedAt: Date.now()
|
||||
};
|
||||
assert.equal(background.shouldReuseMatches(recentState, false), true);
|
||||
|
||||
assert.equal(background.shouldReuseMatches({ ...recentState, pendingFill: true }, false), false);
|
||||
assert.equal(background.shouldReuseMatches({ ...recentState, pageHasLoginForm: false }, false), false);
|
||||
assert.equal(background.shouldReuseMatches(recentState, true), false);
|
||||
});
|
||||
|
||||
test("actionPresentationForState prioritizes approval visibility", () => {
|
||||
const presentation = background.actionPresentationForState({
|
||||
pendingFill: true,
|
||||
pendingMessage: "Approve the browser fill request in KeePassGO.",
|
||||
configured: true,
|
||||
success: true,
|
||||
pageHasLoginForm: true,
|
||||
matches: [{ id: "vault-console" }]
|
||||
});
|
||||
|
||||
assert.equal(presentation.badgeText, "!");
|
||||
assert.equal(presentation.color, "#9f5f0e");
|
||||
assert.match(presentation.title, /approve/i);
|
||||
});
|
||||
|
||||
test("tokenPendingApprovalCount reads token-scoped approval state", () => {
|
||||
assert.equal(background.tokenPendingApprovalCount({ tokenPendingApprovalCount: 2 }), 2);
|
||||
assert.equal(background.tokenPendingApprovalCount({}), 0);
|
||||
});
|
||||
|
||||
test("default settings include a blank bearer token that can be overridden by harness patching", () => {
|
||||
assert.equal(background.defaultSettings.bearerToken, "");
|
||||
});
|
||||
@@ -0,0 +1,676 @@
|
||||
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;
|
||||
}
|
||||
if (input.disabled || input.readOnly) {
|
||||
return false;
|
||||
}
|
||||
const style = window.getComputedStyle(input);
|
||||
if (style.display === "none" || style.visibility === "hidden") {
|
||||
return false;
|
||||
}
|
||||
return input.offsetParent !== null || style.position === "fixed";
|
||||
}
|
||||
|
||||
function normalizeRole(rawRole) {
|
||||
switch (String(rawRole || "").trim().toLowerCase()) {
|
||||
case "password":
|
||||
return "password";
|
||||
default:
|
||||
return "username";
|
||||
}
|
||||
}
|
||||
|
||||
function describeFieldRole(input) {
|
||||
const type = String(input?.getAttribute?.("type") || "").toLowerCase();
|
||||
if (type === "password") {
|
||||
return "password";
|
||||
}
|
||||
const autocomplete = String(input?.autocomplete || "").toLowerCase();
|
||||
if (autocomplete.includes("username") || autocomplete.includes("email")) {
|
||||
return "username";
|
||||
}
|
||||
return "username";
|
||||
}
|
||||
|
||||
function isUsernameCandidate(input) {
|
||||
if (!isVisibleInput(input)) {
|
||||
return false;
|
||||
}
|
||||
return describeFieldRole(input) === "username";
|
||||
}
|
||||
|
||||
function isPasswordCandidate(input) {
|
||||
return isVisibleInput(input) && describeFieldRole(input) === "password";
|
||||
}
|
||||
|
||||
function dispatchFillEvents(input) {
|
||||
if (typeof InputEvent === "function") {
|
||||
input.dispatchEvent(new InputEvent("input", { bubbles: true, data: input.value, inputType: "insertText" }));
|
||||
} else {
|
||||
input.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
}
|
||||
input.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
}
|
||||
|
||||
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 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 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;
|
||||
}
|
||||
}
|
||||
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 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();
|
||||
setInputValue(usernameInput, credential.username);
|
||||
dispatchFillEvents(usernameInput);
|
||||
}
|
||||
if (passwordInput && credential.password) {
|
||||
passwordInput.focus();
|
||||
setInputValue(passwordInput, credential.password);
|
||||
dispatchFillEvents(passwordInput);
|
||||
}
|
||||
|
||||
if (!usernameInput && !passwordInput) {
|
||||
return { ok: false, error: "No fillable username or password fields were found." };
|
||||
}
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
function currentTarget() {
|
||||
return chooseFillTargets(pageState.focusTarget).anchorInput;
|
||||
}
|
||||
|
||||
function hideDock() {
|
||||
chooserOpen = false;
|
||||
dock.style.display = "none";
|
||||
dock.dataset.open = "false";
|
||||
}
|
||||
|
||||
function positionDock() {
|
||||
const anchor = currentTarget();
|
||||
if (!anchor || dock.style.display === "none") {
|
||||
return;
|
||||
}
|
||||
const rect = anchor.getBoundingClientRect();
|
||||
const width = Math.min(340, Math.max(220, rect.width));
|
||||
const left = Math.min(window.innerWidth - width - 12, Math.max(12, rect.left));
|
||||
const top = Math.min(window.innerHeight - 16, rect.bottom + 8);
|
||||
dock.style.left = `${left}px`;
|
||||
dock.style.top = `${top}px`;
|
||||
dock.style.width = `${width}px`;
|
||||
}
|
||||
|
||||
function renderMatches() {
|
||||
matchList.textContent = "";
|
||||
if (pageState.pendingFill) {
|
||||
const pending = document.createElement("div");
|
||||
pending.className = "empty";
|
||||
pending.textContent = pageState.pendingMessage || "Approve or deny the fill request in KeePassGO.";
|
||||
matchList.appendChild(pending);
|
||||
return;
|
||||
}
|
||||
if (!Array.isArray(pageState.matches) || pageState.matches.length === 0) {
|
||||
const empty = document.createElement("div");
|
||||
empty.className = "empty";
|
||||
empty.textContent = pageState.error || "No matching entries were found for this page.";
|
||||
matchList.appendChild(empty);
|
||||
return;
|
||||
}
|
||||
|
||||
for (const match of pageState.matches) {
|
||||
const row = document.createElement("button");
|
||||
row.type = "button";
|
||||
row.className = "match";
|
||||
const title = document.createElement("strong");
|
||||
title.textContent = match.title;
|
||||
const summary = document.createElement("span");
|
||||
summary.className = "subtle";
|
||||
summary.textContent = inlineMatchSummary(match);
|
||||
const quality = document.createElement("span");
|
||||
quality.className = "pill";
|
||||
quality.textContent = match.quality || "Candidate";
|
||||
row.appendChild(title);
|
||||
row.appendChild(summary);
|
||||
row.appendChild(quality);
|
||||
row.addEventListener("click", async () => {
|
||||
row.disabled = true;
|
||||
inlineSuppressed = true;
|
||||
hideDock();
|
||||
try {
|
||||
await runtimeSend({
|
||||
type: "keepassgo-fill-entry",
|
||||
entryId: match.id,
|
||||
target: pageState.focusTarget
|
||||
});
|
||||
} catch (_error) {
|
||||
pageState = {
|
||||
...pageState,
|
||||
pendingFill: false,
|
||||
error: "KeePassGO could not fill this page."
|
||||
};
|
||||
renderInlineState();
|
||||
} finally {
|
||||
row.disabled = false;
|
||||
}
|
||||
});
|
||||
matchList.appendChild(row);
|
||||
}
|
||||
}
|
||||
|
||||
function renderInlineState() {
|
||||
const target = currentTarget();
|
||||
const shouldShow = shouldShowInlineOverlay(pageState, Boolean(target), inlineSuppressed);
|
||||
|
||||
if (!shouldShow) {
|
||||
hideDock();
|
||||
return;
|
||||
}
|
||||
|
||||
ensureRootMounted();
|
||||
dock.style.display = "block";
|
||||
trigger.dataset.tone = pageState.pendingFill ? "warning" : (pageState.error ? "error" : "ready");
|
||||
if (pageState.pendingFill) {
|
||||
meta.textContent = "Approval needed in KeePassGO";
|
||||
panelCopy.textContent = pageState.pendingMessage || "Approve or deny the fill request in KeePassGO.";
|
||||
} else {
|
||||
const count = Array.isArray(pageState.matches) ? pageState.matches.length : 0;
|
||||
meta.textContent = count === 1 ? "1 login ready" : `${count} logins ready`;
|
||||
panelCopy.textContent = "Select a matching login for this field.";
|
||||
}
|
||||
dock.dataset.open = chooserOpen ? "true" : "false";
|
||||
renderMatches();
|
||||
positionDock();
|
||||
}
|
||||
|
||||
function reportFieldState(force) {
|
||||
const scan = scanLoginFields();
|
||||
pageState = {
|
||||
...pageState,
|
||||
pageHasLoginForm: scan.pageHasLoginForm,
|
||||
focusTarget: scan.focusTarget
|
||||
};
|
||||
renderInlineState();
|
||||
const nextTarget = JSON.stringify(scan.focusTarget || null);
|
||||
if (!force && scan.signature === lastReportedSignature && nextTarget === lastReportedTarget) {
|
||||
return;
|
||||
}
|
||||
lastReportedSignature = scan.signature;
|
||||
lastReportedTarget = nextTarget;
|
||||
void runtimeSend({
|
||||
type: "keepassgo-page-ready",
|
||||
force: Boolean(force),
|
||||
pageHasLoginForm: scan.pageHasLoginForm,
|
||||
focusTarget: scan.focusTarget,
|
||||
signature: scan.signature
|
||||
}).then((response) => {
|
||||
if (response && typeof response === "object" && !("success" in response && response.success === false)) {
|
||||
pageState = {
|
||||
...pageState,
|
||||
...response,
|
||||
pageHasLoginForm: Boolean(response.pageHasLoginForm),
|
||||
focusTarget: response.focusTarget || pageState.focusTarget
|
||||
};
|
||||
renderInlineState();
|
||||
}
|
||||
}).catch(() => null);
|
||||
}
|
||||
|
||||
function scheduleRefresh(force) {
|
||||
if (refreshTimer !== null) {
|
||||
clearTimeout(refreshTimer);
|
||||
}
|
||||
refreshTimer = window.setTimeout(() => {
|
||||
refreshTimer = null;
|
||||
reportFieldState(force);
|
||||
}, force ? 0 : 120);
|
||||
}
|
||||
|
||||
trigger.addEventListener("click", () => {
|
||||
chooserOpen = !chooserOpen;
|
||||
renderInlineState();
|
||||
});
|
||||
|
||||
document.addEventListener("focusin", () => {
|
||||
scheduleRefresh(false);
|
||||
});
|
||||
|
||||
document.addEventListener("input", () => {
|
||||
scheduleRefresh(false);
|
||||
}, true);
|
||||
|
||||
document.addEventListener("click", (event) => {
|
||||
if (!root.contains(event.target)) {
|
||||
chooserOpen = false;
|
||||
renderInlineState();
|
||||
}
|
||||
});
|
||||
|
||||
window.addEventListener("scroll", () => {
|
||||
positionDock();
|
||||
}, true);
|
||||
|
||||
window.addEventListener("resize", () => {
|
||||
positionDock();
|
||||
});
|
||||
|
||||
const observer = new MutationObserver((records) => {
|
||||
if (records.some((record) => record.type === "childList")) {
|
||||
scheduleRefresh(false);
|
||||
}
|
||||
});
|
||||
|
||||
observer.observe(document.documentElement, {
|
||||
childList: true,
|
||||
subtree: true
|
||||
});
|
||||
|
||||
ext.runtime.onMessage.addListener((message, _sender, sendResponse) => {
|
||||
if (message?.type === "keepassgo-fill-credential") {
|
||||
try {
|
||||
sendResponse(fillCredential(message.credential || {}, message.target || pageState.focusTarget));
|
||||
} catch (error) {
|
||||
sendResponse({ ok: false, error: error instanceof Error ? error.message : String(error) });
|
||||
}
|
||||
return false;
|
||||
}
|
||||
if (message?.type === "keepassgo-page-state") {
|
||||
pageState = {
|
||||
...pageState,
|
||||
...(message.state || {})
|
||||
};
|
||||
renderInlineState();
|
||||
sendResponse({ ok: true });
|
||||
return false;
|
||||
}
|
||||
if (message?.type === "keepassgo-page-scan") {
|
||||
const scan = scanLoginFields();
|
||||
sendResponse({
|
||||
pageHasLoginForm: scan.pageHasLoginForm,
|
||||
focusTarget: scan.focusTarget,
|
||||
signature: scan.signature
|
||||
});
|
||||
return false;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
reportFieldState(true);
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
|
||||
const content = require("./content.js");
|
||||
|
||||
test("inlineMatchSummary includes username, host, and path context", () => {
|
||||
const summary = content.inlineMatchSummary({
|
||||
username: "dannyocean",
|
||||
url: "https://vault.example.invalid/login",
|
||||
path: ["Root", "Crew"]
|
||||
});
|
||||
|
||||
assert.equal(summary, "dannyocean · vault.example.invalid · Root / Crew");
|
||||
});
|
||||
|
||||
test("domainLabel tolerates invalid URLs", () => {
|
||||
assert.equal(content.domainLabel("https://vault.example.invalid"), "vault.example.invalid");
|
||||
assert.equal(content.domainLabel("not-a-url"), "");
|
||||
});
|
||||
|
||||
test("shouldShowInlineOverlay hides the page overlay after it is suppressed", () => {
|
||||
const state = {
|
||||
pageHasLoginForm: true,
|
||||
configured: true,
|
||||
success: true,
|
||||
status: { locked: false },
|
||||
matches: [{ id: "vault-console" }],
|
||||
pendingFill: false
|
||||
};
|
||||
|
||||
assert.equal(content.shouldShowInlineOverlay(state, true, false), true);
|
||||
assert.equal(content.shouldShowInlineOverlay(state, true, true), false);
|
||||
});
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"manifest_version": 3,
|
||||
"name": "KeePassGO Browser",
|
||||
"version": "0.1.0",
|
||||
"description": "Fill credentials from KeePassGO on sign-in pages.",
|
||||
"permissions": ["activeTab", "nativeMessaging", "storage", "tabs"],
|
||||
"host_permissions": ["http://*/*", "https://*/*"],
|
||||
"background": {
|
||||
"service_worker": "background.js"
|
||||
},
|
||||
"action": {
|
||||
"default_title": "KeePassGO Browser",
|
||||
"default_popup": "popup.html"
|
||||
},
|
||||
"options_ui": {
|
||||
"page": "options.html",
|
||||
"open_in_tab": true
|
||||
},
|
||||
"content_scripts": [
|
||||
{
|
||||
"matches": ["http://*/*", "https://*/*"],
|
||||
"js": ["content.js"],
|
||||
"run_at": "document_idle"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"manifest_version": 2,
|
||||
"name": "KeePassGO Browser",
|
||||
"version": "0.1.0",
|
||||
"description": "Fill credentials from KeePassGO on sign-in pages.",
|
||||
"permissions": [
|
||||
"activeTab",
|
||||
"nativeMessaging",
|
||||
"storage",
|
||||
"tabs",
|
||||
"http://*/*",
|
||||
"https://*/*"
|
||||
],
|
||||
"background": {
|
||||
"scripts": ["background.js"]
|
||||
},
|
||||
"browser_action": {
|
||||
"default_title": "KeePassGO Browser",
|
||||
"default_popup": "popup.html"
|
||||
},
|
||||
"options_ui": {
|
||||
"page": "options.html",
|
||||
"open_in_tab": true
|
||||
},
|
||||
"content_scripts": [
|
||||
{
|
||||
"matches": ["http://*/*", "https://*/*"],
|
||||
"js": ["content.js"],
|
||||
"run_at": "document_idle"
|
||||
}
|
||||
],
|
||||
"browser_specific_settings": {
|
||||
"gecko": {
|
||||
"id": "browser@keepassgo.com"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>KeePassGO Browser Settings</title>
|
||||
<link rel="stylesheet" href="style.css">
|
||||
</head>
|
||||
<body>
|
||||
<main class="surface settings">
|
||||
<header class="topbar">
|
||||
<div>
|
||||
<h1>Browser Settings</h1>
|
||||
<p class="subtle">Connect the extension to KeePassGO.</p>
|
||||
</div>
|
||||
</header>
|
||||
<form id="settings-form" class="settings-form">
|
||||
<label>
|
||||
<span>API token</span>
|
||||
<textarea id="bearer-token" name="bearer-token" rows="6" spellcheck="false"></textarea>
|
||||
</label>
|
||||
<div class="actions">
|
||||
<button type="submit">Save</button>
|
||||
</div>
|
||||
<p id="settings-status" class="subtle"></p>
|
||||
</form>
|
||||
</main>
|
||||
<script src="options.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,49 @@
|
||||
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;
|
||||
if (error) {
|
||||
reject(new Error(error.message));
|
||||
return;
|
||||
}
|
||||
resolve(response);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function loadSettings() {
|
||||
const response = await runtimeSend({ type: "keepassgo-load-settings" });
|
||||
if (!response?.success) {
|
||||
throw new Error(response?.error || "Could not load settings.");
|
||||
}
|
||||
document.getElementById("bearer-token").value = response.settings.bearerToken || "";
|
||||
}
|
||||
|
||||
async function saveSettings(event) {
|
||||
event.preventDefault();
|
||||
const status = document.getElementById("settings-status");
|
||||
status.textContent = "Saving…";
|
||||
try {
|
||||
const response = await runtimeSend({
|
||||
type: "keepassgo-save-settings",
|
||||
settings: {
|
||||
bearerToken: document.getElementById("bearer-token").value
|
||||
}
|
||||
});
|
||||
if (!response?.success) {
|
||||
throw new Error(response?.error || "Could not save settings.");
|
||||
}
|
||||
status.textContent = "Saved.";
|
||||
} catch (error) {
|
||||
status.textContent = error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
}
|
||||
|
||||
document.getElementById("settings-form").addEventListener("submit", saveSettings);
|
||||
void loadSettings();
|
||||
@@ -0,0 +1,30 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>KeePassGO Browser</title>
|
||||
<link rel="stylesheet" href="style.css">
|
||||
</head>
|
||||
<body class="popup">
|
||||
<main class="surface">
|
||||
<header class="topbar">
|
||||
<div>
|
||||
<h1>KeePassGO</h1>
|
||||
<p id="page-host" class="subtle">Checking current page</p>
|
||||
</div>
|
||||
<a href="options.html" target="_blank" rel="noreferrer" class="link-button">Settings</a>
|
||||
</header>
|
||||
<section id="status-card" class="status-card">
|
||||
<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>
|
||||
</section>
|
||||
</main>
|
||||
<script src="popup.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,173 @@
|
||||
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;
|
||||
if (error) {
|
||||
reject(new Error(error.message));
|
||||
return;
|
||||
}
|
||||
resolve(response);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function hostFromURL(rawURL) {
|
||||
try {
|
||||
return new URL(rawURL).host || rawURL;
|
||||
} catch (_error) {
|
||||
return rawURL || "Current page";
|
||||
}
|
||||
}
|
||||
|
||||
function setStatus(title, message, tone) {
|
||||
const card = document.getElementById("status-card");
|
||||
card.dataset.tone = tone || "neutral";
|
||||
document.getElementById("status-title").textContent = title;
|
||||
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 = state.pageHasLoginForm
|
||||
? "No matching entries for this page."
|
||||
: "No login fields detected on this page.";
|
||||
root.appendChild(empty);
|
||||
return;
|
||||
}
|
||||
|
||||
for (const match of state.matches) {
|
||||
const row = document.createElement("button");
|
||||
row.type = "button";
|
||||
row.className = "match-row";
|
||||
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,
|
||||
tabId: targetTabID
|
||||
});
|
||||
if (!result?.success) {
|
||||
throw new Error(result?.error || "Fill failed.");
|
||||
}
|
||||
setStatus("Filled", `${match.title} was sent to the current page.`, "ready");
|
||||
} catch (error) {
|
||||
setStatus("Fill failed", error instanceof Error ? error.message : String(error), "error");
|
||||
} finally {
|
||||
row.disabled = false;
|
||||
}
|
||||
});
|
||||
root.appendChild(row);
|
||||
}
|
||||
}
|
||||
|
||||
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",
|
||||
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(state);
|
||||
return;
|
||||
}
|
||||
if (state.status?.locked) {
|
||||
setStatus("Vault locked", "Unlock KeePassGO, then try the page again.", "warning");
|
||||
renderMatches(state);
|
||||
return;
|
||||
}
|
||||
|
||||
const count = Array.isArray(state.matches) ? state.matches.length : 0;
|
||||
if (!state.pageHasLoginForm) {
|
||||
setStatus("Ready", "KeePassGO is connected. Open a login form to check for matches.", "ready");
|
||||
} else if (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");
|
||||
renderMatches({ matches: [] });
|
||||
}
|
||||
}
|
||||
|
||||
void main();
|
||||
@@ -0,0 +1,178 @@
|
||||
:root {
|
||||
color-scheme: light;
|
||||
--ink: #214f44;
|
||||
--ink-soft: #4d6d66;
|
||||
--surface: #fffdfa;
|
||||
--surface-2: #f2f7f3;
|
||||
--line: #d7e3dc;
|
||||
--accent: #255f4a;
|
||||
--accent-soft: #dfeee6;
|
||||
--warn: #9f5f0e;
|
||||
--error: #9f2f2f;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font: 14px/1.4 "Noto Sans", "Liberation Sans", sans-serif;
|
||||
color: var(--ink);
|
||||
background:
|
||||
radial-gradient(circle at top right, #ecf5ef, transparent 38%),
|
||||
linear-gradient(180deg, #f8fbf8, #eef4f0);
|
||||
}
|
||||
|
||||
body.popup {
|
||||
min-width: 360px;
|
||||
}
|
||||
|
||||
.surface {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.topbar {
|
||||
display: flex;
|
||||
align-items: start;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
h1,
|
||||
h2,
|
||||
p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 22px;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 14px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
margin-bottom: 8px;
|
||||
color: var(--ink-soft);
|
||||
}
|
||||
|
||||
.subtle {
|
||||
color: var(--ink-soft);
|
||||
}
|
||||
|
||||
.status-card {
|
||||
padding: 12px 14px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid var(--line);
|
||||
background: var(--surface);
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.status-card[data-tone="ready"] {
|
||||
border-color: #c5dccf;
|
||||
background: var(--accent-soft);
|
||||
}
|
||||
|
||||
.status-card[data-tone="warning"] {
|
||||
border-color: #e4d0ae;
|
||||
background: #fbf4e7;
|
||||
}
|
||||
|
||||
.status-card[data-tone="error"] {
|
||||
border-color: #e4bcbc;
|
||||
background: #fcf1f1;
|
||||
}
|
||||
|
||||
.inline-hint {
|
||||
margin: -6px 0 16px;
|
||||
}
|
||||
|
||||
.match-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.match-row,
|
||||
button,
|
||||
.link-button {
|
||||
appearance: none;
|
||||
border: 0;
|
||||
border-radius: 12px;
|
||||
background: var(--surface);
|
||||
color: var(--ink);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.match-row {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 12px 14px;
|
||||
border: 1px solid var(--line);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.match-row:hover,
|
||||
button:hover,
|
||||
.link-button:hover {
|
||||
background: var(--surface-2);
|
||||
}
|
||||
|
||||
.match-main {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: start;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.quality {
|
||||
font-size: 12px;
|
||||
color: var(--ink-soft);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.settings {
|
||||
max-width: 720px;
|
||||
margin: 0 auto;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.settings-form {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
label {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
input,
|
||||
textarea {
|
||||
width: 100%;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 10px;
|
||||
background: #fff;
|
||||
color: var(--ink);
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
button,
|
||||
.link-button {
|
||||
padding: 10px 14px;
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
justify-content: end;
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"runtime"
|
||||
"strings"
|
||||
|
||||
"git.julianfamily.org/keepassgo/internal/browserbridge"
|
||||
"git.julianfamily.org/keepassgo/internal/grpcaddr"
|
||||
"google.golang.org/grpc"
|
||||
)
|
||||
|
||||
type bridgeConfig struct {
|
||||
grpcAddr string
|
||||
}
|
||||
|
||||
func main() {
|
||||
cfg := bridgeConfig{
|
||||
grpcAddr: resolveGlobalGRPCAddr(os.Args[1:]),
|
||||
}
|
||||
if len(os.Args) > 1 {
|
||||
switch strings.TrimSpace(os.Args[1]) {
|
||||
case "install-native-host":
|
||||
if err := runInstallNativeHost(os.Args[2:]); err != nil {
|
||||
fail(err)
|
||||
}
|
||||
return
|
||||
case "status":
|
||||
if err := runStatus(cfg, stripGlobalGRPCAddrFlags(os.Args[2:])); err != nil {
|
||||
fail(err)
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
if err := runNativeMessage(cfg); err != nil {
|
||||
_ = browserbridge.WriteResponse(os.Stdout, browserbridge.Response{Success: false, Error: err.Error()})
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func runInstallNativeHost(args []string) error {
|
||||
fs := flag.NewFlagSet("install-native-host", flag.ContinueOnError)
|
||||
browserName := fs.String("browser", string(browserbridge.BrowserFirefox), "target browser: firefox, chrome, chromium")
|
||||
binaryPath := fs.String("binary", "", "path to keepassgo-browser-bridge binary")
|
||||
extensionID := fs.String("extension-id", "", "browser extension id (required for chrome/chromium)")
|
||||
extensionKey := fs.String("extension-key", "", "Chromium manifest public key used to derive a fixed extension id")
|
||||
extensionKeyFile := fs.String("extension-key-file", "", "path to a Chromium manifest public key file")
|
||||
outputPath := fs.String("output", "", "native host manifest output path")
|
||||
if err := fs.Parse(args); err != nil {
|
||||
return err
|
||||
}
|
||||
path := strings.TrimSpace(*binaryPath)
|
||||
if path == "" {
|
||||
resolved, err := defaultBinaryPath()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
path = resolved
|
||||
}
|
||||
resolvedExtensionID := strings.TrimSpace(*extensionID)
|
||||
if resolvedExtensionID == "" {
|
||||
keyValue := strings.TrimSpace(*extensionKey)
|
||||
if keyValue == "" && strings.TrimSpace(*extensionKeyFile) != "" {
|
||||
data, err := os.ReadFile(strings.TrimSpace(*extensionKeyFile))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
keyValue = string(data)
|
||||
}
|
||||
if keyValue != "" {
|
||||
derivedID, err := browserbridge.ChromiumExtensionIDFromManifestKey(keyValue)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
resolvedExtensionID = derivedID
|
||||
}
|
||||
}
|
||||
installed, err := browserbridge.InstallManifest(browserbridge.Browser(strings.TrimSpace(*browserName)), path, resolvedExtensionID, strings.TrimSpace(*outputPath))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Fprintln(os.Stdout, installed)
|
||||
return nil
|
||||
}
|
||||
|
||||
func runStatus(cfg bridgeConfig, args []string) error {
|
||||
fs := flag.NewFlagSet("status", flag.ContinueOnError)
|
||||
token := fs.String("token", "", "KeePassGO API bearer token")
|
||||
if err := fs.Parse(args); err != nil {
|
||||
return err
|
||||
}
|
||||
req := browserbridge.Request{
|
||||
Action: "status",
|
||||
BearerToken: strings.TrimSpace(*token),
|
||||
}
|
||||
conn, client, ctx, err := dialBridge(context.Background(), cfg, req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { _ = conn.Close() }()
|
||||
resp := browserbridge.HandleRequest(ctx, req, cfg.grpcAddr, client)
|
||||
enc := json.NewEncoder(os.Stdout)
|
||||
enc.SetIndent("", " ")
|
||||
return enc.Encode(resp)
|
||||
}
|
||||
|
||||
func runNativeMessage(cfg bridgeConfig) error {
|
||||
req, err := browserbridge.ReadRequest(os.Stdin)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
conn, client, ctx, err := dialBridge(context.Background(), cfg, req)
|
||||
if err != nil {
|
||||
return browserbridge.WriteResponse(os.Stdout, browserbridge.Response{Success: false, Error: err.Error()})
|
||||
}
|
||||
defer func() { _ = conn.Close() }()
|
||||
return browserbridge.WriteResponse(os.Stdout, browserbridge.HandleRequest(ctx, req, cfg.grpcAddr, client))
|
||||
}
|
||||
|
||||
func dialBridge(ctx context.Context, cfg bridgeConfig, req browserbridge.Request) (*grpc.ClientConn, *browserbridge.GRPCClient, context.Context, error) {
|
||||
return browserbridge.DialRequest(ctx, req, cfg.grpcAddr)
|
||||
}
|
||||
|
||||
func defaultBinaryPath() (string, error) {
|
||||
return browserbridge.ResolveBridgeBinaryPath("")
|
||||
}
|
||||
|
||||
func resolveGlobalGRPCAddr(args []string) string {
|
||||
addr := grpcaddr.Default(runtime.GOOS)
|
||||
for i := 0; i < len(args); i++ {
|
||||
arg := strings.TrimSpace(args[i])
|
||||
switch {
|
||||
case arg == "--grpc-addr" && i+1 < len(args):
|
||||
return strings.TrimSpace(args[i+1])
|
||||
case strings.HasPrefix(arg, "--grpc-addr="):
|
||||
return strings.TrimSpace(strings.TrimPrefix(arg, "--grpc-addr="))
|
||||
}
|
||||
}
|
||||
return addr
|
||||
}
|
||||
|
||||
func stripGlobalGRPCAddrFlags(args []string) []string {
|
||||
out := make([]string, 0, len(args))
|
||||
for i := 0; i < len(args); i++ {
|
||||
arg := strings.TrimSpace(args[i])
|
||||
switch {
|
||||
case arg == "--grpc-addr" && i+1 < len(args):
|
||||
i++
|
||||
continue
|
||||
case strings.HasPrefix(arg, "--grpc-addr="):
|
||||
continue
|
||||
default:
|
||||
out = append(out, args[i])
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func fail(err error) {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
# Browser Extension
|
||||
|
||||
KeePassGO browser integration uses:
|
||||
|
||||
- the existing local gRPC API in KeePassGO
|
||||
- API tokens for authorization
|
||||
- a tiny native messaging host for browser-to-gRPC transport adaptation
|
||||
|
||||
The browser extension does **not** talk to vault files directly.
|
||||
|
||||
## Security Model
|
||||
|
||||
- KeePassGO remains the source of truth for authentication, authorization, approvals, and audit events.
|
||||
- The browser extension stores the API token in browser extension storage.
|
||||
- The native messaging host receives the token on each request from the extension.
|
||||
- The native messaging host uses the token only to attach `authorization: Bearer ...` metadata to the local gRPC request.
|
||||
- The native messaging host does not persist the token to disk.
|
||||
|
||||
The native messaging host is therefore part of the trusted client for that browser profile. Scope the API token accordingly.
|
||||
|
||||
## RPCs Used
|
||||
|
||||
The browser integration uses:
|
||||
|
||||
- `GetSessionStatus`
|
||||
- `FindBrowserLogins`
|
||||
- `GetBrowserCredential`
|
||||
|
||||
The browser feature intentionally stays on the same secure gRPC surface used by other trusted automation.
|
||||
|
||||
## Default Listener
|
||||
|
||||
On desktop KeePassGO listens on a Unix socket by default:
|
||||
|
||||
- primary location: under the user runtime directory
|
||||
- fallback: `/run/user/<uid>` if present
|
||||
- final fallback: a private directory under the system temp directory
|
||||
|
||||
Override the listener with `-grpc-addr` or `KEEPASSGO_GRPC_ADDR`, for example:
|
||||
|
||||
```bash
|
||||
KEEPASSGO_GRPC_ADDR=tcp://127.0.0.1:47777 ./keepassgo
|
||||
```
|
||||
|
||||
## Native Host
|
||||
|
||||
Build the bridge:
|
||||
|
||||
```bash
|
||||
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
|
||||
./keepassgo-browser-bridge install-native-host --browser firefox --binary /absolute/path/to/keepassgo-browser-bridge
|
||||
```
|
||||
|
||||
Install a Chromium native messaging manifest:
|
||||
|
||||
```bash
|
||||
./keepassgo-browser-bridge install-native-host --browser chromium --binary /absolute/path/to/keepassgo-browser-bridge --extension-key-file /path/to/chromium-extension-public-key.txt
|
||||
```
|
||||
|
||||
Chrome and Chromium require the actual extension id in the native host manifest. KeePassGO can derive that id from the Chromium manifest public key so you do not have to type it separately.
|
||||
|
||||
For a fixed Chromium ID:
|
||||
|
||||
1. Keep a stable Chromium extension signing key outside the repo.
|
||||
2. Add the corresponding public key to the Chromium manifest as `"key": "<base64-public-key>"`.
|
||||
3. Use the same public key with `install-native-host --extension-key-file ...` so the native host manifest is locked to that stable extension ID.
|
||||
|
||||
## Extension Setup
|
||||
|
||||
Firefox:
|
||||
|
||||
1. Load `browser/extension/manifest.firefox.json` as a temporary add-on or package it as an extension.
|
||||
2. Open the extension settings page.
|
||||
3. Paste an API token scoped for browser login lookup and credential copy.
|
||||
|
||||
Chromium / Chrome:
|
||||
|
||||
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 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
|
||||
|
||||
At minimum, the browser token should have policy rules allowing:
|
||||
|
||||
- `list_entries` for the groups you want the browser to search
|
||||
- `copy_username` for entries the browser may fill
|
||||
- `copy_password` for entries the browser may fill
|
||||
- `copy_url` for entries the browser may confirm against page URL
|
||||
+53
-3
@@ -4,10 +4,13 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"git.julianfamily.org/keepassgo/internal/clipboard"
|
||||
"git.julianfamily.org/keepassgo/internal/grpcaddr"
|
||||
"git.julianfamily.org/keepassgo/internal/passwords"
|
||||
"git.julianfamily.org/keepassgo/internal/session"
|
||||
"git.julianfamily.org/keepassgo/internal/vault"
|
||||
@@ -27,6 +30,7 @@ type Host struct {
|
||||
lastModel vault.Model
|
||||
started bool
|
||||
listenAddr string
|
||||
socketPath string
|
||||
}
|
||||
|
||||
func StartHost(addr string, lifecycle lifecycleBackend, profiles map[string]passwords.Profile, clipboardWriter clipboard.Writer, dirty DirtyProvider) (*Host, error) {
|
||||
@@ -35,7 +39,11 @@ func StartHost(addr string, lifecycle lifecycleBackend, profiles map[string]pass
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
listener, err := net.Listen("tcp", addr)
|
||||
network, endpoint, err := grpcaddr.Parse(addr)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
listener, socketPath, err := listen(network, endpoint)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("listen gRPC host %s: %w", addr, err)
|
||||
}
|
||||
@@ -50,7 +58,8 @@ func StartHost(addr string, lifecycle lifecycleBackend, profiles map[string]pass
|
||||
listener: listener,
|
||||
lifecycle: lifecycle,
|
||||
dirty: dirty,
|
||||
listenAddr: listener.Addr().String(),
|
||||
listenAddr: formatListenAddress(network, listener.Addr().String(), socketPath),
|
||||
socketPath: socketPath,
|
||||
started: true,
|
||||
}
|
||||
if err := host.SyncFromLifecycle(); err != nil && !errors.Is(err, session.ErrLocked) {
|
||||
@@ -91,7 +100,16 @@ func (h *Host) Stop() error {
|
||||
}
|
||||
h.started = false
|
||||
h.grpcServer.Stop()
|
||||
return h.listener.Close()
|
||||
err := h.listener.Close()
|
||||
if errors.Is(err, net.ErrClosed) {
|
||||
err = nil
|
||||
}
|
||||
if h.socketPath != "" {
|
||||
if removeErr := os.Remove(h.socketPath); removeErr != nil && !errors.Is(removeErr, os.ErrNotExist) && err == nil {
|
||||
err = removeErr
|
||||
}
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func (h *Host) SyncFromLifecycle() error {
|
||||
@@ -120,3 +138,35 @@ func (h *Host) SyncFromLifecycle() error {
|
||||
h.server.SetSessionState(h.lastModel, locked, dirty)
|
||||
return nil
|
||||
}
|
||||
|
||||
func listen(network, endpoint string) (net.Listener, string, error) {
|
||||
if network == "unix" {
|
||||
if err := os.MkdirAll(filepath.Dir(endpoint), 0o700); err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
if err := os.Remove(endpoint); err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||
return nil, "", err
|
||||
}
|
||||
listener, err := net.Listen("unix", endpoint)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
if err := os.Chmod(endpoint, 0o600); err != nil {
|
||||
_ = listener.Close()
|
||||
return nil, "", err
|
||||
}
|
||||
return listener, endpoint, nil
|
||||
}
|
||||
listener, err := net.Listen(network, endpoint)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
return listener, "", nil
|
||||
}
|
||||
|
||||
func formatListenAddress(network, listenerAddr, socketPath string) string {
|
||||
if network == "unix" {
|
||||
return "unix://" + socketPath
|
||||
}
|
||||
return listenerAddr
|
||||
}
|
||||
|
||||
@@ -2,10 +2,13 @@ package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"git.julianfamily.org/keepassgo/internal/apitokens"
|
||||
"git.julianfamily.org/keepassgo/internal/grpcaddr"
|
||||
"git.julianfamily.org/keepassgo/internal/passwords"
|
||||
"git.julianfamily.org/keepassgo/internal/session"
|
||||
"git.julianfamily.org/keepassgo/internal/vault"
|
||||
@@ -42,10 +45,14 @@ func TestStartHostServesVaultLifecycleAndSyncsSessionState(t *testing.T) {
|
||||
}
|
||||
defer func() { _ = host.Stop() }()
|
||||
|
||||
network, endpoint, err := grpcaddr.Parse(host.Address())
|
||||
if err != nil {
|
||||
t.Fatalf("Parse(host.Address()) error = %v", err)
|
||||
}
|
||||
conn, err := grpc.NewClient("passthrough:///"+host.Address(),
|
||||
grpc.WithTransportCredentials(insecure.NewCredentials()),
|
||||
grpc.WithContextDialer(func(context.Context, string) (net.Conn, error) {
|
||||
return net.Dial("tcp", host.Address())
|
||||
return net.Dial(network, endpoint)
|
||||
}),
|
||||
)
|
||||
if err != nil {
|
||||
@@ -80,3 +87,37 @@ func TestStartHostServesVaultLifecycleAndSyncsSessionState(t *testing.T) {
|
||||
t.Fatal("GetSessionStatus().Locked = false, want true after lifecycle lock")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStartHostServesOverUnixSocket(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
socketDir := t.TempDir()
|
||||
socketPath := socketDir + "/keepassgo.sock"
|
||||
lifecycle := &session.Manager{}
|
||||
if err := lifecycle.Create(vault.Model{
|
||||
Entries: []vault.Entry{
|
||||
testAPITokenEntry(t,
|
||||
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationManageVault, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root"}}},
|
||||
),
|
||||
},
|
||||
}, vault.MasterKey{Password: "correct horse battery staple"}); err != nil {
|
||||
t.Fatalf("Create() error = %v", err)
|
||||
}
|
||||
|
||||
host, err := StartHost("unix://"+socketPath, lifecycle, passwords.DefaultProfiles(), nil, func() bool { return false })
|
||||
if err != nil {
|
||||
t.Fatalf("StartHost() error = %v", err)
|
||||
}
|
||||
if got := host.Address(); got != "unix://"+socketPath {
|
||||
t.Fatalf("host.Address() = %q, want %q", got, "unix://"+socketPath)
|
||||
}
|
||||
if _, err := os.Stat(socketPath); err != nil {
|
||||
t.Fatalf("Stat(socketPath) error = %v", err)
|
||||
}
|
||||
if err := host.Stop(); err != nil {
|
||||
t.Fatalf("Stop() error = %v", err)
|
||||
}
|
||||
if _, err := os.Stat(socketPath); !errors.Is(err, os.ErrNotExist) {
|
||||
t.Fatalf("socket exists after Stop(), err = %v, want not-exist", err)
|
||||
}
|
||||
}
|
||||
|
||||
+414
-78
@@ -3,7 +3,9 @@ package api
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"maps"
|
||||
"net/url"
|
||||
"os"
|
||||
"slices"
|
||||
"strings"
|
||||
@@ -17,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"
|
||||
@@ -36,6 +39,7 @@ type Server struct {
|
||||
clipboard clipboard.Writer
|
||||
approvals *apiapproval.Broker
|
||||
audit *apiaudit.Log
|
||||
notify func()
|
||||
}
|
||||
|
||||
type lifecycleBackend interface {
|
||||
@@ -52,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,
|
||||
@@ -76,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)
|
||||
}
|
||||
@@ -89,16 +109,29 @@ 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
|
||||
}
|
||||
pendingApprovals := s.approvals.Pending()
|
||||
var tokenPending uint32
|
||||
for _, pending := range pendingApprovals {
|
||||
if pending.TokenID == token.ID {
|
||||
tokenPending++
|
||||
}
|
||||
}
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
locked := s.locked
|
||||
dirty := s.dirty
|
||||
entryCount := uint32(len(s.model.Entries))
|
||||
s.mu.RUnlock()
|
||||
|
||||
return &keepassgov1.GetSessionStatusResponse{
|
||||
Locked: s.locked,
|
||||
Dirty: s.dirty,
|
||||
EntryCount: uint32(len(s.model.Entries)),
|
||||
Locked: locked,
|
||||
Dirty: dirty,
|
||||
EntryCount: entryCount,
|
||||
PendingApprovalCount: uint32(len(pendingApprovals)),
|
||||
TokenPendingApprovalCount: tokenPending,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -225,6 +258,172 @@ func (s *Server) UnlockVault(ctx context.Context, req *keepassgov1.UnlockVaultRe
|
||||
return &keepassgov1.UnlockVaultResponse{}, nil
|
||||
}
|
||||
|
||||
func (s *Server) FindBrowserLogins(ctx context.Context, req *keepassgov1.FindBrowserLoginsRequest) (*keepassgov1.FindBrowserLoginsResponse, error) {
|
||||
model, locked := s.snapshotModel()
|
||||
if locked {
|
||||
return nil, status.Error(codes.FailedPrecondition, "vault is locked")
|
||||
}
|
||||
displayModel := visibleModel(model)
|
||||
token, err := s.authenticateRequest(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
pageHost, err := normalizedBrowserHost(req.GetPageUrl())
|
||||
if err != nil {
|
||||
return nil, status.Error(codes.InvalidArgument, err.Error())
|
||||
}
|
||||
|
||||
var matches []rankedBrowserMatch
|
||||
for _, entry := range displayModel.Entries {
|
||||
quality, score := classifyBrowserEntryMatch(pageHost, entry.URL)
|
||||
if score == 0 {
|
||||
continue
|
||||
}
|
||||
resource := apitokens.Resource{Kind: apitokens.ResourceGroup, Path: entry.Path}
|
||||
matches = append(matches, rankedBrowserMatch{
|
||||
match: &keepassgov1.BrowserLoginMatch{
|
||||
Id: entry.ID,
|
||||
Title: entry.Title,
|
||||
Username: entry.Username,
|
||||
Url: entry.URL,
|
||||
Path: append([]string(nil), entry.Path...),
|
||||
Quality: quality,
|
||||
},
|
||||
score: score,
|
||||
resource: resource,
|
||||
decision: apitokens.Evaluate(token, apitokens.OperationListEntries, resource),
|
||||
})
|
||||
}
|
||||
slices.SortFunc(matches, func(a, b rankedBrowserMatch) int {
|
||||
switch {
|
||||
case a.score != b.score:
|
||||
return b.score - a.score
|
||||
case a.match.GetTitle() != b.match.GetTitle():
|
||||
return strings.Compare(a.match.GetTitle(), b.match.GetTitle())
|
||||
case a.match.GetUsername() != b.match.GetUsername():
|
||||
return strings.Compare(a.match.GetUsername(), b.match.GetUsername())
|
||||
default:
|
||||
return strings.Compare(a.match.GetId(), b.match.GetId())
|
||||
}
|
||||
})
|
||||
out, err := s.authorizedBrowserMatches(ctx, token, matches)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
switch len(out) {
|
||||
case 1:
|
||||
s.audit.Record(apiaudit.Event{
|
||||
Type: apiaudit.EventAutofillFound,
|
||||
TokenID: token.ID,
|
||||
TokenName: token.Name,
|
||||
ClientName: token.ClientName,
|
||||
Operation: apitokens.OperationListEntries,
|
||||
Message: "browser login match found for " + pageHost,
|
||||
})
|
||||
case 2, 3, 4, 5:
|
||||
s.audit.Record(apiaudit.Event{
|
||||
Type: apiaudit.EventAutofillAmbiguous,
|
||||
TokenID: token.ID,
|
||||
TokenName: token.Name,
|
||||
ClientName: token.ClientName,
|
||||
Operation: apitokens.OperationListEntries,
|
||||
Message: "browser login search returned multiple matches for " + pageHost,
|
||||
})
|
||||
}
|
||||
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 s.authorizedBrowserMatchesWithinPath(ctx, token, matches, match.resource.Path)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *Server) authorizedBrowserMatchesWithinPath(_ context.Context, _ apitokens.Token, matches []rankedBrowserMatch, path []string) ([]*keepassgov1.BrowserLoginMatch, error) {
|
||||
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)]) {
|
||||
continue
|
||||
}
|
||||
if match.decision == apitokens.DecisionDeny {
|
||||
continue
|
||||
}
|
||||
out = append(out, match.match)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *Server) GetBrowserCredential(ctx context.Context, req *keepassgov1.GetBrowserCredentialRequest) (*keepassgov1.GetBrowserCredentialResponse, error) {
|
||||
model, locked := s.snapshotModel()
|
||||
if locked {
|
||||
return nil, status.Error(codes.FailedPrecondition, "vault is locked")
|
||||
}
|
||||
token, err := s.authenticateRequest(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
entry, err := findEntryByID(model, req.GetId())
|
||||
if err != nil {
|
||||
return nil, status.Error(codes.NotFound, err.Error())
|
||||
}
|
||||
if pageURL := strings.TrimSpace(req.GetPageUrl()); pageURL != "" {
|
||||
pageHost, err := normalizedBrowserHost(pageURL)
|
||||
if err != nil {
|
||||
return nil, status.Error(codes.InvalidArgument, err.Error())
|
||||
}
|
||||
if _, score := classifyBrowserEntryMatch(pageHost, entry.URL); score == 0 {
|
||||
return nil, status.Error(codes.InvalidArgument, "entry url does not match requested page")
|
||||
}
|
||||
}
|
||||
if strings.TrimSpace(entry.Username) != "" {
|
||||
if _, err := s.authorizeResourceRequest(ctx, token, apitokens.OperationCopyUsername, apitokens.Resource{Kind: apitokens.ResourceEntry, EntryID: entry.ID, Path: entry.Path}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if _, err := s.authorizeResourceRequest(ctx, token, apitokens.OperationCopyPassword, apitokens.Resource{Kind: apitokens.ResourceEntry, EntryID: entry.ID, Path: entry.Path}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if strings.TrimSpace(entry.URL) != "" {
|
||||
if _, err := s.authorizeResourceRequest(ctx, token, apitokens.OperationCopyURL, apitokens.Resource{Kind: apitokens.ResourceEntry, EntryID: entry.ID, Path: entry.Path}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
s.audit.Record(apiaudit.Event{
|
||||
Type: apiaudit.EventAutofillFound,
|
||||
TokenID: token.ID,
|
||||
TokenName: token.Name,
|
||||
ClientName: token.ClientName,
|
||||
Operation: apitokens.OperationCopyPassword,
|
||||
Resource: apitokens.Resource{Kind: apitokens.ResourceEntry, EntryID: entry.ID, Path: entry.Path},
|
||||
Message: "browser credential returned for " + entry.ID,
|
||||
})
|
||||
return &keepassgov1.GetBrowserCredentialResponse{
|
||||
Id: entry.ID,
|
||||
Username: entry.Username,
|
||||
Password: entry.Password,
|
||||
Url: entry.URL,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func mapLifecycleError(operation string, err error) error {
|
||||
switch {
|
||||
case errors.Is(err, os.ErrNotExist):
|
||||
@@ -245,11 +444,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())
|
||||
@@ -258,14 +459,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
|
||||
@@ -276,76 +477,86 @@ 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 {
|
||||
return nil, err
|
||||
func (s *Server) mutateAuthorizedVisiblePath(ctx context.Context, clientPath []string, op apitokens.Operation, mutate func(*vault.Model, []string) error) error {
|
||||
model, locked := s.snapshotModel()
|
||||
if locked {
|
||||
return status.Error(codes.FailedPrecondition, "vault is locked")
|
||||
}
|
||||
internalPath := expandClientPath(visibleModel(model), clientPath)
|
||||
if _, err := s.authorizePathRequest(ctx, op, internalPath); err != nil {
|
||||
return err
|
||||
}
|
||||
return s.mutateAuthorizedModel(func() error { return nil }, func(model *vault.Model) error {
|
||||
return mutate(model, internalPath)
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) mutateAuthorizedModel(authorize func() error, mutate func(*vault.Model) error) error {
|
||||
if err := authorize(); err != nil {
|
||||
return err
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if s.locked {
|
||||
return nil, status.Error(codes.FailedPrecondition, "vault is locked")
|
||||
if err := mutate(&s.model); err != nil {
|
||||
return err
|
||||
}
|
||||
s.dirty = true
|
||||
s.syncMutationLocked()
|
||||
return nil
|
||||
}
|
||||
|
||||
s.model.CreateGroup(req.GetParentPath(), req.GetName())
|
||||
s.dirty = true
|
||||
func (s *Server) CreateGroup(ctx context.Context, req *keepassgov1.CreateGroupRequest) (*keepassgov1.CreateGroupResponse, error) {
|
||||
if err := s.mutateAuthorizedVisiblePath(ctx, req.GetParentPath(), apitokens.OperationMutateGroup, func(model *vault.Model, parentPath []string) error {
|
||||
model.CreateGroup(parentPath, req.GetName())
|
||||
return nil
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
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 {
|
||||
if err := s.mutateAuthorizedVisiblePath(ctx, req.GetPath(), apitokens.OperationMutateGroup, func(model *vault.Model, groupPath []string) error {
|
||||
if err := model.RenameGroup(groupPath, req.GetNewName()); err != nil {
|
||||
if errors.Is(err, vault.ErrEntryNotFound) {
|
||||
return status.Error(codes.NotFound, err.Error())
|
||||
}
|
||||
return status.Errorf(codes.Internal, "rename group: %v", err)
|
||||
}
|
||||
return nil
|
||||
}); 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 errors.Is(err, vault.ErrEntryNotFound) {
|
||||
return nil, status.Error(codes.NotFound, err.Error())
|
||||
}
|
||||
return nil, status.Errorf(codes.Internal, "rename group: %v", err)
|
||||
}
|
||||
|
||||
s.dirty = true
|
||||
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 {
|
||||
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.mutateAuthorizedVisiblePath(ctx, req.GetPath(), apitokens.OperationMutateGroup, func(model *vault.Model, groupPath []string) error {
|
||||
if err := model.DeleteGroup(groupPath); err != nil {
|
||||
switch {
|
||||
case errors.Is(err, vault.ErrEntryNotFound):
|
||||
return nil, status.Error(codes.NotFound, err.Error())
|
||||
return status.Error(codes.NotFound, err.Error())
|
||||
case errors.Is(err, vault.ErrGroupNotEmpty):
|
||||
return nil, status.Error(codes.FailedPrecondition, err.Error())
|
||||
return status.Error(codes.FailedPrecondition, err.Error())
|
||||
default:
|
||||
return nil, status.Errorf(codes.Internal, "delete group: %v", err)
|
||||
return status.Errorf(codes.Internal, "delete group: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
s.dirty = true
|
||||
return nil
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &keepassgov1.DeleteGroupResponse{}, nil
|
||||
}
|
||||
|
||||
@@ -354,21 +565,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
|
||||
}
|
||||
if err := s.mutateAuthorizedModel(func() error { return nil }, func(model *vault.Model) error {
|
||||
model.UpsertEntry(entry)
|
||||
return nil
|
||||
}); 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.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) {
|
||||
@@ -393,6 +605,7 @@ func (s *Server) DeleteEntry(ctx context.Context, req *keepassgov1.DeleteEntryRe
|
||||
}
|
||||
|
||||
s.dirty = true
|
||||
s.syncMutationLocked()
|
||||
return &keepassgov1.DeleteEntryResponse{}, nil
|
||||
}
|
||||
|
||||
@@ -426,7 +639,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) {
|
||||
@@ -447,7 +661,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
|
||||
}
|
||||
@@ -479,7 +693,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) {
|
||||
@@ -497,7 +712,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
|
||||
@@ -518,11 +733,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) {
|
||||
@@ -543,6 +759,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
|
||||
}
|
||||
@@ -554,7 +771,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
|
||||
}
|
||||
|
||||
@@ -565,7 +783,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())
|
||||
@@ -574,7 +793,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) {
|
||||
@@ -626,6 +846,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
|
||||
}
|
||||
@@ -684,6 +905,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
|
||||
}
|
||||
@@ -740,7 +962,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,
|
||||
@@ -749,12 +971,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(),
|
||||
@@ -763,11 +985,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 {
|
||||
@@ -787,6 +1031,53 @@ func findMutableEntryByID(model *vault.Model, id string) (vault.Entry, int, erro
|
||||
return vault.Entry{}, -1, vault.ErrEntryNotFound
|
||||
}
|
||||
|
||||
func normalizedBrowserHost(raw string) (string, error) {
|
||||
parsed, err := url.Parse(strings.TrimSpace(raw))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("parse page url: %w", err)
|
||||
}
|
||||
host := strings.ToLower(parsed.Hostname())
|
||||
if host == "" {
|
||||
return "", fmt.Errorf("page url must include a hostname")
|
||||
}
|
||||
return host, nil
|
||||
}
|
||||
|
||||
func normalizedBrowserEntryHost(raw string) string {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return ""
|
||||
}
|
||||
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
|
||||
}
|
||||
switch {
|
||||
case pageHost == entryHost:
|
||||
return "exact-host", 3
|
||||
case strings.HasSuffix(pageHost, "."+entryHost):
|
||||
return "subdomain", 2
|
||||
default:
|
||||
return "", 0
|
||||
}
|
||||
}
|
||||
|
||||
func visibleModel(model vault.Model) vault.Model {
|
||||
out := model
|
||||
out.Entries = nil
|
||||
@@ -878,6 +1169,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 {
|
||||
@@ -978,14 +1307,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 {
|
||||
|
||||
+566
-4
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -159,6 +236,394 @@ func TestVaultServiceRejectsUnauthorizedPasswordGeneration(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestVaultServiceFindsBrowserLoginsForAuthorizedClients(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
client, _, cleanup := newTestClient(t)
|
||||
defer cleanup()
|
||||
|
||||
ctx := tokenContext(defaultTestTokenSecret)
|
||||
resp, err := client.FindBrowserLogins(ctx, &keepassgov1.FindBrowserLoginsRequest{
|
||||
PageUrl: "https://vault.crew.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 != "vault-console" {
|
||||
t.Fatalf("FindBrowserLogins().Matches[0].Id = %q, want vault-console", resp.Matches[0].Id)
|
||||
}
|
||||
if resp.Matches[0].Quality != "exact-host" {
|
||||
t.Fatalf("FindBrowserLogins().Matches[0].Quality = %q, want exact-host", resp.Matches[0].Quality)
|
||||
}
|
||||
}
|
||||
|
||||
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 TestVaultServiceFindsBrowserLoginsRechecksChildPoliciesAfterPrompt(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
model := vault.Model{
|
||||
Entries: []vault.Entry{
|
||||
{
|
||||
ID: "rusty-casino",
|
||||
Title: "Rusty Casino",
|
||||
Username: "rustyryan",
|
||||
Password: "bellagio-1",
|
||||
URL: "https://vault.heist.example.invalid",
|
||||
Path: []string{"Crews", "Bellagio"},
|
||||
},
|
||||
{
|
||||
ID: "benedict-vault",
|
||||
Title: "Benedict Vault",
|
||||
Username: "terrybenedict",
|
||||
Password: "bellagio-2",
|
||||
URL: "https://vault.heist.example.invalid",
|
||||
Path: []string{"Crews", "Bellagio", "Denied"},
|
||||
},
|
||||
testAPITokenEntry(t,
|
||||
apitokens.PolicyRule{Effect: apitokens.EffectDeny, Operation: apitokens.OperationListEntries, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Crews", "Bellagio", "Denied"}}},
|
||||
),
|
||||
},
|
||||
}
|
||||
client, _, service, cleanup := newTestHarnessForModel(t, model)
|
||||
defer cleanup()
|
||||
service.approvals = apiapproval.NewBroker(time.Minute)
|
||||
|
||||
respCh := make(chan *keepassgov1.FindBrowserLoginsResponse, 1)
|
||||
errCh := make(chan error, 1)
|
||||
go func() {
|
||||
resp, err := client.FindBrowserLogins(tokenContext(defaultTestTokenSecret), &keepassgov1.FindBrowserLoginsRequest{
|
||||
PageUrl: "https://vault.heist.example.invalid/login",
|
||||
})
|
||||
respCh <- resp
|
||||
errCh <- err
|
||||
}()
|
||||
|
||||
pending := waitForServerPendingApproval(t, service, 1)[0]
|
||||
if got := pending.Resource.Path; !slices.Equal(got, []string{"Crews", "Bellagio"}) {
|
||||
t.Fatalf("pending.Resource.Path = %v, want [Crews Bellagio]", got)
|
||||
}
|
||||
if _, _, err := service.ResolveApproval(pending.ID, apiapproval.OutcomeAllowOnce); err != nil {
|
||||
t.Fatalf("ResolveApproval(allow once) error = %v", err)
|
||||
}
|
||||
|
||||
resp := <-respCh
|
||||
if err := <-errCh; 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 got := resp.Matches[0].Id; got != "rusty-casino" {
|
||||
t.Fatalf("FindBrowserLogins().Matches[0].Id = %q, want rusty-casino", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVaultServiceDoesNotMatchSpecificBrowserEntryToParentDomain(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
client, _, cleanup := newTestClientForModel(t, vault.Model{
|
||||
Entries: []vault.Entry{
|
||||
{
|
||||
ID: "inside-man-accounts",
|
||||
Title: "Inside Man Accounts",
|
||||
Username: "daltonrussell",
|
||||
Password: "diamond-1",
|
||||
URL: "https://accounts.heist.example.invalid",
|
||||
Path: []string{"Crews", "Bank"},
|
||||
},
|
||||
testAPITokenEntry(t,
|
||||
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationListEntries, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Crews"}}},
|
||||
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationCopyUsername, Resource: apitokens.Resource{Kind: apitokens.ResourceEntry, EntryID: "inside-man-accounts", Path: []string{"Crews", "Bank"}}},
|
||||
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationCopyPassword, Resource: apitokens.Resource{Kind: apitokens.ResourceEntry, EntryID: "inside-man-accounts", Path: []string{"Crews", "Bank"}}},
|
||||
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationCopyURL, Resource: apitokens.Resource{Kind: apitokens.ResourceEntry, EntryID: "inside-man-accounts", Path: []string{"Crews", "Bank"}}},
|
||||
),
|
||||
},
|
||||
})
|
||||
defer cleanup()
|
||||
|
||||
resp, err := client.FindBrowserLogins(tokenContext(defaultTestTokenSecret), &keepassgov1.FindBrowserLoginsRequest{
|
||||
PageUrl: "https://heist.example.invalid/login",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("FindBrowserLogins() error = %v", err)
|
||||
}
|
||||
if len(resp.Matches) != 0 {
|
||||
t.Fatalf("len(FindBrowserLogins().Matches) = %d, want 0", len(resp.Matches))
|
||||
}
|
||||
|
||||
_, err = client.GetBrowserCredential(tokenContext(defaultTestTokenSecret), &keepassgov1.GetBrowserCredentialRequest{
|
||||
Id: "inside-man-accounts",
|
||||
PageUrl: "https://heist.example.invalid/login",
|
||||
})
|
||||
if status.Code(err) != codes.InvalidArgument {
|
||||
t.Fatalf("GetBrowserCredential() code = %v, want %v", status.Code(err), codes.InvalidArgument)
|
||||
}
|
||||
}
|
||||
|
||||
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()
|
||||
|
||||
client, _, cleanup := newTestClient(t)
|
||||
defer cleanup()
|
||||
|
||||
ctx := tokenContext(defaultTestTokenSecret)
|
||||
resp, err := client.GetBrowserCredential(ctx, &keepassgov1.GetBrowserCredentialRequest{
|
||||
Id: "vault-console",
|
||||
PageUrl: "https://vault.crew.example.invalid/login",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("GetBrowserCredential() error = %v", err)
|
||||
}
|
||||
if resp.Id != "vault-console" {
|
||||
t.Fatalf("GetBrowserCredential().Id = %q, want vault-console", resp.Id)
|
||||
}
|
||||
if resp.Password != "token-1" {
|
||||
t.Fatalf("GetBrowserCredential().Password = %q, want token-1", resp.Password)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVaultServiceRejectsUnauthorizedBrowserCredentialAccess(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
client, _, cleanup := newTestClientForModel(t, vault.Model{
|
||||
Entries: []vault.Entry{
|
||||
{
|
||||
ID: "vault-console",
|
||||
Title: "Vault Console",
|
||||
Username: "dannyocean",
|
||||
Password: "token-1",
|
||||
URL: "https://vault.crew.example.invalid",
|
||||
Path: []string{"Root", "Internet"},
|
||||
},
|
||||
testAPITokenEntry(t,
|
||||
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationListEntries, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root"}}},
|
||||
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationCopyUsername, Resource: apitokens.Resource{Kind: apitokens.ResourceEntry, EntryID: "vault-console", Path: []string{"Root", "Internet"}}},
|
||||
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationCopyURL, Resource: apitokens.Resource{Kind: apitokens.ResourceEntry, EntryID: "vault-console", Path: []string{"Root", "Internet"}}},
|
||||
apitokens.PolicyRule{Effect: apitokens.EffectDeny, Operation: apitokens.OperationCopyPassword, Resource: apitokens.Resource{Kind: apitokens.ResourceEntry, EntryID: "vault-console", Path: []string{"Root", "Internet"}}},
|
||||
),
|
||||
},
|
||||
})
|
||||
defer cleanup()
|
||||
|
||||
_, err := client.GetBrowserCredential(tokenContext(defaultTestTokenSecret), &keepassgov1.GetBrowserCredentialRequest{
|
||||
Id: "vault-console",
|
||||
PageUrl: "https://vault.crew.example.invalid/login",
|
||||
})
|
||||
if status.Code(err) != codes.PermissionDenied {
|
||||
t.Fatalf("GetBrowserCredential() code = %v, want %v", status.Code(err), codes.PermissionDenied)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVaultServicePromptsAndResumesWhenApproved(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -862,6 +1327,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()
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ var (
|
||||
ErrRequestCanceled = errors.New("authorization request canceled")
|
||||
ErrRequestTimedOut = errors.New("authorization request timed out")
|
||||
ErrRequestNotFound = errors.New("authorization request not found")
|
||||
ErrBrokerNotConfigured = errors.New("authorization broker is not configured")
|
||||
)
|
||||
|
||||
type Outcome string
|
||||
@@ -50,6 +51,7 @@ type Broker struct {
|
||||
timeout time.Duration
|
||||
now func() time.Time
|
||||
nextID func() string
|
||||
notify func()
|
||||
}
|
||||
|
||||
type pendingRequest struct {
|
||||
@@ -108,9 +110,18 @@ func (b *Broker) Pending() []Request {
|
||||
return requests
|
||||
}
|
||||
|
||||
func (b *Broker) SetChangeNotifier(notify func()) {
|
||||
if b == nil {
|
||||
return
|
||||
}
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
b.notify = notify
|
||||
}
|
||||
|
||||
func (b *Broker) Request(ctx context.Context, token apitokens.Token, op apitokens.Operation, resource apitokens.Resource) (Result, error) {
|
||||
if b == nil {
|
||||
return Result{}, ErrRequestTimedOut
|
||||
return Result{}, ErrBrokerNotConfigured
|
||||
}
|
||||
|
||||
pending := &pendingRequest{
|
||||
@@ -128,12 +139,20 @@ func (b *Broker) Request(ctx context.Context, token apitokens.Token, op apitoken
|
||||
|
||||
b.mu.Lock()
|
||||
b.pending[pending.request.ID] = pending
|
||||
notify := b.notify
|
||||
b.mu.Unlock()
|
||||
if notify != nil {
|
||||
notify()
|
||||
}
|
||||
|
||||
defer func() {
|
||||
b.mu.Lock()
|
||||
delete(b.pending, pending.request.ID)
|
||||
notify := b.notify
|
||||
b.mu.Unlock()
|
||||
if notify != nil {
|
||||
notify()
|
||||
}
|
||||
}()
|
||||
|
||||
timer := time.NewTimer(b.timeout)
|
||||
|
||||
@@ -3,6 +3,7 @@ package apiapproval
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"slices"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -120,6 +121,46 @@ func TestBrokerTimesOutPendingRequests(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestNilBrokerReturnsConfigurationError(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var broker *Broker
|
||||
_, err := broker.Request(context.Background(), apitokens.Token{ID: "token-1", Name: "CLI"}, apitokens.OperationListGroups, apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root"}})
|
||||
if !errors.Is(err, ErrBrokerNotConfigured) {
|
||||
t.Fatalf("Request(nil broker) error = %v, want %v", err, ErrBrokerNotConfigured)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBrokerNotifiesWhenPendingRequestsChange(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
broker := NewBroker(time.Minute)
|
||||
changes := make(chan int, 4)
|
||||
broker.SetChangeNotifier(func() {
|
||||
changes <- len(broker.Pending())
|
||||
})
|
||||
|
||||
errCh := make(chan error, 1)
|
||||
go func() {
|
||||
_, err := broker.Request(context.Background(), apitokens.Token{ID: "token-1", Name: "CLI"}, apitokens.OperationListGroups, apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root"}})
|
||||
errCh <- err
|
||||
}()
|
||||
|
||||
waitForPending(t, broker, 1)
|
||||
if _, _, err := broker.Resolve(broker.Pending()[0].ID, OutcomeAllowOnce); err != nil {
|
||||
t.Fatalf("Resolve(allow once) error = %v", err)
|
||||
}
|
||||
if err := <-errCh; err != nil {
|
||||
t.Fatalf("Request() error = %v, want nil", err)
|
||||
}
|
||||
|
||||
got := []int{<-changes, <-changes}
|
||||
slices.Sort(got)
|
||||
if !slices.Equal(got, []int{0, 1}) {
|
||||
t.Fatalf("change notifications = %v, want [0 1]", got)
|
||||
}
|
||||
}
|
||||
|
||||
func waitForPending(t *testing.T, broker *Broker, want int) {
|
||||
t.Helper()
|
||||
|
||||
|
||||
+119
-117
@@ -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
|
||||
@@ -182,127 +192,111 @@ func (s *State) RemoteCredentialEntries() ([]vault.Entry, error) {
|
||||
}
|
||||
|
||||
func (s *State) IssueAPIToken(name, clientName string, expiresAt *time.Time, now time.Time) (apitokens.Token, string, error) {
|
||||
session, ok := s.Session.(MutableSession)
|
||||
if !ok {
|
||||
return apitokens.Token{}, "", fmt.Errorf("session is not mutable")
|
||||
}
|
||||
model, err := session.Current()
|
||||
if err != nil {
|
||||
return apitokens.Token{}, "", err
|
||||
}
|
||||
result, err := s.mutateAPITokens(apiaudit.EventTokenIssued, "issued API token", func(model *vault.Model) (tokenMutationResult, error) {
|
||||
token, secret, err := apitokens.Issue(name, clientName, expiresAt, now)
|
||||
if err != nil {
|
||||
return tokenMutationResult{}, err
|
||||
}
|
||||
apitokens.Upsert(model, token)
|
||||
return tokenMutationResult{token: token, secret: secret}, nil
|
||||
})
|
||||
if err != nil {
|
||||
return apitokens.Token{}, "", err
|
||||
}
|
||||
apitokens.Upsert(&model, token)
|
||||
session.Replace(model)
|
||||
s.Dirty = true
|
||||
s.recordTokenAudit(apiaudit.EventTokenIssued, token, "issued API token")
|
||||
return token, secret, nil
|
||||
return result.token, result.secret, nil
|
||||
}
|
||||
|
||||
func (s *State) RotateAPIToken(id string, now time.Time) (apitokens.Token, string, error) {
|
||||
session, ok := s.Session.(MutableSession)
|
||||
if !ok {
|
||||
return apitokens.Token{}, "", fmt.Errorf("session is not mutable")
|
||||
}
|
||||
model, err := session.Current()
|
||||
result, err := s.mutateAPITokens(apiaudit.EventTokenRotated, "rotated API token", func(model *vault.Model) (tokenMutationResult, error) {
|
||||
token, err := apitokens.Find(*model, id)
|
||||
if err != nil {
|
||||
return apitokens.Token{}, "", err
|
||||
}
|
||||
token, err := apitokens.Find(model, id)
|
||||
if err != nil {
|
||||
return apitokens.Token{}, "", err
|
||||
return tokenMutationResult{}, err
|
||||
}
|
||||
token, secret, err := apitokens.Rotate(token, now)
|
||||
if err != nil {
|
||||
return tokenMutationResult{}, err
|
||||
}
|
||||
apitokens.Upsert(model, token)
|
||||
return tokenMutationResult{token: token, secret: secret}, nil
|
||||
})
|
||||
if err != nil {
|
||||
return apitokens.Token{}, "", err
|
||||
}
|
||||
apitokens.Upsert(&model, token)
|
||||
session.Replace(model)
|
||||
s.Dirty = true
|
||||
s.recordTokenAudit(apiaudit.EventTokenRotated, token, "rotated API token")
|
||||
return token, secret, nil
|
||||
return result.token, result.secret, nil
|
||||
}
|
||||
|
||||
func (s *State) UpsertAPIToken(token apitokens.Token) error {
|
||||
session, ok := s.Session.(MutableSession)
|
||||
if !ok {
|
||||
return fmt.Errorf("session is not mutable")
|
||||
}
|
||||
model, err := session.Current()
|
||||
if err != nil {
|
||||
_, err := s.mutateAPITokens(apiaudit.EventTokenUpdated, "updated API token", func(model *vault.Model) (tokenMutationResult, error) {
|
||||
apitokens.Upsert(model, token)
|
||||
return tokenMutationResult{token: token}, nil
|
||||
})
|
||||
return err
|
||||
}
|
||||
apitokens.Upsert(&model, token)
|
||||
session.Replace(model)
|
||||
s.Dirty = true
|
||||
s.recordTokenAudit(apiaudit.EventTokenUpdated, token, "updated API token")
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *State) DisableAPIToken(id string) error {
|
||||
session, ok := s.Session.(MutableSession)
|
||||
if !ok {
|
||||
return fmt.Errorf("session is not mutable")
|
||||
}
|
||||
model, err := session.Current()
|
||||
_, err := s.mutateAPITokens(apiaudit.EventTokenDisabled, "disabled API token", func(model *vault.Model) (tokenMutationResult, error) {
|
||||
token, err := apitokens.Find(*model, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
token, err := apitokens.Find(model, id)
|
||||
if err != nil {
|
||||
return err
|
||||
return tokenMutationResult{}, err
|
||||
}
|
||||
token = apitokens.Disable(token)
|
||||
apitokens.Upsert(&model, token)
|
||||
session.Replace(model)
|
||||
s.Dirty = true
|
||||
s.recordTokenAudit(apiaudit.EventTokenDisabled, token, "disabled API token")
|
||||
return nil
|
||||
apitokens.Upsert(model, token)
|
||||
return tokenMutationResult{token: token}, nil
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *State) RevokeAPIToken(id string, when time.Time) error {
|
||||
session, ok := s.Session.(MutableSession)
|
||||
if !ok {
|
||||
return fmt.Errorf("session is not mutable")
|
||||
}
|
||||
model, err := session.Current()
|
||||
_, err := s.mutateAPITokens(apiaudit.EventTokenRevoked, "revoked API token", func(model *vault.Model) (tokenMutationResult, error) {
|
||||
token, err := apitokens.Find(*model, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
token, err := apitokens.Find(model, id)
|
||||
if err != nil {
|
||||
return err
|
||||
return tokenMutationResult{}, err
|
||||
}
|
||||
token = apitokens.Revoke(token, when)
|
||||
apitokens.Upsert(&model, token)
|
||||
session.Replace(model)
|
||||
s.Dirty = true
|
||||
s.recordTokenAudit(apiaudit.EventTokenRevoked, token, "revoked API token")
|
||||
return nil
|
||||
apitokens.Upsert(model, token)
|
||||
return tokenMutationResult{token: token}, nil
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *State) DeleteAPIToken(id string) error {
|
||||
_, err := s.mutateAPITokens(apiaudit.EventTokenDeleted, "deleted API token", func(model *vault.Model) (tokenMutationResult, error) {
|
||||
token, err := apitokens.Find(*model, id)
|
||||
if err != nil {
|
||||
return tokenMutationResult{}, err
|
||||
}
|
||||
if err := apitokens.Delete(model, id); err != nil {
|
||||
return tokenMutationResult{}, err
|
||||
}
|
||||
return tokenMutationResult{token: token}, nil
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
type tokenMutationResult struct {
|
||||
token apitokens.Token
|
||||
secret string
|
||||
}
|
||||
|
||||
func (s *State) mutateAPITokens(eventType apiaudit.EventType, message string, mutate func(*vault.Model) (tokenMutationResult, error)) (tokenMutationResult, error) {
|
||||
session, ok := s.Session.(MutableSession)
|
||||
if !ok {
|
||||
return fmt.Errorf("session is not mutable")
|
||||
return tokenMutationResult{}, fmt.Errorf("session is not mutable")
|
||||
}
|
||||
model, err := session.Current()
|
||||
if err != nil {
|
||||
return err
|
||||
return tokenMutationResult{}, err
|
||||
}
|
||||
token, err := apitokens.Find(model, id)
|
||||
result, err := mutate(&model)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := apitokens.Delete(&model, id); err != nil {
|
||||
return err
|
||||
return tokenMutationResult{}, err
|
||||
}
|
||||
session.Replace(model)
|
||||
s.Dirty = true
|
||||
s.recordTokenAudit(apiaudit.EventTokenDeleted, token, "deleted API token")
|
||||
return nil
|
||||
if err := s.markDirtyAndAutoSave(); err != nil {
|
||||
return tokenMutationResult{}, err
|
||||
}
|
||||
s.recordTokenAudit(eventType, result.token, message)
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (s *State) recordTokenAudit(eventType apiaudit.EventType, token apitokens.Token, message string) {
|
||||
@@ -339,8 +333,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 +561,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 +580,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 +597,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 +614,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 +635,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 +660,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 +681,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 +703,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 +739,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 +766,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 +957,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 +979,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 +995,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 +1015,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 +1037,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 +1056,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 +1079,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 +1105,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 +1131,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 +1160,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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -16,6 +16,8 @@ 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"
|
||||
"git.julianfamily.org/keepassgo/internal/vault"
|
||||
@@ -56,13 +58,11 @@ func Main() {
|
||||
}
|
||||
|
||||
func defaultGRPCAddr(goos string) string {
|
||||
if strings.EqualFold(strings.TrimSpace(goos), "android") {
|
||||
return "off"
|
||||
}
|
||||
return "127.0.0.1:47777"
|
||||
return grpcaddr.Default(goos)
|
||||
}
|
||||
|
||||
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)
|
||||
@@ -78,6 +78,11 @@ 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() }()
|
||||
}
|
||||
for {
|
||||
@@ -96,6 +101,19 @@ 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
|
||||
}
|
||||
if err := browserbridge.EnsureNativeHostManifests(appBinaryPath); err != nil {
|
||||
platform.LogInfo("KeePassGO", fmt.Sprintf("keepassgo browser native host registration failed: %v", err))
|
||||
}
|
||||
}
|
||||
|
||||
type uiApprovalManager struct {
|
||||
server *api.Server
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -0,0 +1,346 @@
|
||||
package browserbridge
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/binary"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
|
||||
"git.julianfamily.org/keepassgo/internal/grpcaddr"
|
||||
keepassgov1 "git.julianfamily.org/keepassgo/proto/keepassgo/v1"
|
||||
gcodes "google.golang.org/grpc/codes"
|
||||
gstatus "google.golang.org/grpc/status"
|
||||
)
|
||||
|
||||
const (
|
||||
NativeHostName = "com.keepassgo.browser"
|
||||
defaultFirefoxID = "browser@keepassgo.com"
|
||||
maxNativeMessageSize = 1024 * 1024
|
||||
chromiumIDBytes = 16
|
||||
responseVersion = "1"
|
||||
)
|
||||
|
||||
type Request struct {
|
||||
Action string `json:"action"`
|
||||
BearerToken string `json:"bearerToken,omitempty"`
|
||||
URL string `json:"url,omitempty"`
|
||||
EntryID string `json:"entryId,omitempty"`
|
||||
}
|
||||
|
||||
type Response struct {
|
||||
Success bool `json:"success"`
|
||||
Error string `json:"error,omitempty"`
|
||||
Status *Status `json:"status,omitempty"`
|
||||
Matches []Match `json:"matches,omitempty"`
|
||||
Credential *Credential `json:"credential,omitempty"`
|
||||
Version string `json:"version,omitempty"`
|
||||
}
|
||||
|
||||
type Status struct {
|
||||
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 {
|
||||
ID string `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Username string `json:"username,omitempty"`
|
||||
URL string `json:"url,omitempty"`
|
||||
Path []string `json:"path,omitempty"`
|
||||
Quality string `json:"quality,omitempty"`
|
||||
}
|
||||
|
||||
type Credential struct {
|
||||
ID string `json:"id"`
|
||||
Username string `json:"username,omitempty"`
|
||||
Password string `json:"password,omitempty"`
|
||||
URL string `json:"url,omitempty"`
|
||||
}
|
||||
|
||||
type Connection struct {
|
||||
GRPCAddress string
|
||||
BearerToken string
|
||||
}
|
||||
|
||||
type Client interface {
|
||||
Status(context.Context) (*keepassgov1.GetSessionStatusResponse, error)
|
||||
FindBrowserLogins(context.Context, string) ([]*keepassgov1.BrowserLoginMatch, error)
|
||||
GetBrowserCredential(context.Context, string, string) (*keepassgov1.GetBrowserCredentialResponse, error)
|
||||
}
|
||||
|
||||
type Browser string
|
||||
|
||||
const (
|
||||
BrowserFirefox Browser = "firefox"
|
||||
BrowserChrome Browser = "chrome"
|
||||
BrowserChromium Browser = "chromium"
|
||||
)
|
||||
|
||||
type NativeHostManifest struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Path string `json:"path"`
|
||||
Type string `json:"type"`
|
||||
AllowedExtensions []string `json:"allowed_extensions,omitempty"`
|
||||
AllowedOrigins []string `json:"allowed_origins,omitempty"`
|
||||
}
|
||||
|
||||
func DefaultFirefoxExtensionID() string {
|
||||
return defaultFirefoxID
|
||||
}
|
||||
|
||||
func ReadRequest(r io.Reader) (Request, error) {
|
||||
var sizeBuf [4]byte
|
||||
if _, err := io.ReadFull(r, sizeBuf[:]); err != nil {
|
||||
return Request{}, err
|
||||
}
|
||||
size := binary.LittleEndian.Uint32(sizeBuf[:])
|
||||
if size == 0 || size > maxNativeMessageSize {
|
||||
return Request{}, fmt.Errorf("invalid native message size %d", size)
|
||||
}
|
||||
body := make([]byte, size)
|
||||
if _, err := io.ReadFull(r, body); err != nil {
|
||||
return Request{}, err
|
||||
}
|
||||
var req Request
|
||||
if err := json.Unmarshal(body, &req); err != nil {
|
||||
return Request{}, fmt.Errorf("decode native request: %w", err)
|
||||
}
|
||||
return req, nil
|
||||
}
|
||||
|
||||
func WriteResponse(w io.Writer, resp Response) error {
|
||||
data, err := json.Marshal(resp)
|
||||
if err != nil {
|
||||
return fmt.Errorf("encode native response: %w", err)
|
||||
}
|
||||
if len(data) > maxNativeMessageSize {
|
||||
return fmt.Errorf("native response too large: %d", len(data))
|
||||
}
|
||||
var sizeBuf [4]byte
|
||||
binary.LittleEndian.PutUint32(sizeBuf[:], uint32(len(data)))
|
||||
if _, err := w.Write(sizeBuf[:]); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = w.Write(data)
|
||||
return err
|
||||
}
|
||||
|
||||
func (r Request) Connection(grpcAddr string) (Connection, error) {
|
||||
return normalizeConnection(Connection{
|
||||
GRPCAddress: strings.TrimSpace(grpcAddr),
|
||||
BearerToken: strings.TrimSpace(r.BearerToken),
|
||||
})
|
||||
}
|
||||
|
||||
func normalizeConnection(conn Connection) (Connection, error) {
|
||||
if strings.TrimSpace(conn.GRPCAddress) == "" {
|
||||
conn.GRPCAddress = grpcaddr.Default(runtime.GOOS)
|
||||
}
|
||||
if strings.TrimSpace(conn.BearerToken) == "" {
|
||||
return Connection{}, fmt.Errorf("browser bridge bearer token is required")
|
||||
}
|
||||
conn.GRPCAddress = strings.TrimSpace(conn.GRPCAddress)
|
||||
conn.BearerToken = strings.TrimSpace(conn.BearerToken)
|
||||
return conn, nil
|
||||
}
|
||||
|
||||
func HandleRequest(ctx context.Context, req Request, grpcAddr string, client Client) Response {
|
||||
conn, err := req.Connection(grpcAddr)
|
||||
if err != nil {
|
||||
return Response{Success: false, Error: err.Error()}
|
||||
}
|
||||
action := strings.TrimSpace(req.Action)
|
||||
switch action {
|
||||
case "status":
|
||||
status, err := statusResponse(ctx, client, conn.GRPCAddress)
|
||||
if err != nil {
|
||||
return Response{Success: false, Error: err.Error(), Status: disconnectedStatus(conn.GRPCAddress)}
|
||||
}
|
||||
return Response{Success: true, Status: status, Version: responseVersion}
|
||||
case "find-logins":
|
||||
matches, err := findMatches(ctx, client, req.URL)
|
||||
if err != nil {
|
||||
if status := inferredActionStatus(conn.GRPCAddress, err); status != nil {
|
||||
return Response{Success: true, Status: status, Matches: nil, Version: responseVersion}
|
||||
}
|
||||
return Response{Success: false, Error: err.Error(), Status: disconnectedStatus(conn.GRPCAddress)}
|
||||
}
|
||||
return Response{Success: true, Status: availableStatus(conn.GRPCAddress), Matches: matches, Version: responseVersion}
|
||||
case "get-login":
|
||||
credential, err := loadCredential(ctx, client, req.EntryID, req.URL)
|
||||
if err != nil {
|
||||
if status := inferredActionStatus(conn.GRPCAddress, err); status != nil {
|
||||
return Response{Success: false, Error: err.Error(), Status: status}
|
||||
}
|
||||
return Response{Success: false, Error: err.Error(), Status: disconnectedStatus(conn.GRPCAddress)}
|
||||
}
|
||||
return Response{Success: true, Status: availableStatus(conn.GRPCAddress), Credential: credential, Version: responseVersion}
|
||||
default:
|
||||
return Response{Success: false, Error: fmt.Sprintf("unsupported action %q", action)}
|
||||
}
|
||||
}
|
||||
|
||||
func disconnectedStatus(addr string) *Status {
|
||||
return &Status{Connected: false, GRPCAddress: strings.TrimSpace(addr)}
|
||||
}
|
||||
|
||||
func availableStatus(addr string) *Status {
|
||||
return &Status{Connected: true, Locked: false, GRPCAddress: strings.TrimSpace(addr)}
|
||||
}
|
||||
|
||||
func inferredActionStatus(addr string, err error) *Status {
|
||||
switch gstatus.Code(err) {
|
||||
case gcodes.FailedPrecondition:
|
||||
return &Status{Connected: true, Locked: true, GRPCAddress: strings.TrimSpace(addr)}
|
||||
case gcodes.OK:
|
||||
return availableStatus(addr)
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func statusResponse(ctx context.Context, client Client, addr string) (*Status, error) {
|
||||
resp, err := client.Status(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &Status{
|
||||
Connected: true,
|
||||
Locked: resp.GetLocked(),
|
||||
Dirty: resp.GetDirty(),
|
||||
EntryCount: resp.GetEntryCount(),
|
||||
PendingApprovalCount: resp.GetPendingApprovalCount(),
|
||||
TokenPendingApprovalCount: resp.GetTokenPendingApprovalCount(),
|
||||
GRPCAddress: strings.TrimSpace(addr),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func findMatches(ctx context.Context, client Client, rawURL string) ([]Match, error) {
|
||||
resp, err := client.FindBrowserLogins(ctx, strings.TrimSpace(rawURL))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]Match, 0, len(resp))
|
||||
for _, match := range resp {
|
||||
out = append(out, Match{
|
||||
ID: match.GetId(),
|
||||
Title: match.GetTitle(),
|
||||
Username: match.GetUsername(),
|
||||
URL: match.GetUrl(),
|
||||
Path: append([]string(nil), match.GetPath()...),
|
||||
Quality: match.GetQuality(),
|
||||
})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func loadCredential(ctx context.Context, client Client, entryID, rawURL string) (*Credential, error) {
|
||||
id := strings.TrimSpace(entryID)
|
||||
if id == "" {
|
||||
return nil, fmt.Errorf("entry id is required")
|
||||
}
|
||||
resp, err := client.GetBrowserCredential(ctx, id, strings.TrimSpace(rawURL))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &Credential{
|
||||
ID: resp.GetId(),
|
||||
Username: resp.GetUsername(),
|
||||
Password: resp.GetPassword(),
|
||||
URL: resp.GetUrl(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func Manifest(browser Browser, binaryPath, extensionID string) (NativeHostManifest, error) {
|
||||
path := strings.TrimSpace(binaryPath)
|
||||
if path == "" {
|
||||
return NativeHostManifest{}, fmt.Errorf("native host binary path is required")
|
||||
}
|
||||
switch browser {
|
||||
case BrowserFirefox:
|
||||
id := strings.TrimSpace(extensionID)
|
||||
if id == "" {
|
||||
id = defaultFirefoxID
|
||||
}
|
||||
return NativeHostManifest{
|
||||
Name: NativeHostName,
|
||||
Description: "KeePassGO browser bridge",
|
||||
Path: path,
|
||||
Type: "stdio",
|
||||
AllowedExtensions: []string{id},
|
||||
}, nil
|
||||
case BrowserChrome, BrowserChromium:
|
||||
id := strings.TrimSpace(extensionID)
|
||||
if id == "" {
|
||||
return NativeHostManifest{}, fmt.Errorf("%s extension id is required", browser)
|
||||
}
|
||||
return NativeHostManifest{
|
||||
Name: NativeHostName,
|
||||
Description: "KeePassGO browser bridge",
|
||||
Path: path,
|
||||
Type: "stdio",
|
||||
AllowedOrigins: []string{"chrome-extension://" + id + "/"},
|
||||
}, nil
|
||||
default:
|
||||
return NativeHostManifest{}, fmt.Errorf("unsupported browser %q", browser)
|
||||
}
|
||||
}
|
||||
|
||||
func ChromiumExtensionIDFromManifestKey(raw string) (string, error) {
|
||||
normalized := strings.TrimSpace(raw)
|
||||
normalized = strings.ReplaceAll(normalized, "-----BEGIN PUBLIC KEY-----", "")
|
||||
normalized = strings.ReplaceAll(normalized, "-----END PUBLIC KEY-----", "")
|
||||
normalized = strings.ReplaceAll(normalized, "\n", "")
|
||||
normalized = strings.ReplaceAll(normalized, "\r", "")
|
||||
normalized = strings.ReplaceAll(normalized, "\t", "")
|
||||
normalized = strings.ReplaceAll(normalized, " ", "")
|
||||
if normalized == "" {
|
||||
return "", fmt.Errorf("chromium extension key is required")
|
||||
}
|
||||
publicKeyDER, err := base64.StdEncoding.DecodeString(normalized)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("decode chromium extension key: %w", err)
|
||||
}
|
||||
hash := sha256.Sum256(publicKeyDER)
|
||||
var builder strings.Builder
|
||||
builder.Grow(chromiumIDBytes * 2)
|
||||
for _, b := range hash[:chromiumIDBytes] {
|
||||
builder.WriteByte('a' + ((b >> 4) & 0x0f))
|
||||
builder.WriteByte('a' + (b & 0x0f))
|
||||
}
|
||||
return builder.String(), nil
|
||||
}
|
||||
|
||||
func DefaultManifestPath(browser Browser) (string, error) {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
switch browser {
|
||||
case BrowserFirefox:
|
||||
return filepath.Join(home, ".mozilla", "native-messaging-hosts", NativeHostName+".json"), nil
|
||||
case BrowserChrome:
|
||||
return filepath.Join(home, ".config", "google-chrome", "NativeMessagingHosts", NativeHostName+".json"), nil
|
||||
case BrowserChromium:
|
||||
return filepath.Join(home, ".config", "chromium", "NativeMessagingHosts", NativeHostName+".json"), nil
|
||||
default:
|
||||
return "", fmt.Errorf("unsupported browser %q", browser)
|
||||
}
|
||||
}
|
||||
|
||||
func InstallManifest(browser Browser, binaryPath, extensionID, outputPath string) (string, error) {
|
||||
return InstallManifestSet(browser, binaryPath, []string{strings.TrimSpace(extensionID)}, outputPath)
|
||||
}
|
||||
@@ -0,0 +1,396 @@
|
||||
package browserbridge
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/binary"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
keepassgov1 "git.julianfamily.org/keepassgo/proto/keepassgo/v1"
|
||||
gcodes "google.golang.org/grpc/codes"
|
||||
gstatus "google.golang.org/grpc/status"
|
||||
)
|
||||
|
||||
func TestReadRequestAndWriteResponse(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var input bytes.Buffer
|
||||
body, err := json.Marshal(Request{
|
||||
Action: "find-logins",
|
||||
BearerToken: "secret",
|
||||
URL: "https://example.invalid/login",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Marshal() error = %v", err)
|
||||
}
|
||||
if err := binary.Write(&input, binary.LittleEndian, uint32(len(body))); err != nil {
|
||||
t.Fatalf("binary.Write() error = %v", err)
|
||||
}
|
||||
if _, err := input.Write(body); err != nil {
|
||||
t.Fatalf("Write() error = %v", err)
|
||||
}
|
||||
|
||||
req, err := ReadRequest(&input)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadRequest() error = %v", err)
|
||||
}
|
||||
if req.Action != "find-logins" || req.BearerToken != "secret" {
|
||||
t.Fatalf("ReadRequest() = %#v, want action and token preserved", req)
|
||||
}
|
||||
if conn, err := req.Connection("127.0.0.1:47777"); err != nil || conn.GRPCAddress != "127.0.0.1:47777" {
|
||||
t.Fatalf("req.Connection(127.0.0.1:47777) = (%#v, %v), want explicit tcp address preserved", conn, err)
|
||||
}
|
||||
|
||||
var output bytes.Buffer
|
||||
if err := WriteResponse(&output, Response{Success: true, Version: "1"}); err != nil {
|
||||
t.Fatalf("WriteResponse() error = %v", err)
|
||||
}
|
||||
var size uint32
|
||||
if err := binary.Read(&output, binary.LittleEndian, &size); err != nil {
|
||||
t.Fatalf("binary.Read() error = %v", err)
|
||||
}
|
||||
payload := make([]byte, size)
|
||||
if _, err := output.Read(payload); err != nil {
|
||||
t.Fatalf("Read() payload error = %v", err)
|
||||
}
|
||||
var resp Response
|
||||
if err := json.Unmarshal(payload, &resp); err != nil {
|
||||
t.Fatalf("Unmarshal() error = %v", err)
|
||||
}
|
||||
if !resp.Success || resp.Version != "1" {
|
||||
t.Fatalf("response = %#v, want success version 1", resp)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleRequestFindLogins(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
client := &fakeClient{
|
||||
matches: []*keepassgov1.BrowserLoginMatch{
|
||||
{Id: "vault-console", Title: "Vault Console", Username: "dannyocean", Url: "https://vault.example.invalid", Quality: "exact-host"},
|
||||
},
|
||||
}
|
||||
resp := HandleRequest(context.Background(), Request{
|
||||
Action: "find-logins",
|
||||
BearerToken: "secret",
|
||||
URL: "https://vault.example.invalid/login",
|
||||
}, "", client)
|
||||
if !resp.Success {
|
||||
t.Fatalf("HandleRequest() success = false, error = %q", resp.Error)
|
||||
}
|
||||
if len(resp.Matches) != 1 || resp.Matches[0].ID != "vault-console" {
|
||||
t.Fatalf("HandleRequest().Matches = %#v, want vault-console", resp.Matches)
|
||||
}
|
||||
if client.statusCalls != 0 {
|
||||
t.Fatalf("HandleRequest(find-logins) statusCalls = %d, want 0", client.statusCalls)
|
||||
}
|
||||
}
|
||||
|
||||
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()
|
||||
|
||||
client := &fakeClient{
|
||||
credential: &keepassgov1.GetBrowserCredentialResponse{
|
||||
Id: "vault-console",
|
||||
Username: "dannyocean",
|
||||
Password: "token-1",
|
||||
Url: "https://vault.example.invalid",
|
||||
},
|
||||
}
|
||||
resp := HandleRequest(context.Background(), Request{
|
||||
Action: "get-login",
|
||||
BearerToken: "secret",
|
||||
EntryID: "vault-console",
|
||||
URL: "https://vault.example.invalid/login",
|
||||
}, "", client)
|
||||
if !resp.Success {
|
||||
t.Fatalf("HandleRequest() success = false, error = %q", resp.Error)
|
||||
}
|
||||
if resp.Credential == nil || resp.Credential.ID != "vault-console" {
|
||||
t.Fatalf("HandleRequest().Credential = %#v, want vault-console", resp.Credential)
|
||||
}
|
||||
if client.statusCalls != 0 {
|
||||
t.Fatalf("HandleRequest(get-login) statusCalls = %d, want 0", client.statusCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleRequestFindLoginsInfersLockedStatusFromRPC(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
client := &fakeClient{matchesErr: gstatus.Error(gcodes.FailedPrecondition, "vault is locked")}
|
||||
resp := HandleRequest(context.Background(), Request{
|
||||
Action: "find-logins",
|
||||
BearerToken: "secret",
|
||||
URL: "https://vault.example.invalid/login",
|
||||
}, "", client)
|
||||
if !resp.Success {
|
||||
t.Fatalf("HandleRequest(find-logins locked) success = false, error = %q", resp.Error)
|
||||
}
|
||||
if resp.Status == nil || !resp.Status.Locked {
|
||||
t.Fatalf("HandleRequest(find-logins locked).Status = %#v, want locked status", resp.Status)
|
||||
}
|
||||
if client.statusCalls != 0 {
|
||||
t.Fatalf("HandleRequest(find-logins locked) statusCalls = %d, want 0", client.statusCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleRequestRequiresBearerToken(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
resp := HandleRequest(context.Background(), Request{Action: "status"}, "", &fakeClient{})
|
||||
if resp.Success {
|
||||
t.Fatal("HandleRequest().Success = true, want false without token")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequestConnectionDefaultsAddress(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
req := Request{Action: "status", BearerToken: "secret"}
|
||||
conn, err := req.Connection("")
|
||||
if err != nil {
|
||||
t.Fatalf("Connection(\"\") error = %v", err)
|
||||
}
|
||||
if conn.GRPCAddress == "" {
|
||||
t.Fatal("Connection().GRPCAddress = empty, want default address")
|
||||
}
|
||||
if runtime.GOOS != "windows" && !strings.HasPrefix(conn.GRPCAddress, "unix://") && conn.GRPCAddress != "off" {
|
||||
t.Fatalf("Connection().GRPCAddress = %q, want unix socket default on this platform", conn.GRPCAddress)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstallManifest(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tmp := t.TempDir()
|
||||
binaryPath := filepath.Join(tmp, "keepassgo-browser-bridge")
|
||||
if err := os.WriteFile(binaryPath, []byte("#!/bin/sh\n"), 0o755); err != nil {
|
||||
t.Fatalf("WriteFile(binary) error = %v", err)
|
||||
}
|
||||
|
||||
path, err := InstallManifest(BrowserFirefox, binaryPath, "", filepath.Join(tmp, "firefox-host.json"))
|
||||
if err != nil {
|
||||
t.Fatalf("InstallManifest() error = %v", err)
|
||||
}
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile() error = %v", err)
|
||||
}
|
||||
var manifest NativeHostManifest
|
||||
if err := json.Unmarshal(data, &manifest); err != nil {
|
||||
t.Fatalf("Unmarshal() error = %v", err)
|
||||
}
|
||||
if manifest.Path != binaryPath {
|
||||
t.Fatalf("manifest.Path = %q, want %q", manifest.Path, binaryPath)
|
||||
}
|
||||
if len(manifest.AllowedExtensions) != 1 || manifest.AllowedExtensions[0] != DefaultFirefoxExtensionID() {
|
||||
t.Fatalf("manifest.AllowedExtensions = %#v, want default firefox extension id", manifest.AllowedExtensions)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChromiumExtensionIDFromManifestKey(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const publicKey = "MFwwDQYJKoZIhvcNAQEBBQADSwAwSAJBAMfW0u1k4K5A0uN2s0aH7uQKpM3x5Hf8mZfY1xVh0m7E2mJ7M8GiV4m0g0I2w9U9D1yqGQ6w8jzH5v8t7qB2RjMCAwEAAQ=="
|
||||
got, err := ChromiumExtensionIDFromManifestKey(publicKey)
|
||||
if err != nil {
|
||||
t.Fatalf("ChromiumExtensionIDFromManifestKey() error = %v", err)
|
||||
}
|
||||
if got != "okcdfigpojphpoecpglkkmkjmiaefmpd" {
|
||||
t.Fatalf("ChromiumExtensionIDFromManifestKey() = %q, want %q", got, "okcdfigpojphpoecpglkkmkjmiaefmpd")
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
credential *keepassgov1.GetBrowserCredentialResponse
|
||||
err error
|
||||
matchesErr error
|
||||
credentialErr error
|
||||
statusCalls int
|
||||
}
|
||||
|
||||
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) {
|
||||
f.statusCalls++
|
||||
if f.err != nil {
|
||||
return nil, f.err
|
||||
}
|
||||
if f.status == nil {
|
||||
return &keepassgov1.GetSessionStatusResponse{}, nil
|
||||
}
|
||||
return f.status, nil
|
||||
}
|
||||
|
||||
func (f *fakeClient) FindBrowserLogins(context.Context, string) ([]*keepassgov1.BrowserLoginMatch, error) {
|
||||
if f.matchesErr != nil {
|
||||
return nil, f.matchesErr
|
||||
}
|
||||
if f.err != nil {
|
||||
return nil, f.err
|
||||
}
|
||||
return f.matches, nil
|
||||
}
|
||||
|
||||
func (f *fakeClient) GetBrowserCredential(context.Context, string, string) (*keepassgov1.GetBrowserCredentialResponse, error) {
|
||||
if f.credentialErr != nil {
|
||||
return nil, f.credentialErr
|
||||
}
|
||||
if f.err != nil {
|
||||
return nil, f.err
|
||||
}
|
||||
if f.credential == nil {
|
||||
return &keepassgov1.GetBrowserCredentialResponse{}, nil
|
||||
}
|
||||
return f.credential, nil
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
package browserbridge
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net"
|
||||
"strings"
|
||||
|
||||
"git.julianfamily.org/keepassgo/internal/grpcaddr"
|
||||
keepassgov1 "git.julianfamily.org/keepassgo/proto/keepassgo/v1"
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/credentials/insecure"
|
||||
"google.golang.org/grpc/metadata"
|
||||
)
|
||||
|
||||
type GRPCClient struct {
|
||||
client keepassgov1.VaultServiceClient
|
||||
}
|
||||
|
||||
func DialRequest(ctx context.Context, req Request, grpcAddr string) (*grpc.ClientConn, *GRPCClient, context.Context, error) {
|
||||
conn, err := req.Connection(grpcAddr)
|
||||
if err != nil {
|
||||
return nil, nil, nil, err
|
||||
}
|
||||
return Dial(ctx, conn)
|
||||
}
|
||||
|
||||
func Dial(ctx context.Context, conn Connection) (*grpc.ClientConn, *GRPCClient, context.Context, error) {
|
||||
normalized, err := normalizeConnection(conn)
|
||||
if err != nil {
|
||||
return nil, nil, nil, err
|
||||
}
|
||||
network, endpoint, err := grpcaddr.Parse(normalized.GRPCAddress)
|
||||
if err != nil {
|
||||
return nil, nil, nil, err
|
||||
}
|
||||
target := endpoint
|
||||
if network == "unix" {
|
||||
target = "passthrough:///" + endpoint
|
||||
}
|
||||
grpcConn, err := grpc.NewClient(target,
|
||||
grpc.WithTransportCredentials(insecure.NewCredentials()),
|
||||
grpc.WithContextDialer(func(context.Context, string) (net.Conn, error) {
|
||||
return net.Dial(network, endpoint)
|
||||
}),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, nil, nil, fmt.Errorf("dial gRPC host %s: %w", normalized.GRPCAddress, err)
|
||||
}
|
||||
ctx = metadata.AppendToOutgoingContext(ctx, "authorization", "Bearer "+normalized.BearerToken)
|
||||
return grpcConn, &GRPCClient{client: keepassgov1.NewVaultServiceClient(grpcConn)}, ctx, nil
|
||||
}
|
||||
|
||||
func (c *GRPCClient) Status(ctx context.Context) (*keepassgov1.GetSessionStatusResponse, error) {
|
||||
return c.client.GetSessionStatus(ctx, &keepassgov1.GetSessionStatusRequest{})
|
||||
}
|
||||
|
||||
func (c *GRPCClient) FindBrowserLogins(ctx context.Context, pageURL string) ([]*keepassgov1.BrowserLoginMatch, error) {
|
||||
resp, err := c.client.FindBrowserLogins(ctx, &keepassgov1.FindBrowserLoginsRequest{
|
||||
PageUrl: strings.TrimSpace(pageURL),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return resp.GetMatches(), nil
|
||||
}
|
||||
|
||||
func (c *GRPCClient) GetBrowserCredential(ctx context.Context, entryID, pageURL string) (*keepassgov1.GetBrowserCredentialResponse, error) {
|
||||
return c.client.GetBrowserCredential(ctx, &keepassgov1.GetBrowserCredentialRequest{
|
||||
Id: strings.TrimSpace(entryID),
|
||||
PageUrl: strings.TrimSpace(pageURL),
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
package grpcaddr
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const socketName = "keepassgo-grpc.sock"
|
||||
|
||||
func Default(goos string) string {
|
||||
if strings.EqualFold(strings.TrimSpace(goos), "android") {
|
||||
return "off"
|
||||
}
|
||||
if strings.EqualFold(strings.TrimSpace(goos), "windows") {
|
||||
return "127.0.0.1:47777"
|
||||
}
|
||||
return "unix://" + DefaultSocketPath()
|
||||
}
|
||||
|
||||
func DefaultSocketPath() string {
|
||||
return filepath.Join(runtimeDir(), "keepassgo", socketName)
|
||||
}
|
||||
|
||||
func runtimeDir() string {
|
||||
if dir := strings.TrimSpace(os.Getenv("XDG_RUNTIME_DIR")); dir != "" {
|
||||
return dir
|
||||
}
|
||||
if runtime.GOOS != "windows" {
|
||||
uid := strconv.Itoa(os.Getuid())
|
||||
runUserDir := filepath.Join("/run/user", uid)
|
||||
if info, err := os.Stat(runUserDir); err == nil && info.IsDir() {
|
||||
return runUserDir
|
||||
}
|
||||
}
|
||||
return filepath.Join(os.TempDir(), fmt.Sprintf("keepassgo-runtime-%d", os.Getuid()))
|
||||
}
|
||||
|
||||
func Parse(raw string) (network, endpoint string, err error) {
|
||||
value := strings.TrimSpace(raw)
|
||||
switch {
|
||||
case value == "":
|
||||
return "", "", fmt.Errorf("gRPC address is required")
|
||||
case strings.EqualFold(value, "off"):
|
||||
return "", "", nil
|
||||
case strings.HasPrefix(value, "unix://"):
|
||||
path := strings.TrimSpace(strings.TrimPrefix(value, "unix://"))
|
||||
if path == "" {
|
||||
return "", "", fmt.Errorf("unix gRPC socket path is required")
|
||||
}
|
||||
return "unix", path, nil
|
||||
case strings.HasPrefix(value, "tcp://"):
|
||||
addr := strings.TrimSpace(strings.TrimPrefix(value, "tcp://"))
|
||||
if addr == "" {
|
||||
return "", "", fmt.Errorf("tcp gRPC address is required")
|
||||
}
|
||||
return "tcp", addr, nil
|
||||
case strings.HasPrefix(value, "/"):
|
||||
return "unix", value, nil
|
||||
default:
|
||||
return "tcp", value, nil
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package grpcaddr
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestDefaultUsesUnixSocketOnUnixLikeSystems(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("unix default is not expected on windows")
|
||||
}
|
||||
t.Setenv("XDG_RUNTIME_DIR", "/tmp/keepassgo-runtime-test")
|
||||
|
||||
got := Default("linux")
|
||||
want := "unix:///tmp/keepassgo-runtime-test/keepassgo/keepassgo-grpc.sock"
|
||||
if got != want {
|
||||
t.Fatalf("Default() = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParse(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
wantNetwork string
|
||||
wantEnd string
|
||||
}{
|
||||
{name: "unix scheme", input: "unix:///tmp/keepassgo.sock", wantNetwork: "unix", wantEnd: "/tmp/keepassgo.sock"},
|
||||
{name: "tcp scheme", input: "tcp://127.0.0.1:47777", wantNetwork: "tcp", wantEnd: "127.0.0.1:47777"},
|
||||
{name: "bare path", input: filepath.Clean("/tmp/keepassgo.sock"), wantNetwork: "unix", wantEnd: filepath.Clean("/tmp/keepassgo.sock")},
|
||||
{name: "bare tcp", input: "127.0.0.1:47777", wantNetwork: "tcp", wantEnd: "127.0.0.1:47777"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
gotNetwork, gotEnd, err := Parse(tt.input)
|
||||
if err != nil {
|
||||
t.Fatalf("Parse() error = %v", err)
|
||||
}
|
||||
if gotNetwork != tt.wantNetwork || gotEnd != tt.wantEnd {
|
||||
t.Fatalf("Parse() = (%q, %q), want (%q, %q)", gotNetwork, gotEnd, tt.wantNetwork, tt.wantEnd)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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...)
|
||||
}
|
||||
|
||||
@@ -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]
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -42,12 +42,34 @@ build() {
|
||||
local app_version
|
||||
app_version="$(git describe --tags --always --dirty)"
|
||||
go build -ldflags "-X git.julianfamily.org/keepassgo/internal/appui.appVersion=${app_version}" -o keepassgo ./cmd/keepassgo
|
||||
go build -ldflags "-X git.julianfamily.org/keepassgo/internal/appui.appVersion=${app_version}" -o keepassgo-browser-bridge ./cmd/keepassgo-browser-bridge
|
||||
}
|
||||
|
||||
package() {
|
||||
cd "$(_repo_dir)"
|
||||
|
||||
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 \
|
||||
|
||||
+583
-242
File diff suppressed because it is too large
Load Diff
@@ -11,6 +11,8 @@ service VaultService {
|
||||
rpc SaveVault(SaveVaultRequest) returns (SaveVaultResponse);
|
||||
rpc LockVault(LockVaultRequest) returns (LockVaultResponse);
|
||||
rpc UnlockVault(UnlockVaultRequest) returns (UnlockVaultResponse);
|
||||
rpc FindBrowserLogins(FindBrowserLoginsRequest) returns (FindBrowserLoginsResponse);
|
||||
rpc GetBrowserCredential(GetBrowserCredentialRequest) returns (GetBrowserCredentialResponse);
|
||||
rpc ListEntries(ListEntriesRequest) returns (ListEntriesResponse);
|
||||
rpc ListGroups(ListGroupsRequest) returns (ListGroupsResponse);
|
||||
rpc CreateGroup(CreateGroupRequest) returns (CreateGroupResponse);
|
||||
@@ -39,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 {
|
||||
@@ -75,6 +79,35 @@ message UnlockVaultRequest {
|
||||
|
||||
message UnlockVaultResponse {}
|
||||
|
||||
message FindBrowserLoginsRequest {
|
||||
string page_url = 1;
|
||||
}
|
||||
|
||||
message BrowserLoginMatch {
|
||||
string id = 1;
|
||||
string title = 2;
|
||||
string username = 3;
|
||||
string url = 4;
|
||||
repeated string path = 5;
|
||||
string quality = 6;
|
||||
}
|
||||
|
||||
message FindBrowserLoginsResponse {
|
||||
repeated BrowserLoginMatch matches = 1;
|
||||
}
|
||||
|
||||
message GetBrowserCredentialRequest {
|
||||
string id = 1;
|
||||
string page_url = 2;
|
||||
}
|
||||
|
||||
message GetBrowserCredentialResponse {
|
||||
string id = 1;
|
||||
string username = 2;
|
||||
string password = 3;
|
||||
string url = 4;
|
||||
}
|
||||
|
||||
message ListEntriesRequest {
|
||||
repeated string path = 1;
|
||||
string query = 2;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
|
||||
// versions:
|
||||
// - protoc-gen-go-grpc v1.5.1
|
||||
// - protoc v6.33.1
|
||||
// - protoc v7.34.1
|
||||
// source: proto/keepassgo/v1/keepassgo.proto
|
||||
|
||||
package keepassgov1
|
||||
@@ -25,6 +25,8 @@ const (
|
||||
VaultService_SaveVault_FullMethodName = "/keepassgo.v1.VaultService/SaveVault"
|
||||
VaultService_LockVault_FullMethodName = "/keepassgo.v1.VaultService/LockVault"
|
||||
VaultService_UnlockVault_FullMethodName = "/keepassgo.v1.VaultService/UnlockVault"
|
||||
VaultService_FindBrowserLogins_FullMethodName = "/keepassgo.v1.VaultService/FindBrowserLogins"
|
||||
VaultService_GetBrowserCredential_FullMethodName = "/keepassgo.v1.VaultService/GetBrowserCredential"
|
||||
VaultService_ListEntries_FullMethodName = "/keepassgo.v1.VaultService/ListEntries"
|
||||
VaultService_ListGroups_FullMethodName = "/keepassgo.v1.VaultService/ListGroups"
|
||||
VaultService_CreateGroup_FullMethodName = "/keepassgo.v1.VaultService/CreateGroup"
|
||||
@@ -57,6 +59,8 @@ type VaultServiceClient interface {
|
||||
SaveVault(ctx context.Context, in *SaveVaultRequest, opts ...grpc.CallOption) (*SaveVaultResponse, error)
|
||||
LockVault(ctx context.Context, in *LockVaultRequest, opts ...grpc.CallOption) (*LockVaultResponse, error)
|
||||
UnlockVault(ctx context.Context, in *UnlockVaultRequest, opts ...grpc.CallOption) (*UnlockVaultResponse, error)
|
||||
FindBrowserLogins(ctx context.Context, in *FindBrowserLoginsRequest, opts ...grpc.CallOption) (*FindBrowserLoginsResponse, error)
|
||||
GetBrowserCredential(ctx context.Context, in *GetBrowserCredentialRequest, opts ...grpc.CallOption) (*GetBrowserCredentialResponse, error)
|
||||
ListEntries(ctx context.Context, in *ListEntriesRequest, opts ...grpc.CallOption) (*ListEntriesResponse, error)
|
||||
ListGroups(ctx context.Context, in *ListGroupsRequest, opts ...grpc.CallOption) (*ListGroupsResponse, error)
|
||||
CreateGroup(ctx context.Context, in *CreateGroupRequest, opts ...grpc.CallOption) (*CreateGroupResponse, error)
|
||||
@@ -147,6 +151,26 @@ func (c *vaultServiceClient) UnlockVault(ctx context.Context, in *UnlockVaultReq
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *vaultServiceClient) FindBrowserLogins(ctx context.Context, in *FindBrowserLoginsRequest, opts ...grpc.CallOption) (*FindBrowserLoginsResponse, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(FindBrowserLoginsResponse)
|
||||
err := c.cc.Invoke(ctx, VaultService_FindBrowserLogins_FullMethodName, in, out, cOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *vaultServiceClient) GetBrowserCredential(ctx context.Context, in *GetBrowserCredentialRequest, opts ...grpc.CallOption) (*GetBrowserCredentialResponse, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(GetBrowserCredentialResponse)
|
||||
err := c.cc.Invoke(ctx, VaultService_GetBrowserCredential_FullMethodName, in, out, cOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *vaultServiceClient) ListEntries(ctx context.Context, in *ListEntriesRequest, opts ...grpc.CallOption) (*ListEntriesResponse, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(ListEntriesResponse)
|
||||
@@ -357,6 +381,8 @@ type VaultServiceServer interface {
|
||||
SaveVault(context.Context, *SaveVaultRequest) (*SaveVaultResponse, error)
|
||||
LockVault(context.Context, *LockVaultRequest) (*LockVaultResponse, error)
|
||||
UnlockVault(context.Context, *UnlockVaultRequest) (*UnlockVaultResponse, error)
|
||||
FindBrowserLogins(context.Context, *FindBrowserLoginsRequest) (*FindBrowserLoginsResponse, error)
|
||||
GetBrowserCredential(context.Context, *GetBrowserCredentialRequest) (*GetBrowserCredentialResponse, error)
|
||||
ListEntries(context.Context, *ListEntriesRequest) (*ListEntriesResponse, error)
|
||||
ListGroups(context.Context, *ListGroupsRequest) (*ListGroupsResponse, error)
|
||||
CreateGroup(context.Context, *CreateGroupRequest) (*CreateGroupResponse, error)
|
||||
@@ -405,6 +431,12 @@ func (UnimplementedVaultServiceServer) LockVault(context.Context, *LockVaultRequ
|
||||
func (UnimplementedVaultServiceServer) UnlockVault(context.Context, *UnlockVaultRequest) (*UnlockVaultResponse, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method UnlockVault not implemented")
|
||||
}
|
||||
func (UnimplementedVaultServiceServer) FindBrowserLogins(context.Context, *FindBrowserLoginsRequest) (*FindBrowserLoginsResponse, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method FindBrowserLogins not implemented")
|
||||
}
|
||||
func (UnimplementedVaultServiceServer) GetBrowserCredential(context.Context, *GetBrowserCredentialRequest) (*GetBrowserCredentialResponse, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method GetBrowserCredential not implemented")
|
||||
}
|
||||
func (UnimplementedVaultServiceServer) ListEntries(context.Context, *ListEntriesRequest) (*ListEntriesResponse, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method ListEntries not implemented")
|
||||
}
|
||||
@@ -594,6 +626,42 @@ func _VaultService_UnlockVault_Handler(srv interface{}, ctx context.Context, dec
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _VaultService_FindBrowserLogins_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(FindBrowserLoginsRequest)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(VaultServiceServer).FindBrowserLogins(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: VaultService_FindBrowserLogins_FullMethodName,
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(VaultServiceServer).FindBrowserLogins(ctx, req.(*FindBrowserLoginsRequest))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _VaultService_GetBrowserCredential_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(GetBrowserCredentialRequest)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(VaultServiceServer).GetBrowserCredential(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: VaultService_GetBrowserCredential_FullMethodName,
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(VaultServiceServer).GetBrowserCredential(ctx, req.(*GetBrowserCredentialRequest))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _VaultService_ListEntries_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(ListEntriesRequest)
|
||||
if err := dec(in); err != nil {
|
||||
@@ -985,6 +1053,14 @@ var VaultService_ServiceDesc = grpc.ServiceDesc{
|
||||
MethodName: "UnlockVault",
|
||||
Handler: _VaultService_UnlockVault_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "FindBrowserLogins",
|
||||
Handler: _VaultService_FindBrowserLogins_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "GetBrowserCredential",
|
||||
Handler: _VaultService_GetBrowserCredential_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "ListEntries",
|
||||
Handler: _VaultService_ListEntries_Handler,
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"flag"
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
keepassgov1 "git.julianfamily.org/keepassgo/proto/keepassgo/v1"
|
||||
"google.golang.org/grpc"
|
||||
)
|
||||
|
||||
type validationServer struct {
|
||||
keepassgov1.UnimplementedVaultServiceServer
|
||||
statePath string
|
||||
pageURL string
|
||||
}
|
||||
|
||||
func readState(path string) string {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return "idle"
|
||||
}
|
||||
return strings.TrimSpace(string(data))
|
||||
}
|
||||
|
||||
func writeState(path, value string) {
|
||||
_ = os.WriteFile(path, []byte(value), 0o644)
|
||||
}
|
||||
|
||||
func (s *validationServer) GetSessionStatus(context.Context, *keepassgov1.GetSessionStatusRequest) (*keepassgov1.GetSessionStatusResponse, error) {
|
||||
pending := uint32(0)
|
||||
if readState(s.statePath) == "pending" {
|
||||
pending = 1
|
||||
}
|
||||
return &keepassgov1.GetSessionStatusResponse{
|
||||
Locked: false,
|
||||
EntryCount: 1,
|
||||
PendingApprovalCount: pending,
|
||||
TokenPendingApprovalCount: pending,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *validationServer) FindBrowserLogins(context.Context, *keepassgov1.FindBrowserLoginsRequest) (*keepassgov1.FindBrowserLoginsResponse, error) {
|
||||
return &keepassgov1.FindBrowserLoginsResponse{
|
||||
Matches: []*keepassgov1.BrowserLoginMatch{
|
||||
{
|
||||
Id: "vault-console",
|
||||
Title: "Vault Console",
|
||||
Username: "dannyocean",
|
||||
Url: s.pageURL,
|
||||
Path: []string{"Root", "Crew"},
|
||||
Quality: "exact-host",
|
||||
},
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *validationServer) GetBrowserCredential(ctx context.Context, req *keepassgov1.GetBrowserCredentialRequest) (*keepassgov1.GetBrowserCredentialResponse, error) {
|
||||
writeState(s.statePath, "pending")
|
||||
ticker := time.NewTicker(200 * time.Millisecond)
|
||||
defer ticker.Stop()
|
||||
timeout := time.After(20 * time.Second)
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
case <-timeout:
|
||||
return nil, fmt.Errorf("timed out waiting for browser-approval state")
|
||||
case <-ticker.C:
|
||||
if readState(s.statePath) == "approved" {
|
||||
writeState(s.statePath, "done")
|
||||
return &keepassgov1.GetBrowserCredentialResponse{
|
||||
Id: req.GetId(),
|
||||
Username: "dannyocean",
|
||||
Password: "token-1",
|
||||
Url: s.pageURL,
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func main() {
|
||||
listenAddr := flag.String("listen", "127.0.0.1:47779", "listen address")
|
||||
statePath := flag.String("state", "", "path to mutable validation state file")
|
||||
pageURL := flag.String("page-url", "http://127.0.0.1:18080/login.html", "login page URL returned by the stub")
|
||||
flag.Parse()
|
||||
|
||||
if strings.TrimSpace(*statePath) == "" {
|
||||
panic("validation state file is required")
|
||||
}
|
||||
|
||||
listener, err := net.Listen("tcp", strings.TrimSpace(*listenAddr))
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
server := grpc.NewServer()
|
||||
keepassgov1.RegisterVaultServiceServer(server, &validationServer{
|
||||
statePath: strings.TrimSpace(*statePath),
|
||||
pageURL: strings.TrimSpace(*pageURL),
|
||||
})
|
||||
if err := server.Serve(listener); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,611 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
import argparse
|
||||
import base64
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import socket
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import textwrap
|
||||
import time
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||
EXTENSION_SOURCE = REPO_ROOT / "browser" / "extension"
|
||||
STUB_SERVER = REPO_ROOT / "scripts" / "browser_extension_validation_server.go"
|
||||
TOKEN = "test-token"
|
||||
ORIGINAL_HOME = Path(os.environ.get("HOME", ""))
|
||||
|
||||
|
||||
def run(cmd, *, cwd=None, env=None, check=True):
|
||||
result = subprocess.run(cmd, cwd=cwd, env=env, text=True, capture_output=True)
|
||||
if check and result.returncode != 0:
|
||||
raise RuntimeError(
|
||||
f"command failed ({result.returncode}): {' '.join(cmd)}\n"
|
||||
f"stdout:\n{result.stdout}\n"
|
||||
f"stderr:\n{result.stderr}"
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
def ensure_selenium_venv(venv_dir: Path):
|
||||
python_bin = venv_dir / "bin" / "python"
|
||||
if not python_bin.exists():
|
||||
run([sys.executable, "-m", "venv", str(venv_dir)])
|
||||
run([str(python_bin), "-m", "pip", "install", "selenium"])
|
||||
return python_bin
|
||||
|
||||
|
||||
def require_binary(name):
|
||||
path = shutil.which(name)
|
||||
if not path:
|
||||
raise RuntimeError(f"required binary {name!r} was not found in PATH")
|
||||
return path
|
||||
|
||||
|
||||
def find_geckodriver():
|
||||
direct = shutil.which("geckodriver")
|
||||
if direct:
|
||||
return direct
|
||||
cache_root = ORIGINAL_HOME / ".cache" / "selenium" / "geckodriver"
|
||||
if cache_root.exists():
|
||||
candidates = sorted(cache_root.glob("**/geckodriver"))
|
||||
if candidates:
|
||||
return str(candidates[-1])
|
||||
raise RuntimeError("required binary 'geckodriver' was not found in PATH or Selenium cache")
|
||||
|
||||
|
||||
def write_login_fixture(path: Path):
|
||||
path.write_text(
|
||||
textwrap.dedent(
|
||||
"""\
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<body>
|
||||
<form id="heist-login">
|
||||
<label>Username <input id="username" type="text" autocomplete="username"></label>
|
||||
<label>Password <input id="password" type="password" autocomplete="current-password"></label>
|
||||
<button type="submit">Open Vault</button>
|
||||
</form>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
|
||||
def build_bridge(binary_path: Path):
|
||||
run(["go", "build", "-o", str(binary_path), "./cmd/keepassgo-browser-bridge"], cwd=REPO_ROOT)
|
||||
|
||||
|
||||
def patch_validation_defaults(background_js: Path, grpc_addr: str):
|
||||
data = background_js.read_text(encoding="utf-8")
|
||||
data = data.replace('grpcAddress: "",', f'grpcAddress: "{grpc_addr}",', 1)
|
||||
data = data.replace('bearerToken: ""', f'bearerToken: "{TOKEN}"', 1)
|
||||
data += textwrap.dedent(
|
||||
"""
|
||||
|
||||
;((api) => {
|
||||
api.runtime.onMessage.addListener((message, _sender, sendResponse) => {
|
||||
if (message?.type === "keepassgo-validation-ping") {
|
||||
sendResponse({ ok: true });
|
||||
return false;
|
||||
}
|
||||
if (message?.type === "keepassgo-validation-status") {
|
||||
(async () => {
|
||||
try {
|
||||
const settings = await loadSettings();
|
||||
const status = await connectNative({
|
||||
action: "status",
|
||||
grpcAddress: settings.grpcAddress,
|
||||
bearerToken: settings.bearerToken
|
||||
});
|
||||
sendResponse({ ok: true, settings, status });
|
||||
} catch (error) {
|
||||
sendResponse({ ok: false, error: String(error) });
|
||||
}
|
||||
})();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
})(globalThis.browser ?? globalThis.chrome);
|
||||
"""
|
||||
)
|
||||
background_js.write_text(data, encoding="utf-8")
|
||||
|
||||
|
||||
def patch_validation_content(content_js: Path):
|
||||
data = content_js.read_text(encoding="utf-8")
|
||||
data += textwrap.dedent(
|
||||
"""
|
||||
|
||||
;(() => {
|
||||
const set = (name, value) => {
|
||||
document.documentElement.setAttribute(name, String(value));
|
||||
};
|
||||
const api = globalThis.browser ?? globalThis.chrome;
|
||||
set("data-keepassgo-validation-runtime-id", api?.runtime?.id || "");
|
||||
const username = document.getElementById("username");
|
||||
const focusTarget = username ? {
|
||||
role: "username",
|
||||
formIndex: 0,
|
||||
fieldIndex: 0,
|
||||
id: "username",
|
||||
name: "",
|
||||
autocomplete: "username"
|
||||
} : null;
|
||||
document.documentElement.setAttribute("data-keepassgo-validation-content", "loaded");
|
||||
try {
|
||||
if (api?.runtime?.sendMessage) {
|
||||
Promise.resolve(api.runtime.sendMessage({ type: "keepassgo-validation-ping" }))
|
||||
.then((response) => {
|
||||
if (response?.ok) {
|
||||
set("data-keepassgo-validation-background", "ok");
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
set("data-keepassgo-validation-background", String(error));
|
||||
});
|
||||
Promise.resolve(api.runtime.sendMessage({ type: "keepassgo-validation-status" }))
|
||||
.then((response) => {
|
||||
if (response?.ok) {
|
||||
set("data-keepassgo-validation-native", JSON.stringify(response.status || {}));
|
||||
set("data-keepassgo-validation-settings", JSON.stringify(response.settings || {}));
|
||||
} else {
|
||||
set("data-keepassgo-validation-native-error", response?.error || "unknown");
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
set("data-keepassgo-validation-native-error", String(error));
|
||||
});
|
||||
Promise.resolve(api.runtime.sendMessage({
|
||||
type: "keepassgo-page-ready",
|
||||
force: true,
|
||||
pageHasLoginForm: true,
|
||||
focusTarget,
|
||||
signature: "validation"
|
||||
}))
|
||||
.then((response) => {
|
||||
set("data-keepassgo-validation-page-ready", JSON.stringify(response || {}));
|
||||
})
|
||||
.catch((error) => {
|
||||
set("data-keepassgo-validation-page-ready-error", String(error));
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
set("data-keepassgo-validation-background", String(error));
|
||||
}
|
||||
})();
|
||||
"""
|
||||
)
|
||||
content_js.write_text(data, encoding="utf-8")
|
||||
|
||||
|
||||
def prepare_chromium_extension(workspace: Path, grpc_addr: str):
|
||||
ext_dir = workspace / "extension-chromium"
|
||||
shutil.copytree(EXTENSION_SOURCE, ext_dir)
|
||||
patch_validation_defaults(ext_dir / "background.js", grpc_addr)
|
||||
patch_validation_content(ext_dir / "content.js")
|
||||
|
||||
key_pem = workspace / "extension-key.pem"
|
||||
key_b64 = workspace / "extension-key.b64"
|
||||
run(["openssl", "genrsa", "-out", str(key_pem), "2048"])
|
||||
der = subprocess.check_output(["openssl", "rsa", "-in", str(key_pem), "-pubout", "-outform", "DER"])
|
||||
key_b64.write_text(base64.b64encode(der).decode("utf-8"), encoding="utf-8")
|
||||
|
||||
manifest = json.loads((ext_dir / "manifest.chromium.json").read_text(encoding="utf-8"))
|
||||
manifest["key"] = key_b64.read_text(encoding="utf-8").strip()
|
||||
(ext_dir / "manifest.json").write_text(json.dumps(manifest, indent=2) + "\n", encoding="utf-8")
|
||||
return ext_dir, key_b64
|
||||
|
||||
|
||||
def prepare_firefox_extension(workspace: Path, grpc_addr: str):
|
||||
ext_dir = workspace / "extension-firefox"
|
||||
shutil.copytree(EXTENSION_SOURCE, ext_dir)
|
||||
patch_validation_defaults(ext_dir / "background.js", grpc_addr)
|
||||
patch_validation_content(ext_dir / "content.js")
|
||||
manifest = json.loads((ext_dir / "manifest.firefox.json").read_text(encoding="utf-8"))
|
||||
(ext_dir / "manifest.json").write_text(json.dumps(manifest, indent=2) + "\n", encoding="utf-8")
|
||||
xpi_path = workspace / "keepassgo-firefox.xpi"
|
||||
with zipfile.ZipFile(xpi_path, "w") as zf:
|
||||
for path in ext_dir.iterdir():
|
||||
if path.is_file() and path.name != "manifest.firefox.json":
|
||||
zf.write(path, arcname=path.name)
|
||||
return xpi_path
|
||||
|
||||
|
||||
def install_chromium_native_host(workspace: Path, bridge_binary: Path, key_b64: Path):
|
||||
home_dir = workspace / "home"
|
||||
home_dir.mkdir(parents=True, exist_ok=True)
|
||||
env = os.environ.copy()
|
||||
env["HOME"] = str(home_dir)
|
||||
env["XDG_CONFIG_HOME"] = str(home_dir / ".config")
|
||||
result = run(
|
||||
[
|
||||
str(bridge_binary),
|
||||
"install-native-host",
|
||||
"--browser",
|
||||
"chromium",
|
||||
"--binary",
|
||||
str(bridge_binary),
|
||||
"--extension-key-file",
|
||||
str(key_b64),
|
||||
],
|
||||
env=env,
|
||||
)
|
||||
manifest_path = Path(result.stdout.strip())
|
||||
for mirror in [
|
||||
home_dir / ".config" / "google-chrome" / "NativeMessagingHosts" / "com.keepassgo.browser.json",
|
||||
home_dir / ".config" / "chromium-browser" / "NativeMessagingHosts" / "com.keepassgo.browser.json",
|
||||
home_dir / ".config" / "chromium" / "chromium" / "NativeMessagingHosts" / "com.keepassgo.browser.json",
|
||||
workspace / "chromium-profile" / "NativeMessagingHosts" / "com.keepassgo.browser.json",
|
||||
]:
|
||||
mirror.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copyfile(manifest_path, mirror)
|
||||
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
|
||||
origin = manifest["allowed_origins"][0]
|
||||
extension_id = re.search(r"chrome-extension://([^/]+)/", origin).group(1)
|
||||
return extension_id, home_dir
|
||||
|
||||
|
||||
def install_firefox_native_host(workspace: Path, bridge_binary: Path):
|
||||
home_dir = workspace / "home"
|
||||
home_dir.mkdir(parents=True, exist_ok=True)
|
||||
env = os.environ.copy()
|
||||
env["HOME"] = str(home_dir)
|
||||
run(
|
||||
[
|
||||
str(bridge_binary),
|
||||
"install-native-host",
|
||||
"--browser",
|
||||
"firefox",
|
||||
"--binary",
|
||||
str(bridge_binary),
|
||||
],
|
||||
env=env,
|
||||
)
|
||||
return home_dir
|
||||
|
||||
|
||||
def launch_process(cmd, *, cwd=None, env=None, log_path=None):
|
||||
handle = open(log_path, "w", encoding="utf-8") if log_path else subprocess.DEVNULL
|
||||
return subprocess.Popen(cmd, cwd=cwd, env=env, stdout=handle, stderr=handle, text=True)
|
||||
|
||||
|
||||
def wait_for_http(url: str, timeout: float = 10.0):
|
||||
import urllib.request
|
||||
|
||||
deadline = time.time() + timeout
|
||||
while time.time() < deadline:
|
||||
try:
|
||||
with urllib.request.urlopen(url, timeout=1) as response:
|
||||
if response.status == 200:
|
||||
return
|
||||
except Exception:
|
||||
time.sleep(0.2)
|
||||
raise RuntimeError(f"timed out waiting for HTTP endpoint {url}")
|
||||
|
||||
|
||||
def wait_for_tcp(host: str, port: int, *, timeout: float = 20.0, process=None, log_path: Path | None = None, name: str = "TCP endpoint"):
|
||||
deadline = time.time() + timeout
|
||||
while time.time() < deadline:
|
||||
if process is not None and process.poll() is not None:
|
||||
details = ""
|
||||
if log_path and log_path.exists():
|
||||
details = f"\nlog:\n{log_path.read_text(encoding='utf-8')}"
|
||||
raise RuntimeError(f"{name} exited before becoming ready{details}")
|
||||
try:
|
||||
with socket.create_connection((host, port), timeout=1):
|
||||
return
|
||||
except OSError:
|
||||
time.sleep(0.2)
|
||||
details = ""
|
||||
if log_path and log_path.exists():
|
||||
details = f"\nlog:\n{log_path.read_text(encoding='utf-8')}"
|
||||
raise RuntimeError(f"timed out waiting for {name} on {host}:{port}{details}")
|
||||
|
||||
|
||||
def save_artifact(path: Path, content: str):
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(content, encoding="utf-8")
|
||||
|
||||
|
||||
def find_free_port() -> int:
|
||||
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
|
||||
sock.bind(("127.0.0.1", 0))
|
||||
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||||
return int(sock.getsockname()[1])
|
||||
|
||||
|
||||
def run_chromium_flow(workspace: Path, extension_id: str, login_url: str):
|
||||
from selenium import webdriver
|
||||
from selenium.webdriver.chrome.options import Options
|
||||
from selenium.webdriver.chrome.service import Service
|
||||
from selenium.webdriver.common.by import By
|
||||
from selenium.webdriver.support.ui import WebDriverWait
|
||||
from selenium.webdriver.support import expected_conditions as EC
|
||||
|
||||
ext_dir = workspace / "extension-chromium"
|
||||
options = Options()
|
||||
options.binary_location = require_binary("chromium")
|
||||
options.set_capability("goog:loggingPrefs", {"browser": "ALL"})
|
||||
for arg in [
|
||||
f"--user-data-dir={workspace / 'chromium-profile'}",
|
||||
f"--load-extension={ext_dir}",
|
||||
f"--disable-extensions-except={ext_dir}",
|
||||
"--no-first-run",
|
||||
"--no-default-browser-check",
|
||||
"--disable-search-engine-choice-screen",
|
||||
"--disable-gpu",
|
||||
"--no-sandbox",
|
||||
"--disable-dev-shm-usage",
|
||||
]:
|
||||
options.add_argument(arg)
|
||||
driver = webdriver.Chrome(service=Service(require_binary("chromedriver")), options=options)
|
||||
wait = WebDriverWait(driver, 25)
|
||||
stage = "launch browser"
|
||||
|
||||
try:
|
||||
stage = "open login page"
|
||||
driver.get(login_url)
|
||||
wait.until(EC.element_to_be_clickable((By.ID, "username"))).click()
|
||||
stage = "wait for inline root"
|
||||
wait.until(lambda d: d.execute_script("return !!document.querySelector('#keepassgo-inline-root')"))
|
||||
stage = "wait for inline dock"
|
||||
wait.until(
|
||||
lambda d: d.execute_script(
|
||||
"const root=document.querySelector('#keepassgo-inline-root');"
|
||||
"const dock=root?.shadowRoot?.querySelector('.dock');"
|
||||
"return !!(dock && getComputedStyle(dock).display !== 'none');"
|
||||
)
|
||||
)
|
||||
stage = "open inline chooser"
|
||||
driver.execute_script(
|
||||
"document.querySelector('#keepassgo-inline-root').shadowRoot.querySelector('.trigger').click()"
|
||||
)
|
||||
stage = "wait for chooser matches"
|
||||
wait.until(
|
||||
lambda d: d.execute_script(
|
||||
"const root=document.querySelector('#keepassgo-inline-root');"
|
||||
"return root.shadowRoot.querySelectorAll('.match').length || 0;"
|
||||
)
|
||||
)
|
||||
stage = "request browser fill"
|
||||
driver.execute_script(
|
||||
"document.querySelector('#keepassgo-inline-root').shadowRoot.querySelector('.match').click()"
|
||||
)
|
||||
stage = "wait for page approval prompt"
|
||||
wait.until(
|
||||
lambda d: "Approve or deny"
|
||||
in d.execute_script(
|
||||
"const root=document.querySelector('#keepassgo-inline-root');"
|
||||
"return root.shadowRoot.querySelector('.match-list').textContent;"
|
||||
)
|
||||
)
|
||||
state_path = workspace / "state.txt"
|
||||
deadline = time.time() + 10
|
||||
while time.time() < deadline:
|
||||
if state_path.read_text(encoding="utf-8").strip() == "pending":
|
||||
break
|
||||
time.sleep(0.2)
|
||||
else:
|
||||
raise RuntimeError("stub server never observed a pending approval state")
|
||||
|
||||
stage = "verify popup approval state"
|
||||
target_tab_id = driver.execute_script(
|
||||
"const raw = document.documentElement.getAttribute('data-keepassgo-validation-page-ready');"
|
||||
"return raw ? JSON.parse(raw).tabId : null;"
|
||||
)
|
||||
if not target_tab_id:
|
||||
raise RuntimeError("validation page did not expose a target tab id for popup state checks")
|
||||
driver.switch_to.new_window("tab")
|
||||
driver.get(f"chrome-extension://{extension_id}/popup.html?tabId={int(target_tab_id)}")
|
||||
wait.until(lambda d: "Approval needed" in d.find_element(By.ID, "status-title").text)
|
||||
|
||||
stage = "approve fill and wait for completion"
|
||||
state_path.write_text("approved", encoding="utf-8")
|
||||
driver.switch_to.window(driver.window_handles[0])
|
||||
wait.until(lambda d: d.find_element(By.ID, "username").get_attribute("value") == "dannyocean")
|
||||
wait.until(lambda d: d.find_element(By.ID, "password").get_attribute("value") == "token-1")
|
||||
return True
|
||||
except Exception as exc: # noqa: BLE001
|
||||
artifacts = workspace / "artifacts"
|
||||
save_artifact(artifacts / "chromium-page.html", driver.page_source)
|
||||
try:
|
||||
driver.save_screenshot(str(artifacts / "chromium-page.png"))
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
save_artifact(artifacts / "chromium-browser.log", json.dumps(driver.get_log("browser"), indent=2))
|
||||
except Exception:
|
||||
pass
|
||||
raise RuntimeError(f"chromium validation failed during {stage}: {type(exc).__name__}: {exc}") from exc
|
||||
finally:
|
||||
driver.quit()
|
||||
|
||||
|
||||
def run_firefox_flow(workspace: Path, login_url: str):
|
||||
from selenium import webdriver
|
||||
from selenium.webdriver.firefox.options import Options
|
||||
from selenium.webdriver.firefox.service import Service
|
||||
from selenium.webdriver.common.by import By
|
||||
from selenium.webdriver.support.ui import WebDriverWait
|
||||
from selenium.webdriver.support import expected_conditions as EC
|
||||
|
||||
xpi_path = workspace / "keepassgo-firefox.xpi"
|
||||
options = Options()
|
||||
options.binary_location = require_binary("firefox")
|
||||
options.add_argument("-headless")
|
||||
service = Service(find_geckodriver())
|
||||
driver = webdriver.Firefox(service=service, options=options)
|
||||
wait = WebDriverWait(driver, 25)
|
||||
stage = "launch firefox"
|
||||
try:
|
||||
stage = "install temporary addon"
|
||||
addon_id = driver.install_addon(str(xpi_path), temporary=True)
|
||||
if addon_id != "browser@keepassgo.com":
|
||||
raise RuntimeError(f"unexpected addon id {addon_id!r}")
|
||||
stage = "open login page"
|
||||
driver.get(login_url)
|
||||
wait.until(EC.element_to_be_clickable((By.ID, "username"))).click()
|
||||
stage = "wait for inline root"
|
||||
wait.until(lambda d: d.execute_script("return !!document.querySelector('#keepassgo-inline-root')"))
|
||||
stage = "wait for inline dock"
|
||||
wait.until(
|
||||
lambda d: d.execute_script(
|
||||
"const root=document.querySelector('#keepassgo-inline-root');"
|
||||
"const dock=root?.shadowRoot?.querySelector('.dock');"
|
||||
"return !!(dock && getComputedStyle(dock).display !== 'none');"
|
||||
)
|
||||
)
|
||||
stage = "open inline chooser"
|
||||
driver.execute_script(
|
||||
"document.querySelector('#keepassgo-inline-root').shadowRoot.querySelector('.trigger').click()"
|
||||
)
|
||||
stage = "wait for chooser matches"
|
||||
wait.until(
|
||||
lambda d: d.execute_script(
|
||||
"const root=document.querySelector('#keepassgo-inline-root');"
|
||||
"return root.shadowRoot.querySelectorAll('.match').length || 0;"
|
||||
)
|
||||
)
|
||||
stage = "request browser fill"
|
||||
driver.execute_script(
|
||||
"document.querySelector('#keepassgo-inline-root').shadowRoot.querySelector('.match').click()"
|
||||
)
|
||||
stage = "wait for page approval prompt"
|
||||
wait.until(
|
||||
lambda d: "Approve or deny"
|
||||
in d.execute_script(
|
||||
"const root=document.querySelector('#keepassgo-inline-root');"
|
||||
"return root.shadowRoot.querySelector('.match-list').textContent;"
|
||||
)
|
||||
)
|
||||
state_path = workspace / "state.txt"
|
||||
deadline = time.time() + 10
|
||||
while time.time() < deadline:
|
||||
if state_path.read_text(encoding="utf-8").strip() == "pending":
|
||||
break
|
||||
time.sleep(0.2)
|
||||
else:
|
||||
raise RuntimeError("stub server never observed a pending approval state")
|
||||
stage = "approve fill and wait for completion"
|
||||
state_path.write_text("approved", encoding="utf-8")
|
||||
wait.until(lambda d: d.find_element(By.ID, "username").get_attribute("value") == "dannyocean")
|
||||
wait.until(lambda d: d.find_element(By.ID, "password").get_attribute("value") == "token-1")
|
||||
return True
|
||||
except Exception as exc: # noqa: BLE001
|
||||
artifacts = workspace / "artifacts"
|
||||
save_artifact(artifacts / "firefox-page.html", driver.page_source)
|
||||
try:
|
||||
driver.save_screenshot(str(artifacts / "firefox-page.png"))
|
||||
except Exception:
|
||||
pass
|
||||
raise RuntimeError(f"firefox validation failed during {stage}: {type(exc).__name__}: {exc}") from exc
|
||||
finally:
|
||||
driver.quit()
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="Validate the browser-extension flow with isolated real-browser harnesses.")
|
||||
parser.add_argument("--browser", choices=["firefox", "chromium", "both"], default="firefox")
|
||||
parser.add_argument("--keep-workspace", action="store_true")
|
||||
parser.add_argument("--workspace", help=argparse.SUPPRESS)
|
||||
args = parser.parse_args()
|
||||
|
||||
workspace = Path(args.workspace) if args.workspace else Path(tempfile.mkdtemp(prefix="keepassgo-browser-validate."))
|
||||
workspace.joinpath("home").mkdir(parents=True, exist_ok=True)
|
||||
workspace.joinpath("web").mkdir(parents=True, exist_ok=True)
|
||||
if not args.workspace:
|
||||
workspace.joinpath("state.txt").write_text("idle", encoding="utf-8")
|
||||
write_login_fixture(workspace / "web" / "login.html")
|
||||
|
||||
python_bin = ensure_selenium_venv(workspace / "venv")
|
||||
if Path(sys.executable) != python_bin:
|
||||
cmd = [str(python_bin), str(Path(__file__).resolve()), "--workspace", str(workspace), "--browser", args.browser]
|
||||
if args.keep_workspace:
|
||||
cmd.append("--keep-workspace")
|
||||
raise SystemExit(subprocess.run(cmd, cwd=REPO_ROOT).returncode)
|
||||
|
||||
bridge_binary = workspace / "keepassgo-browser-bridge"
|
||||
build_bridge(bridge_binary)
|
||||
http_port = find_free_port()
|
||||
grpc_port = find_free_port()
|
||||
login_url = f"http://127.0.0.1:{http_port}/login.html"
|
||||
grpc_addr = f"tcp://127.0.0.1:{grpc_port}"
|
||||
ext_dir_chromium = xpi_path = None
|
||||
if args.browser in {"chromium", "both"}:
|
||||
ext_dir_chromium, key_b64 = prepare_chromium_extension(workspace, grpc_addr)
|
||||
chromium_id, chromium_home = install_chromium_native_host(workspace, bridge_binary, key_b64)
|
||||
if args.browser in {"firefox", "both"}:
|
||||
xpi_path = prepare_firefox_extension(workspace, grpc_addr)
|
||||
firefox_home = install_firefox_native_host(workspace, bridge_binary)
|
||||
|
||||
home_dir = workspace / "home"
|
||||
env = os.environ.copy()
|
||||
env["HOME"] = str(home_dir)
|
||||
env["XDG_CONFIG_HOME"] = str(home_dir / ".config")
|
||||
env["CHROME_CONFIG_HOME"] = str(home_dir / ".config")
|
||||
os.environ["HOME"] = str(home_dir)
|
||||
os.environ["XDG_CONFIG_HOME"] = env["XDG_CONFIG_HOME"]
|
||||
os.environ["CHROME_CONFIG_HOME"] = env["CHROME_CONFIG_HOME"]
|
||||
http_server = launch_process(
|
||||
[sys.executable, "-m", "http.server", str(http_port)],
|
||||
cwd=workspace / "web",
|
||||
env=env,
|
||||
log_path=workspace / "http.log",
|
||||
)
|
||||
stub_server = launch_process(
|
||||
[
|
||||
"go",
|
||||
"run",
|
||||
str(STUB_SERVER),
|
||||
"--listen",
|
||||
f"127.0.0.1:{grpc_port}",
|
||||
"--state",
|
||||
str(workspace / "state.txt"),
|
||||
"--page-url",
|
||||
login_url,
|
||||
],
|
||||
cwd=REPO_ROOT,
|
||||
env=env,
|
||||
log_path=workspace / "stub.log",
|
||||
)
|
||||
|
||||
try:
|
||||
wait_for_http(login_url)
|
||||
wait_for_tcp("127.0.0.1", grpc_port, process=stub_server, log_path=workspace / "stub.log", name="validation gRPC server")
|
||||
browser_results = []
|
||||
if args.browser in {"firefox", "both"}:
|
||||
browser_results.append("firefox")
|
||||
run_firefox_flow(workspace, login_url)
|
||||
workspace.joinpath("state.txt").write_text("idle", encoding="utf-8")
|
||||
if args.browser in {"chromium", "both"}:
|
||||
browser_results.append("chromium")
|
||||
run_chromium_flow(workspace, chromium_id, login_url)
|
||||
print(f"browser validation passed for {', '.join(browser_results)}; workspace: {workspace}", flush=True)
|
||||
if not args.keep_workspace:
|
||||
shutil.rmtree(workspace, ignore_errors=True)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
print(f"{exc}\nworkspace preserved at {workspace}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
finally:
|
||||
for process in [stub_server, http_server]:
|
||||
if process.poll() is None:
|
||||
process.terminate()
|
||||
try:
|
||||
process.wait(timeout=3)
|
||||
except subprocess.TimeoutExpired:
|
||||
process.kill()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user