Compare commits
3 Commits
0.3.0
...
852c115b2a
| Author | SHA1 | Date | |
|---|---|---|---|
| 852c115b2a | |||
| 2ef571c241 | |||
| c017308aa1 |
@@ -25,7 +25,7 @@ ifneq ($(strip $(SIGNPASS)),)
|
|||||||
GOGIO_SIGN_FLAGS += -signpass $(SIGNPASS)
|
GOGIO_SIGN_FLAGS += -signpass $(SIGNPASS)
|
||||||
endif
|
endif
|
||||||
|
|
||||||
.PHONY: apk archlinux-pkgbuild
|
.PHONY: apk archlinux-pkgbuild browser-bridge
|
||||||
apk: android/keepassgo-android.jar
|
apk: android/keepassgo-android.jar
|
||||||
@test -x "$(JAVA_HOME)/bin/java" || { echo "JAVA_HOME must point to a working JDK install"; exit 1; }
|
@test -x "$(JAVA_HOME)/bin/java" || { echo "JAVA_HOME must point to a working JDK install"; exit 1; }
|
||||||
@test -d "$(ANDROID_SDK_ROOT)" || { echo "ANDROID_SDK_ROOT must point to an Android SDK install"; exit 1; }
|
@test -d "$(ANDROID_SDK_ROOT)" || { echo "ANDROID_SDK_ROOT must point to an Android SDK install"; exit 1; }
|
||||||
@@ -68,3 +68,6 @@ archlinux-pkgbuild: $(ARCH_PKG_TMPL) Makefile
|
|||||||
-e 's|@PKGVER@|$(ARCH_PKGVER)|g' \
|
-e 's|@PKGVER@|$(ARCH_PKGVER)|g' \
|
||||||
-e 's|@REPO_DIR@|$(ARCH_REPO_DIR)|g' \
|
-e 's|@REPO_DIR@|$(ARCH_REPO_DIR)|g' \
|
||||||
"$(ARCH_PKG_TMPL)" > "$(ARCH_PKGBUILD)"
|
"$(ARCH_PKG_TMPL)" > "$(ARCH_PKGBUILD)"
|
||||||
|
|
||||||
|
browser-bridge:
|
||||||
|
go build ./cmd/keepassgo-browser-bridge
|
||||||
|
|||||||
@@ -63,6 +63,7 @@ makepkg -si
|
|||||||
The package installs:
|
The package installs:
|
||||||
|
|
||||||
- `/usr/bin/keepassgo`
|
- `/usr/bin/keepassgo`
|
||||||
|
- `/usr/bin/keepassgo-browser-bridge`
|
||||||
- a desktop entry at `/usr/share/applications/keepassgo.desktop`
|
- a desktop entry at `/usr/share/applications/keepassgo.desktop`
|
||||||
- application icons under the hicolor theme
|
- 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.
|
Desktop automation is resolved through the secure gRPC API rather than synthetic auto-type.
|
||||||
See [`docs/desktop-automation.md`](./docs/desktop-automation.md).
|
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).
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
# KeePassGO Browser Extension
|
||||||
|
|
||||||
|
Shared extension assets for Firefox and Chromium-based browsers live here.
|
||||||
|
|
||||||
|
- `manifest.firefox.json` uses the fixed Firefox extension id `browser@keepassgo.com`
|
||||||
|
- `manifest.chromium.json` is the Chromium/Chrome manifest template
|
||||||
|
- `background.js` talks to the native messaging host `com.keepassgo.browser`
|
||||||
|
- `content.js` fills username and password fields on the current page
|
||||||
|
- `options.html` stores the local gRPC address and API token in browser extension storage
|
||||||
|
|
||||||
|
The extension sends the API token to the native host on each request. The bridge does not store the token on disk.
|
||||||
@@ -0,0 +1,195 @@
|
|||||||
|
const ext = globalThis.browser ?? globalThis.chrome;
|
||||||
|
const nativeHost = "com.keepassgo.browser";
|
||||||
|
const defaultSettings = {
|
||||||
|
grpcAddress: "",
|
||||||
|
bearerToken: ""
|
||||||
|
};
|
||||||
|
|
||||||
|
function storageGet(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) {
|
||||||
|
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 tabsQuery(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 tabsSendMessage(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) {
|
||||||
|
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(["grpcAddress", "bearerToken"]);
|
||||||
|
return {
|
||||||
|
grpcAddress: (stored.grpcAddress || defaultSettings.grpcAddress).trim(),
|
||||||
|
bearerToken: (stored.bearerToken || "").trim()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function activePageContext() {
|
||||||
|
const [tab] = await tabsQuery({ active: true, currentWindow: true });
|
||||||
|
return {
|
||||||
|
tabId: tab?.id ?? null,
|
||||||
|
url: typeof tab?.url === "string" ? tab.url : ""
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function statusForPage() {
|
||||||
|
const settings = await loadSettings();
|
||||||
|
const page = await activePageContext();
|
||||||
|
if (!settings.bearerToken) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
configured: false,
|
||||||
|
status: null,
|
||||||
|
pageUrl: page.url,
|
||||||
|
matches: [],
|
||||||
|
error: "Set an API token in extension settings."
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const status = await connectNative({
|
||||||
|
action: "status",
|
||||||
|
grpcAddress: settings.grpcAddress,
|
||||||
|
bearerToken: settings.bearerToken
|
||||||
|
});
|
||||||
|
if (!status.success || status.status?.locked || !page.url.startsWith("http")) {
|
||||||
|
return {
|
||||||
|
success: status.success,
|
||||||
|
configured: true,
|
||||||
|
status: status.status ?? null,
|
||||||
|
pageUrl: page.url,
|
||||||
|
matches: [],
|
||||||
|
error: status.error ?? ""
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const matches = await connectNative({
|
||||||
|
action: "find-logins",
|
||||||
|
grpcAddress: settings.grpcAddress,
|
||||||
|
bearerToken: settings.bearerToken,
|
||||||
|
url: page.url
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
success: matches.success,
|
||||||
|
configured: true,
|
||||||
|
status: matches.status ?? status.status ?? null,
|
||||||
|
pageUrl: page.url,
|
||||||
|
matches: matches.matches ?? [],
|
||||||
|
error: matches.error ?? ""
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fillLogin(entryId) {
|
||||||
|
const settings = await loadSettings();
|
||||||
|
const page = await activePageContext();
|
||||||
|
if (!settings.bearerToken) {
|
||||||
|
throw new Error("API token is not configured.");
|
||||||
|
}
|
||||||
|
if (page.tabId == null) {
|
||||||
|
throw new Error("No active tab is available.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await connectNative({
|
||||||
|
action: "get-login",
|
||||||
|
grpcAddress: settings.grpcAddress,
|
||||||
|
bearerToken: settings.bearerToken,
|
||||||
|
entryId,
|
||||||
|
url: page.url
|
||||||
|
});
|
||||||
|
if (!response.success || !response.credential) {
|
||||||
|
throw new Error(response.error || "KeePassGO did not return a credential.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const fillResponse = await tabsSendMessage(page.tabId, {
|
||||||
|
type: "keepassgo-fill-credential",
|
||||||
|
credential: response.credential
|
||||||
|
});
|
||||||
|
if (!fillResponse?.ok) {
|
||||||
|
throw new Error(fillResponse?.error || "The current page could not be filled.");
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
credential: response.credential,
|
||||||
|
pageUrl: page.url
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
ext.runtime.onMessage.addListener((message, _sender, sendResponse) => {
|
||||||
|
(async () => {
|
||||||
|
switch (message?.type) {
|
||||||
|
case "keepassgo-popup-state":
|
||||||
|
sendResponse(await statusForPage());
|
||||||
|
return;
|
||||||
|
case "keepassgo-fill-entry":
|
||||||
|
sendResponse({ success: true, ...(await fillLogin(message.entryId)) });
|
||||||
|
return;
|
||||||
|
case "keepassgo-load-settings":
|
||||||
|
sendResponse({ success: true, settings: await loadSettings() });
|
||||||
|
return;
|
||||||
|
case "keepassgo-save-settings":
|
||||||
|
await storageSet({
|
||||||
|
grpcAddress: String(message.settings?.grpcAddress || defaultSettings.grpcAddress).trim(),
|
||||||
|
bearerToken: String(message.settings?.bearerToken || "").trim()
|
||||||
|
});
|
||||||
|
sendResponse({ success: true });
|
||||||
|
return;
|
||||||
|
default:
|
||||||
|
sendResponse({ success: false, error: `Unsupported message ${message?.type || ""}`.trim() });
|
||||||
|
}
|
||||||
|
})().catch((error) => {
|
||||||
|
sendResponse({ success: false, error: error instanceof Error ? error.message : String(error) });
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
});
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
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 dispatchFillEvents(input) {
|
||||||
|
input.dispatchEvent(new Event("input", { bubbles: true }));
|
||||||
|
input.dispatchEvent(new Event("change", { bubbles: true }));
|
||||||
|
}
|
||||||
|
|
||||||
|
function findPasswordInput() {
|
||||||
|
return Array.from(document.querySelectorAll('input[type="password"]')).find(isVisibleInput) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function findUsernameInput(passwordInput) {
|
||||||
|
const form = passwordInput?.form || null;
|
||||||
|
const scope = form || document;
|
||||||
|
const candidates = Array.from(scope.querySelectorAll('input[type="text"], input[type="email"], input:not([type])'))
|
||||||
|
.filter(isVisibleInput);
|
||||||
|
if (passwordInput) {
|
||||||
|
const sameForm = candidates.find((input) => input.form === passwordInput.form);
|
||||||
|
if (sameForm) {
|
||||||
|
return sameForm;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return candidates[0] || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fillCredential(credential) {
|
||||||
|
const passwordInput = findPasswordInput();
|
||||||
|
const usernameInput = findUsernameInput(passwordInput);
|
||||||
|
|
||||||
|
if (usernameInput && credential.username) {
|
||||||
|
usernameInput.focus();
|
||||||
|
usernameInput.value = credential.username;
|
||||||
|
dispatchFillEvents(usernameInput);
|
||||||
|
}
|
||||||
|
if (passwordInput && credential.password) {
|
||||||
|
passwordInput.focus();
|
||||||
|
passwordInput.value = credential.password;
|
||||||
|
dispatchFillEvents(passwordInput);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!usernameInput && !passwordInput) {
|
||||||
|
return { ok: false, error: "No fillable username or password fields were found." };
|
||||||
|
}
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
(globalThis.browser ?? globalThis.chrome).runtime.onMessage.addListener((message, _sender, sendResponse) => {
|
||||||
|
if (message?.type !== "keepassgo-fill-credential") {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
sendResponse(fillCredential(message.credential || {}));
|
||||||
|
} catch (error) {
|
||||||
|
sendResponse({ ok: false, error: error instanceof Error ? error.message : String(error) });
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
});
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"manifest_version": 3,
|
||||||
|
"name": "KeePassGO Browser",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"description": "Fill credentials from KeePassGO over the local gRPC API.",
|
||||||
|
"permissions": ["activeTab", "nativeMessaging", "storage", "tabs"],
|
||||||
|
"host_permissions": ["http://*/*", "https://*/*"],
|
||||||
|
"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,31 @@
|
|||||||
|
{
|
||||||
|
"manifest_version": 3,
|
||||||
|
"name": "KeePassGO Browser",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"description": "Fill credentials from KeePassGO over the local gRPC API.",
|
||||||
|
"permissions": ["activeTab", "nativeMessaging", "storage", "tabs"],
|
||||||
|
"host_permissions": ["http://*/*", "https://*/*"],
|
||||||
|
"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"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"browser_specific_settings": {
|
||||||
|
"gecko": {
|
||||||
|
"id": "browser@keepassgo.com"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
<!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">Configure how the extension reaches KeePassGO.</p>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<form id="settings-form" class="settings-form">
|
||||||
|
<label>
|
||||||
|
<span>gRPC address</span>
|
||||||
|
<input id="grpc-address" name="grpc-address" type="text" value="" placeholder="Leave blank for the local default socket" autocomplete="off">
|
||||||
|
</label>
|
||||||
|
<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,47 @@
|
|||||||
|
const extOptions = globalThis.browser ?? globalThis.chrome;
|
||||||
|
|
||||||
|
function runtimeSend(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("grpc-address").value = response.settings.grpcAddress || "";
|
||||||
|
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: {
|
||||||
|
grpcAddress: document.getElementById("grpc-address").value,
|
||||||
|
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,29 @@
|
|||||||
|
<!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>
|
||||||
|
<section>
|
||||||
|
<h2>Matches</h2>
|
||||||
|
<div id="matches" class="match-list"></div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
<script src="popup.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
const extPopup = globalThis.browser ?? globalThis.chrome;
|
||||||
|
|
||||||
|
function runtimeSend(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 renderMatches(state) {
|
||||||
|
const root = document.getElementById("matches");
|
||||||
|
root.textContent = "";
|
||||||
|
if (!Array.isArray(state.matches) || state.matches.length === 0) {
|
||||||
|
const empty = document.createElement("p");
|
||||||
|
empty.className = "subtle";
|
||||||
|
empty.textContent = "No matching entries for this page.";
|
||||||
|
root.appendChild(empty);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const match of state.matches) {
|
||||||
|
const row = document.createElement("button");
|
||||||
|
row.type = "button";
|
||||||
|
row.className = "match-row";
|
||||||
|
row.innerHTML = `
|
||||||
|
<span class="match-main">
|
||||||
|
<strong>${match.title}</strong>
|
||||||
|
<span class="subtle">${match.username || "No username"}</span>
|
||||||
|
</span>
|
||||||
|
<span class="quality">${match.quality || ""}</span>
|
||||||
|
`;
|
||||||
|
row.addEventListener("click", async () => {
|
||||||
|
row.disabled = true;
|
||||||
|
try {
|
||||||
|
const result = await runtimeSend({ type: "keepassgo-fill-entry", entryId: match.id });
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
try {
|
||||||
|
const state = await runtimeSend({ type: "keepassgo-popup-state" });
|
||||||
|
document.getElementById("page-host").textContent = hostFromURL(state.pageUrl || "");
|
||||||
|
|
||||||
|
if (!state.configured) {
|
||||||
|
setStatus("Configure access", state.error || "Set the API token in extension settings.", "warning");
|
||||||
|
renderMatches({ matches: [] });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!state.success) {
|
||||||
|
setStatus("KeePassGO unavailable", state.error || "The native host could not reach KeePassGO.", "error");
|
||||||
|
renderMatches({ matches: [] });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (state.status?.locked) {
|
||||||
|
setStatus("Vault locked", "Unlock KeePassGO, then open the popup again.", "warning");
|
||||||
|
renderMatches({ matches: [] });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const count = Array.isArray(state.matches) ? state.matches.length : 0;
|
||||||
|
setStatus("Ready", count === 0 ? "KeePassGO is connected." : `${count} matching entr${count === 1 ? "y" : "ies"} found.`, "ready");
|
||||||
|
renderMatches(state);
|
||||||
|
} catch (error) {
|
||||||
|
setStatus("Error", error instanceof Error ? error.message : String(error), "error");
|
||||||
|
renderMatches({ matches: [] });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void main();
|
||||||
@@ -0,0 +1,174 @@
|
|||||||
|
: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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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,143 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"git.julianfamily.org/keepassgo/internal/browserbridge"
|
||||||
|
"git.julianfamily.org/keepassgo/internal/grpcaddr"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
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(os.Args[2:]); err != nil {
|
||||||
|
fail(err)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := runNativeMessage(); 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(args []string) error {
|
||||||
|
fs := flag.NewFlagSet("status", flag.ContinueOnError)
|
||||||
|
grpcAddr := fs.String("grpc-addr", grpcaddr.Default(runtime.GOOS), "KeePassGO local gRPC address")
|
||||||
|
token := fs.String("token", "", "KeePassGO API bearer token")
|
||||||
|
if err := fs.Parse(args); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
req := browserbridge.Request{
|
||||||
|
Action: "status",
|
||||||
|
GRPCAddress: strings.TrimSpace(*grpcAddr),
|
||||||
|
BearerToken: strings.TrimSpace(*token),
|
||||||
|
}
|
||||||
|
connCfg, err := req.Connection()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
conn, client, ctx, err := browserbridge.Dial(context.Background(), connCfg)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer func() { _ = conn.Close() }()
|
||||||
|
resp := browserbridge.HandleRequest(ctx, req, client)
|
||||||
|
enc := json.NewEncoder(os.Stdout)
|
||||||
|
enc.SetIndent("", " ")
|
||||||
|
return enc.Encode(resp)
|
||||||
|
}
|
||||||
|
|
||||||
|
func runNativeMessage() error {
|
||||||
|
req, err := browserbridge.ReadRequest(os.Stdin)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
connCfg, err := req.Connection()
|
||||||
|
if err != nil {
|
||||||
|
return browserbridge.WriteResponse(os.Stdout, browserbridge.Response{Success: false, Error: err.Error()})
|
||||||
|
}
|
||||||
|
conn, client, ctx, err := browserbridge.Dial(context.Background(), connCfg)
|
||||||
|
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, client))
|
||||||
|
}
|
||||||
|
|
||||||
|
func defaultBinaryPath() (string, error) {
|
||||||
|
self, err := os.Executable()
|
||||||
|
if err == nil && strings.TrimSpace(self) != "" {
|
||||||
|
return self, nil
|
||||||
|
}
|
||||||
|
self, err = exec.LookPath("keepassgo-browser-bridge")
|
||||||
|
if err == nil {
|
||||||
|
return self, nil
|
||||||
|
}
|
||||||
|
return filepath.Abs("./keepassgo-browser-bridge")
|
||||||
|
}
|
||||||
|
|
||||||
|
func fail(err error) {
|
||||||
|
fmt.Fprintln(os.Stderr, err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
# 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 gRPC address and 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
|
||||||
|
```
|
||||||
|
|
||||||
|
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. Leave the gRPC address blank to use the local default Unix socket, or set an explicit address if you overrode the listener.
|
||||||
|
4. Paste an API token scoped for browser login lookup and credential copy.
|
||||||
|
|
||||||
|
Chromium / Chrome:
|
||||||
|
|
||||||
|
1. Load `browser/extension/` with `manifest.chromium.json`.
|
||||||
|
2. Note the extension id the browser assigns.
|
||||||
|
3. Install the native host manifest with that extension id.
|
||||||
|
4. Configure the gRPC address and API token in the extension settings page.
|
||||||
|
|
||||||
|
## 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
|
||||||
+50
-3
@@ -4,10 +4,13 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net"
|
"net"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
|
|
||||||
"git.julianfamily.org/keepassgo/internal/clipboard"
|
"git.julianfamily.org/keepassgo/internal/clipboard"
|
||||||
|
"git.julianfamily.org/keepassgo/internal/grpcaddr"
|
||||||
"git.julianfamily.org/keepassgo/internal/passwords"
|
"git.julianfamily.org/keepassgo/internal/passwords"
|
||||||
"git.julianfamily.org/keepassgo/internal/session"
|
"git.julianfamily.org/keepassgo/internal/session"
|
||||||
"git.julianfamily.org/keepassgo/internal/vault"
|
"git.julianfamily.org/keepassgo/internal/vault"
|
||||||
@@ -27,6 +30,7 @@ type Host struct {
|
|||||||
lastModel vault.Model
|
lastModel vault.Model
|
||||||
started bool
|
started bool
|
||||||
listenAddr string
|
listenAddr string
|
||||||
|
socketPath string
|
||||||
}
|
}
|
||||||
|
|
||||||
func StartHost(addr string, lifecycle lifecycleBackend, profiles map[string]passwords.Profile, clipboardWriter clipboard.Writer, dirty DirtyProvider) (*Host, error) {
|
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
|
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 {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("listen gRPC host %s: %w", addr, err)
|
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,
|
listener: listener,
|
||||||
lifecycle: lifecycle,
|
lifecycle: lifecycle,
|
||||||
dirty: dirty,
|
dirty: dirty,
|
||||||
listenAddr: listener.Addr().String(),
|
listenAddr: formatListenAddress(network, listener.Addr().String(), socketPath),
|
||||||
|
socketPath: socketPath,
|
||||||
started: true,
|
started: true,
|
||||||
}
|
}
|
||||||
if err := host.SyncFromLifecycle(); err != nil && !errors.Is(err, session.ErrLocked) {
|
if err := host.SyncFromLifecycle(); err != nil && !errors.Is(err, session.ErrLocked) {
|
||||||
@@ -91,7 +100,13 @@ func (h *Host) Stop() error {
|
|||||||
}
|
}
|
||||||
h.started = false
|
h.started = false
|
||||||
h.grpcServer.Stop()
|
h.grpcServer.Stop()
|
||||||
return h.listener.Close()
|
err := h.listener.Close()
|
||||||
|
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 {
|
func (h *Host) SyncFromLifecycle() error {
|
||||||
@@ -120,3 +135,35 @@ func (h *Host) SyncFromLifecycle() error {
|
|||||||
h.server.SetSessionState(h.lastModel, locked, dirty)
|
h.server.SetSessionState(h.lastModel, locked, dirty)
|
||||||
return nil
|
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 (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
"net"
|
"net"
|
||||||
|
"os"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"git.julianfamily.org/keepassgo/internal/apitokens"
|
"git.julianfamily.org/keepassgo/internal/apitokens"
|
||||||
|
"git.julianfamily.org/keepassgo/internal/grpcaddr"
|
||||||
"git.julianfamily.org/keepassgo/internal/passwords"
|
"git.julianfamily.org/keepassgo/internal/passwords"
|
||||||
"git.julianfamily.org/keepassgo/internal/session"
|
"git.julianfamily.org/keepassgo/internal/session"
|
||||||
"git.julianfamily.org/keepassgo/internal/vault"
|
"git.julianfamily.org/keepassgo/internal/vault"
|
||||||
@@ -42,10 +45,14 @@ func TestStartHostServesVaultLifecycleAndSyncsSessionState(t *testing.T) {
|
|||||||
}
|
}
|
||||||
defer func() { _ = host.Stop() }()
|
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(),
|
conn, err := grpc.NewClient("passthrough:///"+host.Address(),
|
||||||
grpc.WithTransportCredentials(insecure.NewCredentials()),
|
grpc.WithTransportCredentials(insecure.NewCredentials()),
|
||||||
grpc.WithContextDialer(func(context.Context, string) (net.Conn, error) {
|
grpc.WithContextDialer(func(context.Context, string) (net.Conn, error) {
|
||||||
return net.Dial("tcp", host.Address())
|
return net.Dial(network, endpoint)
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -80,3 +87,37 @@ func TestStartHostServesVaultLifecycleAndSyncsSessionState(t *testing.T) {
|
|||||||
t.Fatal("GetSessionStatus().Locked = false, want true after lifecycle lock")
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,7 +3,9 @@ package api
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
"maps"
|
"maps"
|
||||||
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
"slices"
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -225,6 +227,133 @@ func (s *Server) UnlockVault(ctx context.Context, req *keepassgov1.UnlockVaultRe
|
|||||||
return &keepassgov1.UnlockVaultResponse{}, nil
|
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")
|
||||||
|
}
|
||||||
|
token, err := s.authorizeVaultRequest(ctx, apitokens.OperationListEntries)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
pageHost, err := normalizedBrowserHost(req.GetPageUrl())
|
||||||
|
if err != nil {
|
||||||
|
return nil, status.Error(codes.InvalidArgument, err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
type rankedMatch struct {
|
||||||
|
match *keepassgov1.BrowserLoginMatch
|
||||||
|
score int
|
||||||
|
}
|
||||||
|
var matches []rankedMatch
|
||||||
|
for _, entry := range visibleModel(model).Entries {
|
||||||
|
quality, score := classifyBrowserEntryMatch(pageHost, entry.URL)
|
||||||
|
if score == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
matches = append(matches, rankedMatch{
|
||||||
|
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,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
slices.SortFunc(matches, func(a, b rankedMatch) 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 := make([]*keepassgov1.BrowserLoginMatch, 0, len(matches))
|
||||||
|
for _, match := range matches {
|
||||||
|
out = append(out, match.match)
|
||||||
|
}
|
||||||
|
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) 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 {
|
func mapLifecycleError(operation string, err error) error {
|
||||||
switch {
|
switch {
|
||||||
case errors.Is(err, os.ErrNotExist):
|
case errors.Is(err, os.ErrNotExist):
|
||||||
@@ -787,6 +916,39 @@ func findMutableEntryByID(model *vault.Model, id string) (vault.Entry, int, erro
|
|||||||
return vault.Entry{}, -1, vault.ErrEntryNotFound
|
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 classifyBrowserEntryMatch(pageHost, rawEntryURL string) (string, int) {
|
||||||
|
parsed, err := url.Parse(strings.TrimSpace(rawEntryURL))
|
||||||
|
if err != nil {
|
||||||
|
return "", 0
|
||||||
|
}
|
||||||
|
entryHost := strings.ToLower(parsed.Hostname())
|
||||||
|
if entryHost == "" {
|
||||||
|
return "", 0
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case pageHost == entryHost:
|
||||||
|
return "exact-host", 3
|
||||||
|
case strings.HasSuffix(pageHost, "."+entryHost):
|
||||||
|
return "subdomain", 2
|
||||||
|
case strings.HasSuffix(entryHost, "."+pageHost):
|
||||||
|
return "parent-domain", 1
|
||||||
|
default:
|
||||||
|
return "", 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func visibleModel(model vault.Model) vault.Model {
|
func visibleModel(model vault.Model) vault.Model {
|
||||||
out := model
|
out := model
|
||||||
out.Entries = nil
|
out.Entries = nil
|
||||||
|
|||||||
@@ -159,6 +159,84 @@ 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 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) {
|
func TestVaultServicePromptsAndResumesWhenApproved(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ type Broker struct {
|
|||||||
timeout time.Duration
|
timeout time.Duration
|
||||||
now func() time.Time
|
now func() time.Time
|
||||||
nextID func() string
|
nextID func() string
|
||||||
|
notify func()
|
||||||
}
|
}
|
||||||
|
|
||||||
type pendingRequest struct {
|
type pendingRequest struct {
|
||||||
@@ -108,6 +109,15 @@ func (b *Broker) Pending() []Request {
|
|||||||
return requests
|
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) {
|
func (b *Broker) Request(ctx context.Context, token apitokens.Token, op apitokens.Operation, resource apitokens.Resource) (Result, error) {
|
||||||
if b == nil {
|
if b == nil {
|
||||||
return Result{}, ErrRequestTimedOut
|
return Result{}, ErrRequestTimedOut
|
||||||
@@ -128,12 +138,20 @@ func (b *Broker) Request(ctx context.Context, token apitokens.Token, op apitoken
|
|||||||
|
|
||||||
b.mu.Lock()
|
b.mu.Lock()
|
||||||
b.pending[pending.request.ID] = pending
|
b.pending[pending.request.ID] = pending
|
||||||
|
notify := b.notify
|
||||||
b.mu.Unlock()
|
b.mu.Unlock()
|
||||||
|
if notify != nil {
|
||||||
|
notify()
|
||||||
|
}
|
||||||
|
|
||||||
defer func() {
|
defer func() {
|
||||||
b.mu.Lock()
|
b.mu.Lock()
|
||||||
delete(b.pending, pending.request.ID)
|
delete(b.pending, pending.request.ID)
|
||||||
|
notify := b.notify
|
||||||
b.mu.Unlock()
|
b.mu.Unlock()
|
||||||
|
if notify != nil {
|
||||||
|
notify()
|
||||||
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
timer := time.NewTimer(b.timeout)
|
timer := time.NewTimer(b.timeout)
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package apiapproval
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
|
"slices"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -120,6 +121,36 @@ func TestBrokerTimesOutPendingRequests(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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) {
|
func waitForPending(t *testing.T, broker *Broker, want int) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import (
|
|||||||
"git.julianfamily.org/keepassgo/internal/apiapproval"
|
"git.julianfamily.org/keepassgo/internal/apiapproval"
|
||||||
"git.julianfamily.org/keepassgo/internal/apitokens"
|
"git.julianfamily.org/keepassgo/internal/apitokens"
|
||||||
"git.julianfamily.org/keepassgo/internal/appui/platform"
|
"git.julianfamily.org/keepassgo/internal/appui/platform"
|
||||||
|
"git.julianfamily.org/keepassgo/internal/grpcaddr"
|
||||||
"git.julianfamily.org/keepassgo/internal/passwords"
|
"git.julianfamily.org/keepassgo/internal/passwords"
|
||||||
"git.julianfamily.org/keepassgo/internal/session"
|
"git.julianfamily.org/keepassgo/internal/session"
|
||||||
"git.julianfamily.org/keepassgo/internal/vault"
|
"git.julianfamily.org/keepassgo/internal/vault"
|
||||||
@@ -56,10 +57,7 @@ func Main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func defaultGRPCAddr(goos string) string {
|
func defaultGRPCAddr(goos string) string {
|
||||||
if strings.EqualFold(strings.TrimSpace(goos), "android") {
|
return grpcaddr.Default(goos)
|
||||||
return "off"
|
|
||||||
}
|
|
||||||
return "127.0.0.1:47777"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func run(w *app.Window, mode string, paths statePaths, grpcAddr string) error {
|
func run(w *app.Window, mode string, paths statePaths, grpcAddr string) error {
|
||||||
@@ -78,6 +76,7 @@ func run(w *app.Window, mode string, paths statePaths, grpcAddr string) error {
|
|||||||
ui.state.AuditLog = ui.auditLog
|
ui.state.AuditLog = ui.auditLog
|
||||||
ui.grpcAddress = host.Address()
|
ui.grpcAddress = host.Address()
|
||||||
ui.state.Approvals = &uiApprovalManager{server: host.Server()}
|
ui.state.Approvals = &uiApprovalManager{server: host.Server()}
|
||||||
|
host.Server().ApprovalBroker().SetChangeNotifier(ui.invalidate)
|
||||||
defer func() { _ = host.Stop() }()
|
defer func() { _ = host.Stop() }()
|
||||||
}
|
}
|
||||||
for {
|
for {
|
||||||
|
|||||||
@@ -0,0 +1,350 @@
|
|||||||
|
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"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
NativeHostName = "com.keepassgo.browser"
|
||||||
|
defaultFirefoxID = "browser@keepassgo.com"
|
||||||
|
maxNativeMessageSize = 1024 * 1024
|
||||||
|
chromiumIDBytes = 16
|
||||||
|
)
|
||||||
|
|
||||||
|
type Request struct {
|
||||||
|
Action string `json:"action"`
|
||||||
|
GRPCAddress string `json:"grpcAddress,omitempty"`
|
||||||
|
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"`
|
||||||
|
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() (Connection, error) {
|
||||||
|
conn := Connection{
|
||||||
|
GRPCAddress: strings.TrimSpace(r.GRPCAddress),
|
||||||
|
BearerToken: strings.TrimSpace(r.BearerToken),
|
||||||
|
}
|
||||||
|
if conn.GRPCAddress == "" {
|
||||||
|
conn.GRPCAddress = grpcaddr.Default(runtime.GOOS)
|
||||||
|
}
|
||||||
|
if conn.BearerToken == "" {
|
||||||
|
return Connection{}, fmt.Errorf("browser bridge bearer token is required")
|
||||||
|
}
|
||||||
|
return conn, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func HandleRequest(ctx context.Context, req Request, client Client) Response {
|
||||||
|
conn, err := req.Connection()
|
||||||
|
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: "1"}
|
||||||
|
case "find-logins":
|
||||||
|
status, err := statusResponse(ctx, client, conn.GRPCAddress)
|
||||||
|
if err != nil {
|
||||||
|
return Response{Success: false, Error: err.Error(), Status: disconnectedStatus(conn.GRPCAddress)}
|
||||||
|
}
|
||||||
|
if status.Locked {
|
||||||
|
return Response{Success: true, Status: status, Matches: nil, Version: "1"}
|
||||||
|
}
|
||||||
|
matches, err := findMatches(ctx, client, req.URL)
|
||||||
|
if err != nil {
|
||||||
|
return Response{Success: false, Error: err.Error(), Status: status}
|
||||||
|
}
|
||||||
|
return Response{Success: true, Status: status, Matches: matches, Version: "1"}
|
||||||
|
case "get-login":
|
||||||
|
status, err := statusResponse(ctx, client, conn.GRPCAddress)
|
||||||
|
if err != nil {
|
||||||
|
return Response{Success: false, Error: err.Error(), Status: disconnectedStatus(conn.GRPCAddress)}
|
||||||
|
}
|
||||||
|
if status.Locked {
|
||||||
|
return Response{Success: false, Error: "vault is locked", Status: status}
|
||||||
|
}
|
||||||
|
credential, err := loadCredential(ctx, client, req.EntryID, req.URL)
|
||||||
|
if err != nil {
|
||||||
|
return Response{Success: false, Error: err.Error(), Status: status}
|
||||||
|
}
|
||||||
|
return Response{Success: true, Status: status, Credential: credential, Version: "1"}
|
||||||
|
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 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(),
|
||||||
|
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) {
|
||||||
|
manifest, err := Manifest(browser, binaryPath, extensionID)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
path := strings.TrimSpace(outputPath)
|
||||||
|
if path == "" {
|
||||||
|
path, err = DefaultManifestPath(browser)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||||
|
return "", fmt.Errorf("create native host manifest dir: %w", err)
|
||||||
|
}
|
||||||
|
data, err := json.MarshalIndent(manifest, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("encode native host manifest: %w", err)
|
||||||
|
}
|
||||||
|
data = append(data, '\n')
|
||||||
|
if err := os.WriteFile(path, data, 0o644); err != nil {
|
||||||
|
return "", fmt.Errorf("write native host manifest: %w", err)
|
||||||
|
}
|
||||||
|
return path, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,216 @@
|
|||||||
|
package browserbridge
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/binary"
|
||||||
|
"encoding/json"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
keepassgov1 "git.julianfamily.org/keepassgo/proto/keepassgo/v1"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestReadRequestAndWriteResponse(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
var input bytes.Buffer
|
||||||
|
body, err := json.Marshal(Request{
|
||||||
|
Action: "find-logins",
|
||||||
|
GRPCAddress: "127.0.0.1:47777",
|
||||||
|
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(); err != nil || conn.GRPCAddress != "127.0.0.1:47777" {
|
||||||
|
t.Fatalf("req.Connection() = (%#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{
|
||||||
|
status: &keepassgov1.GetSessionStatusResponse{Locked: false, EntryCount: 2},
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleRequestGetLogin(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
client := fakeClient{
|
||||||
|
status: &keepassgov1.GetSessionStatusResponse{Locked: false, EntryCount: 1},
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type fakeClient struct {
|
||||||
|
status *keepassgov1.GetSessionStatusResponse
|
||||||
|
matches []*keepassgov1.BrowserLoginMatch
|
||||||
|
credential *keepassgov1.GetBrowserCredentialResponse
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f fakeClient) Status(context.Context) (*keepassgov1.GetSessionStatusResponse, error) {
|
||||||
|
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.err != nil {
|
||||||
|
return nil, f.err
|
||||||
|
}
|
||||||
|
return f.matches, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f fakeClient) GetBrowserCredential(context.Context, string, string) (*keepassgov1.GetBrowserCredentialResponse, error) {
|
||||||
|
if f.err != nil {
|
||||||
|
return nil, f.err
|
||||||
|
}
|
||||||
|
if f.credential == nil {
|
||||||
|
return &keepassgov1.GetBrowserCredentialResponse{}, nil
|
||||||
|
}
|
||||||
|
return f.credential, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
package browserbridge
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
|
"runtime"
|
||||||
|
"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 Dial(ctx context.Context, conn Connection) (*grpc.ClientConn, *GRPCClient, context.Context, error) {
|
||||||
|
if strings.TrimSpace(conn.GRPCAddress) == "" {
|
||||||
|
conn.GRPCAddress = grpcaddr.Default(runtime.GOOS)
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(conn.BearerToken) == "" {
|
||||||
|
return nil, nil, nil, fmt.Errorf("browser bridge bearer token is required")
|
||||||
|
}
|
||||||
|
network, endpoint, err := grpcaddr.Parse(conn.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", strings.TrimSpace(conn.GRPCAddress), err)
|
||||||
|
}
|
||||||
|
ctx = metadata.AppendToOutgoingContext(ctx, "authorization", "Bearer "+strings.TrimSpace(conn.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,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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -42,12 +42,14 @@ build() {
|
|||||||
local app_version
|
local app_version
|
||||||
app_version="$(git describe --tags --always --dirty)"
|
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 ./cmd/keepassgo
|
||||||
|
go build -ldflags "-X git.julianfamily.org/keepassgo/internal/appui.appVersion=${app_version}" -o keepassgo-browser-bridge ./cmd/keepassgo-browser-bridge
|
||||||
}
|
}
|
||||||
|
|
||||||
package() {
|
package() {
|
||||||
cd "$(_repo_dir)"
|
cd "$(_repo_dir)"
|
||||||
|
|
||||||
install -Dm755 keepassgo "${pkgdir}/usr/bin/keepassgo"
|
install -Dm755 keepassgo "${pkgdir}/usr/bin/keepassgo"
|
||||||
|
install -Dm755 keepassgo-browser-bridge "${pkgdir}/usr/bin/keepassgo-browser-bridge"
|
||||||
install -Dm644 internal/assets/keepassgo-icon.png \
|
install -Dm644 internal/assets/keepassgo-icon.png \
|
||||||
"${pkgdir}/usr/share/icons/hicolor/512x512/apps/keepassgo.png"
|
"${pkgdir}/usr/share/icons/hicolor/512x512/apps/keepassgo.png"
|
||||||
install -Dm644 internal/assets/keepassgo-icon.svg \
|
install -Dm644 internal/assets/keepassgo-icon.svg \
|
||||||
|
|||||||
+575
-252
File diff suppressed because it is too large
Load Diff
@@ -11,6 +11,8 @@ service VaultService {
|
|||||||
rpc SaveVault(SaveVaultRequest) returns (SaveVaultResponse);
|
rpc SaveVault(SaveVaultRequest) returns (SaveVaultResponse);
|
||||||
rpc LockVault(LockVaultRequest) returns (LockVaultResponse);
|
rpc LockVault(LockVaultRequest) returns (LockVaultResponse);
|
||||||
rpc UnlockVault(UnlockVaultRequest) returns (UnlockVaultResponse);
|
rpc UnlockVault(UnlockVaultRequest) returns (UnlockVaultResponse);
|
||||||
|
rpc FindBrowserLogins(FindBrowserLoginsRequest) returns (FindBrowserLoginsResponse);
|
||||||
|
rpc GetBrowserCredential(GetBrowserCredentialRequest) returns (GetBrowserCredentialResponse);
|
||||||
rpc ListEntries(ListEntriesRequest) returns (ListEntriesResponse);
|
rpc ListEntries(ListEntriesRequest) returns (ListEntriesResponse);
|
||||||
rpc ListGroups(ListGroupsRequest) returns (ListGroupsResponse);
|
rpc ListGroups(ListGroupsRequest) returns (ListGroupsResponse);
|
||||||
rpc CreateGroup(CreateGroupRequest) returns (CreateGroupResponse);
|
rpc CreateGroup(CreateGroupRequest) returns (CreateGroupResponse);
|
||||||
@@ -75,6 +77,35 @@ message UnlockVaultRequest {
|
|||||||
|
|
||||||
message UnlockVaultResponse {}
|
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 {
|
message ListEntriesRequest {
|
||||||
repeated string path = 1;
|
repeated string path = 1;
|
||||||
string query = 2;
|
string query = 2;
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
|
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
|
||||||
// versions:
|
// versions:
|
||||||
// - protoc-gen-go-grpc v1.5.1
|
// - protoc-gen-go-grpc v1.5.1
|
||||||
// - protoc v6.33.1
|
// - protoc v7.34.1
|
||||||
// source: proto/keepassgo/v1/keepassgo.proto
|
// source: proto/keepassgo/v1/keepassgo.proto
|
||||||
|
|
||||||
package keepassgov1
|
package keepassgov1
|
||||||
@@ -19,32 +19,34 @@ import (
|
|||||||
const _ = grpc.SupportPackageIsVersion9
|
const _ = grpc.SupportPackageIsVersion9
|
||||||
|
|
||||||
const (
|
const (
|
||||||
VaultService_GetSessionStatus_FullMethodName = "/keepassgo.v1.VaultService/GetSessionStatus"
|
VaultService_GetSessionStatus_FullMethodName = "/keepassgo.v1.VaultService/GetSessionStatus"
|
||||||
VaultService_OpenVault_FullMethodName = "/keepassgo.v1.VaultService/OpenVault"
|
VaultService_OpenVault_FullMethodName = "/keepassgo.v1.VaultService/OpenVault"
|
||||||
VaultService_OpenRemoteVault_FullMethodName = "/keepassgo.v1.VaultService/OpenRemoteVault"
|
VaultService_OpenRemoteVault_FullMethodName = "/keepassgo.v1.VaultService/OpenRemoteVault"
|
||||||
VaultService_SaveVault_FullMethodName = "/keepassgo.v1.VaultService/SaveVault"
|
VaultService_SaveVault_FullMethodName = "/keepassgo.v1.VaultService/SaveVault"
|
||||||
VaultService_LockVault_FullMethodName = "/keepassgo.v1.VaultService/LockVault"
|
VaultService_LockVault_FullMethodName = "/keepassgo.v1.VaultService/LockVault"
|
||||||
VaultService_UnlockVault_FullMethodName = "/keepassgo.v1.VaultService/UnlockVault"
|
VaultService_UnlockVault_FullMethodName = "/keepassgo.v1.VaultService/UnlockVault"
|
||||||
VaultService_ListEntries_FullMethodName = "/keepassgo.v1.VaultService/ListEntries"
|
VaultService_FindBrowserLogins_FullMethodName = "/keepassgo.v1.VaultService/FindBrowserLogins"
|
||||||
VaultService_ListGroups_FullMethodName = "/keepassgo.v1.VaultService/ListGroups"
|
VaultService_GetBrowserCredential_FullMethodName = "/keepassgo.v1.VaultService/GetBrowserCredential"
|
||||||
VaultService_CreateGroup_FullMethodName = "/keepassgo.v1.VaultService/CreateGroup"
|
VaultService_ListEntries_FullMethodName = "/keepassgo.v1.VaultService/ListEntries"
|
||||||
VaultService_RenameGroup_FullMethodName = "/keepassgo.v1.VaultService/RenameGroup"
|
VaultService_ListGroups_FullMethodName = "/keepassgo.v1.VaultService/ListGroups"
|
||||||
VaultService_DeleteGroup_FullMethodName = "/keepassgo.v1.VaultService/DeleteGroup"
|
VaultService_CreateGroup_FullMethodName = "/keepassgo.v1.VaultService/CreateGroup"
|
||||||
VaultService_UpsertEntry_FullMethodName = "/keepassgo.v1.VaultService/UpsertEntry"
|
VaultService_RenameGroup_FullMethodName = "/keepassgo.v1.VaultService/RenameGroup"
|
||||||
VaultService_DeleteEntry_FullMethodName = "/keepassgo.v1.VaultService/DeleteEntry"
|
VaultService_DeleteGroup_FullMethodName = "/keepassgo.v1.VaultService/DeleteGroup"
|
||||||
VaultService_RestoreEntry_FullMethodName = "/keepassgo.v1.VaultService/RestoreEntry"
|
VaultService_UpsertEntry_FullMethodName = "/keepassgo.v1.VaultService/UpsertEntry"
|
||||||
VaultService_ListEntryHistory_FullMethodName = "/keepassgo.v1.VaultService/ListEntryHistory"
|
VaultService_DeleteEntry_FullMethodName = "/keepassgo.v1.VaultService/DeleteEntry"
|
||||||
VaultService_RestoreEntryHistory_FullMethodName = "/keepassgo.v1.VaultService/RestoreEntryHistory"
|
VaultService_RestoreEntry_FullMethodName = "/keepassgo.v1.VaultService/RestoreEntry"
|
||||||
VaultService_ListTemplates_FullMethodName = "/keepassgo.v1.VaultService/ListTemplates"
|
VaultService_ListEntryHistory_FullMethodName = "/keepassgo.v1.VaultService/ListEntryHistory"
|
||||||
VaultService_UpsertTemplate_FullMethodName = "/keepassgo.v1.VaultService/UpsertTemplate"
|
VaultService_RestoreEntryHistory_FullMethodName = "/keepassgo.v1.VaultService/RestoreEntryHistory"
|
||||||
VaultService_DeleteTemplate_FullMethodName = "/keepassgo.v1.VaultService/DeleteTemplate"
|
VaultService_ListTemplates_FullMethodName = "/keepassgo.v1.VaultService/ListTemplates"
|
||||||
VaultService_InstantiateTemplate_FullMethodName = "/keepassgo.v1.VaultService/InstantiateTemplate"
|
VaultService_UpsertTemplate_FullMethodName = "/keepassgo.v1.VaultService/UpsertTemplate"
|
||||||
VaultService_ListAttachments_FullMethodName = "/keepassgo.v1.VaultService/ListAttachments"
|
VaultService_DeleteTemplate_FullMethodName = "/keepassgo.v1.VaultService/DeleteTemplate"
|
||||||
VaultService_UploadAttachment_FullMethodName = "/keepassgo.v1.VaultService/UploadAttachment"
|
VaultService_InstantiateTemplate_FullMethodName = "/keepassgo.v1.VaultService/InstantiateTemplate"
|
||||||
VaultService_DownloadAttachment_FullMethodName = "/keepassgo.v1.VaultService/DownloadAttachment"
|
VaultService_ListAttachments_FullMethodName = "/keepassgo.v1.VaultService/ListAttachments"
|
||||||
VaultService_DeleteAttachment_FullMethodName = "/keepassgo.v1.VaultService/DeleteAttachment"
|
VaultService_UploadAttachment_FullMethodName = "/keepassgo.v1.VaultService/UploadAttachment"
|
||||||
VaultService_CopyEntryField_FullMethodName = "/keepassgo.v1.VaultService/CopyEntryField"
|
VaultService_DownloadAttachment_FullMethodName = "/keepassgo.v1.VaultService/DownloadAttachment"
|
||||||
VaultService_GeneratePassword_FullMethodName = "/keepassgo.v1.VaultService/GeneratePassword"
|
VaultService_DeleteAttachment_FullMethodName = "/keepassgo.v1.VaultService/DeleteAttachment"
|
||||||
|
VaultService_CopyEntryField_FullMethodName = "/keepassgo.v1.VaultService/CopyEntryField"
|
||||||
|
VaultService_GeneratePassword_FullMethodName = "/keepassgo.v1.VaultService/GeneratePassword"
|
||||||
)
|
)
|
||||||
|
|
||||||
// VaultServiceClient is the client API for VaultService service.
|
// VaultServiceClient is the client API for VaultService service.
|
||||||
@@ -57,6 +59,8 @@ type VaultServiceClient interface {
|
|||||||
SaveVault(ctx context.Context, in *SaveVaultRequest, opts ...grpc.CallOption) (*SaveVaultResponse, error)
|
SaveVault(ctx context.Context, in *SaveVaultRequest, opts ...grpc.CallOption) (*SaveVaultResponse, error)
|
||||||
LockVault(ctx context.Context, in *LockVaultRequest, opts ...grpc.CallOption) (*LockVaultResponse, error)
|
LockVault(ctx context.Context, in *LockVaultRequest, opts ...grpc.CallOption) (*LockVaultResponse, error)
|
||||||
UnlockVault(ctx context.Context, in *UnlockVaultRequest, opts ...grpc.CallOption) (*UnlockVaultResponse, 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)
|
ListEntries(ctx context.Context, in *ListEntriesRequest, opts ...grpc.CallOption) (*ListEntriesResponse, error)
|
||||||
ListGroups(ctx context.Context, in *ListGroupsRequest, opts ...grpc.CallOption) (*ListGroupsResponse, error)
|
ListGroups(ctx context.Context, in *ListGroupsRequest, opts ...grpc.CallOption) (*ListGroupsResponse, error)
|
||||||
CreateGroup(ctx context.Context, in *CreateGroupRequest, opts ...grpc.CallOption) (*CreateGroupResponse, 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
|
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) {
|
func (c *vaultServiceClient) ListEntries(ctx context.Context, in *ListEntriesRequest, opts ...grpc.CallOption) (*ListEntriesResponse, error) {
|
||||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||||
out := new(ListEntriesResponse)
|
out := new(ListEntriesResponse)
|
||||||
@@ -357,6 +381,8 @@ type VaultServiceServer interface {
|
|||||||
SaveVault(context.Context, *SaveVaultRequest) (*SaveVaultResponse, error)
|
SaveVault(context.Context, *SaveVaultRequest) (*SaveVaultResponse, error)
|
||||||
LockVault(context.Context, *LockVaultRequest) (*LockVaultResponse, error)
|
LockVault(context.Context, *LockVaultRequest) (*LockVaultResponse, error)
|
||||||
UnlockVault(context.Context, *UnlockVaultRequest) (*UnlockVaultResponse, 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)
|
ListEntries(context.Context, *ListEntriesRequest) (*ListEntriesResponse, error)
|
||||||
ListGroups(context.Context, *ListGroupsRequest) (*ListGroupsResponse, error)
|
ListGroups(context.Context, *ListGroupsRequest) (*ListGroupsResponse, error)
|
||||||
CreateGroup(context.Context, *CreateGroupRequest) (*CreateGroupResponse, 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) {
|
func (UnimplementedVaultServiceServer) UnlockVault(context.Context, *UnlockVaultRequest) (*UnlockVaultResponse, error) {
|
||||||
return nil, status.Errorf(codes.Unimplemented, "method UnlockVault not implemented")
|
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) {
|
func (UnimplementedVaultServiceServer) ListEntries(context.Context, *ListEntriesRequest) (*ListEntriesResponse, error) {
|
||||||
return nil, status.Errorf(codes.Unimplemented, "method ListEntries not implemented")
|
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)
|
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) {
|
func _VaultService_ListEntries_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||||
in := new(ListEntriesRequest)
|
in := new(ListEntriesRequest)
|
||||||
if err := dec(in); err != nil {
|
if err := dec(in); err != nil {
|
||||||
@@ -985,6 +1053,14 @@ var VaultService_ServiceDesc = grpc.ServiceDesc{
|
|||||||
MethodName: "UnlockVault",
|
MethodName: "UnlockVault",
|
||||||
Handler: _VaultService_UnlockVault_Handler,
|
Handler: _VaultService_UnlockVault_Handler,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
MethodName: "FindBrowserLogins",
|
||||||
|
Handler: _VaultService_FindBrowserLogins_Handler,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
MethodName: "GetBrowserCredential",
|
||||||
|
Handler: _VaultService_GetBrowserCredential_Handler,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
MethodName: "ListEntries",
|
MethodName: "ListEntries",
|
||||||
Handler: _VaultService_ListEntries_Handler,
|
Handler: _VaultService_ListEntries_Handler,
|
||||||
|
|||||||
Reference in New Issue
Block a user