Add browser extension gRPC bridge

This commit is contained in:
Joe Julian
2026-04-11 00:52:01 -07:00
parent 885d599db1
commit c017308aa1
23 changed files with 2437 additions and 280 deletions
+4 -1
View File
@@ -25,7 +25,7 @@ ifneq ($(strip $(SIGNPASS)),)
GOGIO_SIGN_FLAGS += -signpass $(SIGNPASS)
endif
.PHONY: apk archlinux-pkgbuild
.PHONY: apk archlinux-pkgbuild browser-bridge
apk: android/keepassgo-android.jar
@test -x "$(JAVA_HOME)/bin/java" || { echo "JAVA_HOME must point to a working JDK install"; exit 1; }
@test -d "$(ANDROID_SDK_ROOT)" || { echo "ANDROID_SDK_ROOT must point to an Android SDK install"; exit 1; }
@@ -68,3 +68,6 @@ archlinux-pkgbuild: $(ARCH_PKG_TMPL) Makefile
-e 's|@PKGVER@|$(ARCH_PKGVER)|g' \
-e 's|@REPO_DIR@|$(ARCH_REPO_DIR)|g' \
"$(ARCH_PKG_TMPL)" > "$(ARCH_PKGBUILD)"
browser-bridge:
go build ./cmd/keepassgo-browser-bridge
+6
View File
@@ -63,6 +63,7 @@ makepkg -si
The package installs:
- `/usr/bin/keepassgo`
- `/usr/bin/keepassgo-browser-bridge`
- a desktop entry at `/usr/share/applications/keepassgo.desktop`
- application icons under the hicolor theme
@@ -98,3 +99,8 @@ You will need the Android SDK and NDK installed and configured for real device o
Desktop automation is resolved through the secure gRPC API rather than synthetic auto-type.
See [`docs/desktop-automation.md`](./docs/desktop-automation.md).
## 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).
+11
View File
@@ -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.invalid`
- `manifest.chromium.json` is the Chromium/Chrome manifest template
- `background.js` talks to the native messaging host `org.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.
+195
View File
@@ -0,0 +1,195 @@
const ext = globalThis.browser ?? globalThis.chrome;
const nativeHost = "org.keepassgo.browser";
const defaultSettings = {
grpcAddress: "127.0.0.1:47777",
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;
});
+69
View File
@@ -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;
});
+26
View File
@@ -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"
}
]
}
+31
View File
@@ -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.invalid"
}
}
}
+34
View File
@@ -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="127.0.0.1:47777" 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>
+47
View File
@@ -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 || "127.0.0.1:47777";
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();
+29
View File
@@ -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>
+101
View File
@@ -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();
+174
View File
@@ -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;
}
+121
View File
@@ -0,0 +1,121 @@
package main
import (
"context"
"encoding/json"
"flag"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"git.julianfamily.org/keepassgo/internal/browserbridge"
)
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)")
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
}
installed, err := browserbridge.InstallManifest(browserbridge.Browser(strings.TrimSpace(*browserName)), path, strings.TrimSpace(*extensionID), 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", browserbridge.DefaultGRPCAddress, "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)
}
+76
View File
@@ -0,0 +1,76 @@
# 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.
## 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-id <your-extension-id>
```
Chrome and Chromium require the actual extension id in the native host manifest.
## 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. Set the KeePassGO gRPC address, usually `127.0.0.1:47777`.
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
+162
View File
@@ -3,7 +3,9 @@ package api
import (
"context"
"errors"
"fmt"
"maps"
"net/url"
"os"
"slices"
"strings"
@@ -225,6 +227,133 @@ func (s *Server) UnlockVault(ctx context.Context, req *keepassgov1.UnlockVaultRe
return &keepassgov1.UnlockVaultResponse{}, nil
}
func (s *Server) FindBrowserLogins(ctx context.Context, req *keepassgov1.FindBrowserLoginsRequest) (*keepassgov1.FindBrowserLoginsResponse, error) {
model, locked := s.snapshotModel()
if locked {
return nil, status.Error(codes.FailedPrecondition, "vault is locked")
}
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 {
switch {
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
}
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 {
out := model
out.Entries = nil
+78
View File
@@ -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) {
t.Parallel()
+321
View File
@@ -0,0 +1,321 @@
package browserbridge
import (
"context"
"encoding/binary"
"encoding/json"
"fmt"
"io"
"os"
"path/filepath"
"strings"
keepassgov1 "git.julianfamily.org/keepassgo/proto/keepassgo/v1"
)
const (
NativeHostName = "org.keepassgo.browser"
DefaultGRPCAddress = "127.0.0.1:47777"
defaultFirefoxID = "browser@keepassgo.invalid"
maxNativeMessageSize = 1024 * 1024
)
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 = DefaultGRPCAddress
}
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 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
}
+182
View File
@@ -0,0 +1,182 @@
package browserbridge
import (
"bytes"
"context"
"encoding/binary"
"encoding/json"
"os"
"path/filepath"
"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)
}
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 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)
}
}
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
}
+59
View File
@@ -0,0 +1,59 @@
package browserbridge
import (
"context"
"fmt"
"net"
"strings"
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 = DefaultGRPCAddress
}
if strings.TrimSpace(conn.BearerToken) == "" {
return nil, nil, nil, fmt.Errorf("browser bridge bearer token is required")
}
address := strings.TrimSpace(conn.GRPCAddress)
grpcConn, err := grpc.NewClient("passthrough:///"+address,
grpc.WithTransportCredentials(insecure.NewCredentials()),
grpc.WithContextDialer(func(context.Context, string) (net.Conn, error) {
return net.Dial("tcp", address)
}),
)
if err != nil {
return nil, nil, nil, fmt.Errorf("dial gRPC host %s: %w", address, 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),
})
}
@@ -42,12 +42,14 @@ build() {
local app_version
app_version="$(git describe --tags --always --dirty)"
go build -ldflags "-X git.julianfamily.org/keepassgo/internal/appui.appVersion=${app_version}" -o keepassgo ./cmd/keepassgo
go build -ldflags "-X git.julianfamily.org/keepassgo/internal/appui.appVersion=${app_version}" -o keepassgo-browser-bridge ./cmd/keepassgo-browser-bridge
}
package() {
cd "$(_repo_dir)"
install -Dm755 keepassgo "${pkgdir}/usr/bin/keepassgo"
install -Dm755 keepassgo-browser-bridge "${pkgdir}/usr/bin/keepassgo-browser-bridge"
install -Dm644 internal/assets/keepassgo-icon.png \
"${pkgdir}/usr/share/icons/hicolor/512x512/apps/keepassgo.png"
install -Dm644 internal/assets/keepassgo-icon.svg \
File diff suppressed because it is too large Load Diff
+31
View File
@@ -11,6 +11,8 @@ service VaultService {
rpc SaveVault(SaveVaultRequest) returns (SaveVaultResponse);
rpc LockVault(LockVaultRequest) returns (LockVaultResponse);
rpc UnlockVault(UnlockVaultRequest) returns (UnlockVaultResponse);
rpc FindBrowserLogins(FindBrowserLoginsRequest) returns (FindBrowserLoginsResponse);
rpc GetBrowserCredential(GetBrowserCredentialRequest) returns (GetBrowserCredentialResponse);
rpc ListEntries(ListEntriesRequest) returns (ListEntriesResponse);
rpc ListGroups(ListGroupsRequest) returns (ListGroupsResponse);
rpc CreateGroup(CreateGroupRequest) returns (CreateGroupResponse);
@@ -75,6 +77,35 @@ message UnlockVaultRequest {
message UnlockVaultResponse {}
message FindBrowserLoginsRequest {
string page_url = 1;
}
message BrowserLoginMatch {
string id = 1;
string title = 2;
string username = 3;
string url = 4;
repeated string path = 5;
string quality = 6;
}
message FindBrowserLoginsResponse {
repeated BrowserLoginMatch matches = 1;
}
message GetBrowserCredentialRequest {
string id = 1;
string page_url = 2;
}
message GetBrowserCredentialResponse {
string id = 1;
string username = 2;
string password = 3;
string url = 4;
}
message ListEntriesRequest {
repeated string path = 1;
string query = 2;
+103 -27
View File
@@ -1,7 +1,7 @@
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
// versions:
// - protoc-gen-go-grpc v1.5.1
// - protoc v6.33.1
// - protoc v7.34.1
// source: proto/keepassgo/v1/keepassgo.proto
package keepassgov1
@@ -19,32 +19,34 @@ import (
const _ = grpc.SupportPackageIsVersion9
const (
VaultService_GetSessionStatus_FullMethodName = "/keepassgo.v1.VaultService/GetSessionStatus"
VaultService_OpenVault_FullMethodName = "/keepassgo.v1.VaultService/OpenVault"
VaultService_OpenRemoteVault_FullMethodName = "/keepassgo.v1.VaultService/OpenRemoteVault"
VaultService_SaveVault_FullMethodName = "/keepassgo.v1.VaultService/SaveVault"
VaultService_LockVault_FullMethodName = "/keepassgo.v1.VaultService/LockVault"
VaultService_UnlockVault_FullMethodName = "/keepassgo.v1.VaultService/UnlockVault"
VaultService_ListEntries_FullMethodName = "/keepassgo.v1.VaultService/ListEntries"
VaultService_ListGroups_FullMethodName = "/keepassgo.v1.VaultService/ListGroups"
VaultService_CreateGroup_FullMethodName = "/keepassgo.v1.VaultService/CreateGroup"
VaultService_RenameGroup_FullMethodName = "/keepassgo.v1.VaultService/RenameGroup"
VaultService_DeleteGroup_FullMethodName = "/keepassgo.v1.VaultService/DeleteGroup"
VaultService_UpsertEntry_FullMethodName = "/keepassgo.v1.VaultService/UpsertEntry"
VaultService_DeleteEntry_FullMethodName = "/keepassgo.v1.VaultService/DeleteEntry"
VaultService_RestoreEntry_FullMethodName = "/keepassgo.v1.VaultService/RestoreEntry"
VaultService_ListEntryHistory_FullMethodName = "/keepassgo.v1.VaultService/ListEntryHistory"
VaultService_RestoreEntryHistory_FullMethodName = "/keepassgo.v1.VaultService/RestoreEntryHistory"
VaultService_ListTemplates_FullMethodName = "/keepassgo.v1.VaultService/ListTemplates"
VaultService_UpsertTemplate_FullMethodName = "/keepassgo.v1.VaultService/UpsertTemplate"
VaultService_DeleteTemplate_FullMethodName = "/keepassgo.v1.VaultService/DeleteTemplate"
VaultService_InstantiateTemplate_FullMethodName = "/keepassgo.v1.VaultService/InstantiateTemplate"
VaultService_ListAttachments_FullMethodName = "/keepassgo.v1.VaultService/ListAttachments"
VaultService_UploadAttachment_FullMethodName = "/keepassgo.v1.VaultService/UploadAttachment"
VaultService_DownloadAttachment_FullMethodName = "/keepassgo.v1.VaultService/DownloadAttachment"
VaultService_DeleteAttachment_FullMethodName = "/keepassgo.v1.VaultService/DeleteAttachment"
VaultService_CopyEntryField_FullMethodName = "/keepassgo.v1.VaultService/CopyEntryField"
VaultService_GeneratePassword_FullMethodName = "/keepassgo.v1.VaultService/GeneratePassword"
VaultService_GetSessionStatus_FullMethodName = "/keepassgo.v1.VaultService/GetSessionStatus"
VaultService_OpenVault_FullMethodName = "/keepassgo.v1.VaultService/OpenVault"
VaultService_OpenRemoteVault_FullMethodName = "/keepassgo.v1.VaultService/OpenRemoteVault"
VaultService_SaveVault_FullMethodName = "/keepassgo.v1.VaultService/SaveVault"
VaultService_LockVault_FullMethodName = "/keepassgo.v1.VaultService/LockVault"
VaultService_UnlockVault_FullMethodName = "/keepassgo.v1.VaultService/UnlockVault"
VaultService_FindBrowserLogins_FullMethodName = "/keepassgo.v1.VaultService/FindBrowserLogins"
VaultService_GetBrowserCredential_FullMethodName = "/keepassgo.v1.VaultService/GetBrowserCredential"
VaultService_ListEntries_FullMethodName = "/keepassgo.v1.VaultService/ListEntries"
VaultService_ListGroups_FullMethodName = "/keepassgo.v1.VaultService/ListGroups"
VaultService_CreateGroup_FullMethodName = "/keepassgo.v1.VaultService/CreateGroup"
VaultService_RenameGroup_FullMethodName = "/keepassgo.v1.VaultService/RenameGroup"
VaultService_DeleteGroup_FullMethodName = "/keepassgo.v1.VaultService/DeleteGroup"
VaultService_UpsertEntry_FullMethodName = "/keepassgo.v1.VaultService/UpsertEntry"
VaultService_DeleteEntry_FullMethodName = "/keepassgo.v1.VaultService/DeleteEntry"
VaultService_RestoreEntry_FullMethodName = "/keepassgo.v1.VaultService/RestoreEntry"
VaultService_ListEntryHistory_FullMethodName = "/keepassgo.v1.VaultService/ListEntryHistory"
VaultService_RestoreEntryHistory_FullMethodName = "/keepassgo.v1.VaultService/RestoreEntryHistory"
VaultService_ListTemplates_FullMethodName = "/keepassgo.v1.VaultService/ListTemplates"
VaultService_UpsertTemplate_FullMethodName = "/keepassgo.v1.VaultService/UpsertTemplate"
VaultService_DeleteTemplate_FullMethodName = "/keepassgo.v1.VaultService/DeleteTemplate"
VaultService_InstantiateTemplate_FullMethodName = "/keepassgo.v1.VaultService/InstantiateTemplate"
VaultService_ListAttachments_FullMethodName = "/keepassgo.v1.VaultService/ListAttachments"
VaultService_UploadAttachment_FullMethodName = "/keepassgo.v1.VaultService/UploadAttachment"
VaultService_DownloadAttachment_FullMethodName = "/keepassgo.v1.VaultService/DownloadAttachment"
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.
@@ -57,6 +59,8 @@ type VaultServiceClient interface {
SaveVault(ctx context.Context, in *SaveVaultRequest, opts ...grpc.CallOption) (*SaveVaultResponse, error)
LockVault(ctx context.Context, in *LockVaultRequest, opts ...grpc.CallOption) (*LockVaultResponse, error)
UnlockVault(ctx context.Context, in *UnlockVaultRequest, opts ...grpc.CallOption) (*UnlockVaultResponse, error)
FindBrowserLogins(ctx context.Context, in *FindBrowserLoginsRequest, opts ...grpc.CallOption) (*FindBrowserLoginsResponse, error)
GetBrowserCredential(ctx context.Context, in *GetBrowserCredentialRequest, opts ...grpc.CallOption) (*GetBrowserCredentialResponse, error)
ListEntries(ctx context.Context, in *ListEntriesRequest, opts ...grpc.CallOption) (*ListEntriesResponse, error)
ListGroups(ctx context.Context, in *ListGroupsRequest, opts ...grpc.CallOption) (*ListGroupsResponse, error)
CreateGroup(ctx context.Context, in *CreateGroupRequest, opts ...grpc.CallOption) (*CreateGroupResponse, error)
@@ -147,6 +151,26 @@ func (c *vaultServiceClient) UnlockVault(ctx context.Context, in *UnlockVaultReq
return out, nil
}
func (c *vaultServiceClient) FindBrowserLogins(ctx context.Context, in *FindBrowserLoginsRequest, opts ...grpc.CallOption) (*FindBrowserLoginsResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(FindBrowserLoginsResponse)
err := c.cc.Invoke(ctx, VaultService_FindBrowserLogins_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *vaultServiceClient) GetBrowserCredential(ctx context.Context, in *GetBrowserCredentialRequest, opts ...grpc.CallOption) (*GetBrowserCredentialResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(GetBrowserCredentialResponse)
err := c.cc.Invoke(ctx, VaultService_GetBrowserCredential_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *vaultServiceClient) ListEntries(ctx context.Context, in *ListEntriesRequest, opts ...grpc.CallOption) (*ListEntriesResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(ListEntriesResponse)
@@ -357,6 +381,8 @@ type VaultServiceServer interface {
SaveVault(context.Context, *SaveVaultRequest) (*SaveVaultResponse, error)
LockVault(context.Context, *LockVaultRequest) (*LockVaultResponse, error)
UnlockVault(context.Context, *UnlockVaultRequest) (*UnlockVaultResponse, error)
FindBrowserLogins(context.Context, *FindBrowserLoginsRequest) (*FindBrowserLoginsResponse, error)
GetBrowserCredential(context.Context, *GetBrowserCredentialRequest) (*GetBrowserCredentialResponse, error)
ListEntries(context.Context, *ListEntriesRequest) (*ListEntriesResponse, error)
ListGroups(context.Context, *ListGroupsRequest) (*ListGroupsResponse, error)
CreateGroup(context.Context, *CreateGroupRequest) (*CreateGroupResponse, error)
@@ -405,6 +431,12 @@ func (UnimplementedVaultServiceServer) LockVault(context.Context, *LockVaultRequ
func (UnimplementedVaultServiceServer) UnlockVault(context.Context, *UnlockVaultRequest) (*UnlockVaultResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method UnlockVault not implemented")
}
func (UnimplementedVaultServiceServer) FindBrowserLogins(context.Context, *FindBrowserLoginsRequest) (*FindBrowserLoginsResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method FindBrowserLogins not implemented")
}
func (UnimplementedVaultServiceServer) GetBrowserCredential(context.Context, *GetBrowserCredentialRequest) (*GetBrowserCredentialResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method GetBrowserCredential not implemented")
}
func (UnimplementedVaultServiceServer) ListEntries(context.Context, *ListEntriesRequest) (*ListEntriesResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method ListEntries not implemented")
}
@@ -594,6 +626,42 @@ func _VaultService_UnlockVault_Handler(srv interface{}, ctx context.Context, dec
return interceptor(ctx, in, info, handler)
}
func _VaultService_FindBrowserLogins_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(FindBrowserLoginsRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(VaultServiceServer).FindBrowserLogins(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: VaultService_FindBrowserLogins_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(VaultServiceServer).FindBrowserLogins(ctx, req.(*FindBrowserLoginsRequest))
}
return interceptor(ctx, in, info, handler)
}
func _VaultService_GetBrowserCredential_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(GetBrowserCredentialRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(VaultServiceServer).GetBrowserCredential(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: VaultService_GetBrowserCredential_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(VaultServiceServer).GetBrowserCredential(ctx, req.(*GetBrowserCredentialRequest))
}
return interceptor(ctx, in, info, handler)
}
func _VaultService_ListEntries_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(ListEntriesRequest)
if err := dec(in); err != nil {
@@ -985,6 +1053,14 @@ var VaultService_ServiceDesc = grpc.ServiceDesc{
MethodName: "UnlockVault",
Handler: _VaultService_UnlockVault_Handler,
},
{
MethodName: "FindBrowserLogins",
Handler: _VaultService_FindBrowserLogins_Handler,
},
{
MethodName: "GetBrowserCredential",
Handler: _VaultService_GetBrowserCredential_Handler,
},
{
MethodName: "ListEntries",
Handler: _VaultService_ListEntries_Handler,