diff --git a/browser/extension/background.js b/browser/extension/background.js
index a0840ac..4ff07ad 100644
--- a/browser/extension/background.js
+++ b/browser/extension/background.js
@@ -191,6 +191,96 @@ function cloneTarget(target) {
return target && typeof target === "object" ? { ...target } : null;
}
+function cloneSavePlan(plan) {
+ if (!plan || typeof plan !== "object") {
+ return null;
+ }
+ return {
+ mode: plan.mode === "update" ? "update" : "save",
+ entryId: typeof plan.entryId === "string" ? plan.entryId : "",
+ title: typeof plan.title === "string" ? plan.title : "",
+ path: Array.isArray(plan.path) ? [...plan.path] : [],
+ username: typeof plan.username === "string" ? plan.username : "",
+ password: typeof plan.password === "string" ? plan.password : "",
+ url: typeof plan.url === "string" ? plan.url : ""
+ };
+}
+
+function normalizeObservedCredential(observed) {
+ if (!observed || typeof observed !== "object") {
+ return null;
+ }
+ const password = typeof observed.password === "string" ? observed.password.trim() : "";
+ const url = typeof observed.url === "string" ? observed.url.trim() : "";
+ if (!password || !url) {
+ return null;
+ }
+ return {
+ title: typeof observed.title === "string" ? observed.title.trim() : "",
+ username: typeof observed.username === "string" ? observed.username.trim() : "",
+ password,
+ url
+ };
+}
+
+function matchHost(rawURL) {
+ if (typeof rawURL !== "string") {
+ return "";
+ }
+ const trimmed = rawURL.trim();
+ if (!trimmed) {
+ return "";
+ }
+ try {
+ return new URL(trimmed).hostname.toLowerCase();
+ } catch (_error) {
+ return trimmed.replace(/^https?:\/\//i, "").replace(/\/.*$/, "").toLowerCase();
+ }
+}
+
+function defaultObservedTitle(observed) {
+ if (observed?.title) {
+ return observed.title;
+ }
+ return matchHost(observed?.url) || "Browser Login";
+}
+
+function savePlanForObservedLogin(observed, matches) {
+ const normalized = normalizeObservedCredential(observed);
+ if (!normalized) {
+ return null;
+ }
+ const targetHost = matchHost(normalized.url);
+ const exact = Array.isArray(matches) ? matches.find((match) =>
+ typeof match?.id === "string" &&
+ String(match?.username || "").trim().toLowerCase() === normalized.username.toLowerCase() &&
+ matchHost(match?.url || "") === targetHost
+ ) : null;
+ if (exact) {
+ return {
+ mode: "update",
+ entryId: exact.id,
+ title: exact.title || defaultObservedTitle(normalized),
+ path: Array.isArray(exact.path) ? [...exact.path] : [],
+ username: normalized.username,
+ password: normalized.password,
+ url: normalized.url
+ };
+ }
+ const fallbackPath = Array.isArray(matches) && matches.length > 0 && Array.isArray(matches[0]?.path)
+ ? [...matches[0].path]
+ : [];
+ return {
+ mode: "save",
+ entryId: "",
+ title: defaultObservedTitle(normalized),
+ path: fallbackPath,
+ username: normalized.username,
+ password: normalized.password,
+ url: normalized.url
+ };
+}
+
function normalizePageState(state) {
return {
tabId: Number.isInteger(state?.tabId) ? state.tabId : null,
@@ -207,6 +297,7 @@ function normalizePageState(state) {
pendingEntryId: typeof state?.pendingEntryId === "string" ? state.pendingEntryId : "",
pendingTarget: cloneTarget(state?.pendingTarget),
pendingMessage: typeof state?.pendingMessage === "string" ? state.pendingMessage : "",
+ pendingSave: cloneSavePlan(state?.pendingSave),
lastFilledEntryId: typeof state?.lastFilledEntryId === "string" ? state.lastFilledEntryId : "",
updatedAt: Number.isFinite(state?.updatedAt) ? state.updatedAt : 0
};
@@ -228,6 +319,7 @@ function defaultPageState(tabId, pageUrl) {
pendingEntryId: "",
pendingTarget: null,
pendingMessage: "",
+ pendingSave: null,
lastFilledEntryId: "",
updatedAt: 0
});
@@ -347,6 +439,12 @@ function actionPresentationForState(state) {
badgeText = "!";
color = "#9f5f0e";
title = approvalHintForState(state) || "KeePassGO approval needed for this page";
+ } else if (state.pendingSave) {
+ badgeText = "S";
+ color = "#255f4a";
+ title = state.pendingSave.mode === "update"
+ ? `KeePassGO can update ${state.pendingSave.title || "this login"}`
+ : "KeePassGO can save the submitted login";
} else if (!state.configured) {
title = "Configure KeePassGO Browser in extension settings";
} else if (!state.success) {
@@ -663,12 +761,64 @@ async function refreshActivePage(options = {}) {
return refreshPageState(page.tabId, page.url, options);
}
+async function saveObservedLogin(tabId, selectedMatch = null) {
+ if (!Number.isInteger(tabId)) {
+ throw new Error("No active tab is available.");
+ }
+ const tab = await tabsGet(tabId);
+ const pageUrl = typeof tab?.url === "string" ? tab.url : "";
+ let state = await getPageState(tabId, pageUrl);
+ const pendingSave = cloneSavePlan(state.pendingSave);
+ if (!pendingSave) {
+ throw new Error("There is no pending login to save.");
+ }
+ const request = {
+ action: "save-login",
+ title: pendingSave.title,
+ username: pendingSave.username,
+ password: pendingSave.password,
+ url: pendingSave.url
+ };
+ if (selectedMatch && typeof selectedMatch === "object") {
+ if (pendingSave.mode === "update" && typeof selectedMatch.id === "string" && selectedMatch.id.trim()) {
+ request.entryId = selectedMatch.id.trim();
+ request.title = String(selectedMatch.title || pendingSave.title || "").trim();
+ } else if (Array.isArray(selectedMatch.path) && selectedMatch.path.length > 0) {
+ request.path = [...selectedMatch.path];
+ }
+ } else if (pendingSave.mode === "update" && pendingSave.entryId) {
+ request.entryId = pendingSave.entryId;
+ } else if (pendingSave.path.length > 0) {
+ request.path = [...pendingSave.path];
+ }
+ const settings = await loadSettings();
+ if (!settings.bearerToken) {
+ throw new Error("API token is not configured.");
+ }
+ const response = await connectNative({
+ ...request,
+ bearerToken: settings.bearerToken
+ });
+ if (!response?.success) {
+ throw new Error(response?.error || "KeePassGO did not save the submitted login.");
+ }
+ state = await setPageState(tabId, {
+ ...state,
+ pendingSave: null,
+ error: "",
+ updatedAt: Date.now()
+ });
+ await refreshPageState(tabId, pageUrl, { force: true });
+ return { state };
+}
+
const backgroundTestExports = {
normalizePageState,
actionPresentationForState,
shouldReuseMatches,
shouldContinueWatchingState,
tokenPendingApprovalCount,
+ savePlanForObservedLogin,
defaultSettings
};
@@ -723,6 +873,33 @@ if (isNodeTestEnv) {
});
return;
}
+ case "keepassgo-observed-login":
+ if (Number.isInteger(sender?.tab?.id)) {
+ const targetState = await getPageState(sender.tab.id, sender.tab.url || "");
+ const nextSave = savePlanForObservedLogin(message.observed, targetState.matches);
+ sendResponse(await setPageState(sender.tab.id, {
+ ...targetState,
+ pendingSave: nextSave,
+ updatedAt: Date.now()
+ }));
+ return;
+ }
+ sendResponse({ success: false, error: "No active tab is available." });
+ return;
+ case "keepassgo-save-login": {
+ const targetTabID = Number.isInteger(message?.tabId)
+ ? message.tabId
+ : (Number.isInteger(sender?.tab?.id) ? sender.tab.id : (await activePageContext()).tabId);
+ const selectedMatch = message?.selectedMatch && typeof message.selectedMatch === "object"
+ ? {
+ id: String(message.selectedMatch.id || "").trim(),
+ title: String(message.selectedMatch.title || "").trim(),
+ path: Array.isArray(message.selectedMatch.path) ? message.selectedMatch.path : []
+ }
+ : null;
+ sendResponse({ success: true, ...(await saveObservedLogin(targetTabID, selectedMatch)) });
+ return;
+ }
case "keepassgo-page-ready":
if (Number.isInteger(sender?.tab?.id)) {
sendResponse(await refreshPageState(sender.tab.id, sender.tab.url, {
diff --git a/browser/extension/background.test.cjs b/browser/extension/background.test.cjs
index 77db9a7..b030502 100644
--- a/browser/extension/background.test.cjs
+++ b/browser/extension/background.test.cjs
@@ -70,3 +70,62 @@ test("shouldContinueWatchingState keeps polling locked login pages", () => {
test("default settings include a blank bearer token that can be overridden by harness patching", () => {
assert.equal(background.defaultSettings.bearerToken, "");
});
+
+test("savePlanForObservedLogin prefers updating an exact username match", () => {
+ const plan = background.savePlanForObservedLogin({
+ username: "dannyocean",
+ password: "bellagio-safe",
+ url: "https://vault.example.invalid/login"
+ }, [
+ {
+ id: "vault-console",
+ title: "Vault Console",
+ username: "dannyocean",
+ url: "vault.example.invalid",
+ path: ["Crew", "Internet"]
+ },
+ {
+ id: "bellagio-backup",
+ title: "Bellagio Backup",
+ username: "rustyryan",
+ url: "vault.example.invalid",
+ path: ["Crew", "Internet"]
+ }
+ ]);
+
+ assert.deepEqual(plan, {
+ mode: "update",
+ entryId: "vault-console",
+ title: "Vault Console",
+ path: ["Crew", "Internet"],
+ username: "dannyocean",
+ password: "bellagio-safe",
+ url: "https://vault.example.invalid/login"
+ });
+});
+
+test("savePlanForObservedLogin falls back to saving into the current page group", () => {
+ const plan = background.savePlanForObservedLogin({
+ username: "linuscaldwell",
+ password: "yellow-chip",
+ url: "https://vault.example.invalid/login"
+ }, [
+ {
+ id: "vault-console",
+ title: "Vault Console",
+ username: "dannyocean",
+ url: "vault.example.invalid",
+ path: ["Crew", "Internet"]
+ }
+ ]);
+
+ assert.deepEqual(plan, {
+ mode: "save",
+ entryId: "",
+ title: "vault.example.invalid",
+ path: ["Crew", "Internet"],
+ username: "linuscaldwell",
+ password: "yellow-chip",
+ url: "https://vault.example.invalid/login"
+ });
+});
diff --git a/browser/extension/content.js b/browser/extension/content.js
index 5b87599..cf3b936 100644
--- a/browser/extension/content.js
+++ b/browser/extension/content.js
@@ -396,6 +396,22 @@ function fillCredential(credential, targetDescriptor) {
return { ok: true };
}
+function submittedCredential(candidate, rawURL) {
+ if (!candidate?.passwordInput) {
+ return null;
+ }
+ const password = String(candidate.passwordInput.value || "").trim();
+ if (!password) {
+ return null;
+ }
+ return {
+ title: domainLabel(rawURL),
+ username: String(candidate.usernameInput?.value || "").trim(),
+ password,
+ url: String(rawURL || "").trim()
+ };
+}
+
function domainLabel(rawURL) {
try {
return new URL(rawURL).host || "";
@@ -447,7 +463,8 @@ const contentTestExports = {
fieldHintText,
scopeHintText,
hasAuthFlowSignals,
- authFlowCandidate
+ authFlowCandidate,
+ submittedCredential
};
if (isNodeTestEnv) {
@@ -805,6 +822,23 @@ if (isNodeTestEnv) {
scheduleRefresh(false);
}, true);
+ document.addEventListener("submit", (event) => {
+ const form = event.target instanceof HTMLFormElement ? event.target : null;
+ if (!form) {
+ return;
+ }
+ const passwordInput = visibleInputs(form).find(isPasswordCandidate) || null;
+ const candidate = passwordInput ? authFlowCandidate(passwordInput) : null;
+ const observed = submittedCredential(candidate, window.location.href);
+ if (!observed) {
+ return;
+ }
+ void runtimeSend({
+ type: "keepassgo-observed-login",
+ observed
+ }).catch(() => null);
+ }, true);
+
document.addEventListener("click", (event) => {
if (!root.contains(event.target)) {
chooserOpen = false;
diff --git a/browser/extension/content.test.cjs b/browser/extension/content.test.cjs
index e18d13c..1e0166a 100644
--- a/browser/extension/content.test.cjs
+++ b/browser/extension/content.test.cjs
@@ -120,3 +120,17 @@ test("shouldShowInlineOverlay hides the page overlay after idle expiry", () => {
assert.equal(content.shouldShowInlineOverlay(state, true, false, false), true);
assert.equal(content.shouldShowInlineOverlay(state, true, false, true), false);
});
+
+test("submittedCredential captures the pending browser save payload from a login candidate", () => {
+ const candidate = {
+ usernameInput: { value: "linuscaldwell" },
+ passwordInput: { value: "yellow-chip" }
+ };
+
+ assert.deepEqual(content.submittedCredential(candidate, "https://bellagio.example.invalid/login"), {
+ title: "bellagio.example.invalid",
+ username: "linuscaldwell",
+ password: "yellow-chip",
+ url: "https://bellagio.example.invalid/login"
+ });
+});
diff --git a/browser/extension/popup.html b/browser/extension/popup.html
index ab28393..c8401f8 100644
--- a/browser/extension/popup.html
+++ b/browser/extension/popup.html
@@ -20,6 +20,13 @@
Checking KeePassGO.
Loading page state.
+
+
+
Save Submitted Login
+
KeePassGO can save this login.
+
+
+
Matches
diff --git a/browser/extension/popup.js b/browser/extension/popup.js
index 4c56f4b..de456e3 100644
--- a/browser/extension/popup.js
+++ b/browser/extension/popup.js
@@ -43,6 +43,12 @@ function matchSubtitle(match) {
return parts.join(" ยท ") || "No username";
}
+function saveCardLabel(pendingSave) {
+ return pendingSave?.mode === "update"
+ ? `Update ${pendingSave.title || "Login"}`
+ : "Save Login";
+}
+
function renderMatchList(root, matches, options = {}) {
const targetTabID = popupTabID();
const emptyMessage = options.emptyMessage || "No matching entries.";
@@ -75,19 +81,23 @@ function renderMatchList(root, matches, options = {}) {
row.appendChild(quality);
row.addEventListener("click", async () => {
row.disabled = true;
- setStatus("Approval may be required", "KeePassGO will prompt if this token needs approval before fill.", "warning");
try {
- const result = await runtimeSend({
- type: "keepassgo-fill-entry",
- entryId: match.id,
- tabId: targetTabID
- });
- if (!result?.success) {
- throw new Error(result?.error || "Fill failed.");
+ if (typeof options.onSelect === "function") {
+ await options.onSelect(match, targetTabID);
+ } else {
+ setStatus("Approval may be required", "KeePassGO will prompt if this token needs approval before fill.", "warning");
+ const result = await runtimeSend({
+ type: "keepassgo-fill-entry",
+ entryId: match.id,
+ tabId: targetTabID
+ });
+ if (!result?.success) {
+ throw new Error(result?.error || "Fill failed.");
+ }
+ setStatus("Filled", `${match.title} was sent to the current page.`, "ready");
}
- setStatus("Filled", `${match.title} was sent to the current page.`, "ready");
} catch (error) {
- setStatus("Fill failed", error instanceof Error ? error.message : String(error), "error");
+ setStatus(options.onSelect ? "Save failed" : "Fill failed", error instanceof Error ? error.message : String(error), "error");
} finally {
row.disabled = false;
}
@@ -100,7 +110,30 @@ function renderMatches(state) {
const emptyMessage = state.pageHasLoginForm
? "No matching entries for this page."
: "No login fields detected on this page.";
- renderMatchList(document.getElementById("matches"), state.matches, { emptyMessage });
+ const root = document.getElementById("matches");
+ if (state.pendingSave) {
+ renderMatchList(root, state.matches, {
+ emptyMessage,
+ onSelect: async (match, targetTabID) => {
+ const result = await runtimeSend({
+ type: "keepassgo-save-login",
+ tabId: targetTabID,
+ selectedMatch: {
+ id: match.id,
+ title: match.title,
+ path: Array.isArray(match.path) ? match.path : []
+ }
+ });
+ if (!result?.success) {
+ throw new Error(result?.error || "Save failed.");
+ }
+ setStatus("Saved", `${state.pendingSave.title || "Login"} is now in KeePassGO.`, "ready");
+ document.getElementById("save-card").hidden = true;
+ }
+ });
+ return;
+ }
+ renderMatchList(root, state.matches, { emptyMessage });
}
function renderSearchResults(results, query) {
@@ -135,6 +168,46 @@ function renderPageHint(state) {
hint.textContent = "Open a sign-in page to see KeePassGO suggestions here.";
}
+function renderPendingSave(state) {
+ const card = document.getElementById("save-card");
+ const message = document.getElementById("save-message");
+ const action = document.getElementById("save-action");
+ const pendingSave = state.pendingSave;
+ if (!pendingSave) {
+ card.hidden = true;
+ action.onclick = null;
+ return;
+ }
+ card.hidden = false;
+ action.textContent = saveCardLabel(pendingSave);
+ if (pendingSave.mode === "update") {
+ message.textContent = `KeePassGO can update ${pendingSave.title || "this login"} with the submitted password.`;
+ } else if (Array.isArray(pendingSave.path) && pendingSave.path.length > 0) {
+ message.textContent = `KeePassGO can save this login in ${pendingSave.path.join(" / ")}. Search the vault to choose a different group if needed.`;
+ } else {
+ message.textContent = "Search the vault below to choose a group for this submitted login.";
+ }
+ action.disabled = pendingSave.mode !== "update" && (!Array.isArray(pendingSave.path) || pendingSave.path.length === 0);
+ action.onclick = async () => {
+ action.disabled = true;
+ try {
+ const result = await runtimeSend({
+ type: "keepassgo-save-login",
+ tabId: popupTabID()
+ });
+ if (!result?.success) {
+ throw new Error(result?.error || "Save failed.");
+ }
+ setStatus("Saved", `${pendingSave.title || "Login"} is now in KeePassGO.`, "ready");
+ card.hidden = true;
+ } catch (error) {
+ setStatus("Save failed", error instanceof Error ? error.message : String(error), "error");
+ } finally {
+ action.disabled = false;
+ }
+ };
+}
+
function popupTabID() {
const rawValue = new URLSearchParams(window.location.search).get("tabId");
if (rawValue === null) {
@@ -183,6 +256,7 @@ async function main() {
});
document.getElementById("page-host").textContent = hostFromURL(state.pageUrl || "");
renderPageHint(state);
+ renderPendingSave(state);
if (!state.configured) {
setStatus("Configure access", state.error || "Set the API token in extension settings.", "warning");
@@ -208,6 +282,8 @@ async function main() {
const count = Array.isArray(state.matches) ? state.matches.length : 0;
if (!state.pageHasLoginForm) {
setStatus("Ready", "KeePassGO is connected. Open a login form to check for matches.", "ready");
+ } else if (state.pendingSave) {
+ setStatus("Save submitted login", state.pendingSave.mode === "update" ? `Update ${state.pendingSave.title || "this login"} or pick a different target below.` : "Save this submitted login or search below to choose a target entry.", "ready");
} else if (count === 0) {
setStatus("Checked this page", "KeePassGO did not find a matching login for this form.", "ready");
} else {
diff --git a/browser/extension/style.css b/browser/extension/style.css
index 5c3cc25..584d46a 100644
--- a/browser/extension/style.css
+++ b/browser/extension/style.css
@@ -100,6 +100,18 @@ h2 {
margin-top: 16px;
}
+.save-card {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 12px;
+ padding: 12px 14px;
+ margin: 0 0 16px;
+ border: 1px solid #c5dccf;
+ border-radius: 12px;
+ background: var(--accent-soft);
+}
+
.search-form {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
diff --git a/docs/browser-extension.md b/docs/browser-extension.md
index 2e47f7c..0c5c9c9 100644
--- a/docs/browser-extension.md
+++ b/docs/browser-extension.md
@@ -134,6 +134,31 @@ Expected behavior:
and turns the locked affordance back into live matches without requiring a
page reload.
+## Save And Update Workflow
+
+User story:
+
+- After the user submits a login form, the browser extension should help store
+ that credential instead of forcing the user back into KeePassGO manually.
+- If KeePassGO already has a matching entry for that site and username, the
+ popup should offer an update.
+- If the user is creating a new login, the popup should let the user save it
+ into a relevant vault group without leaving the browser.
+
+Expected behavior:
+
+- Submitted login forms queue a pending browser save/update state for the
+ active tab.
+- The popup shows that pending save/update state prominently instead of hiding
+ it behind page matches alone.
+- When KeePassGO finds an exact browser match for the submitted username and
+ site, the popup offers an `Update` action for that entry.
+- When there is no exact entry match, the popup offers a `Save` action using a
+ relevant group path from the current page matches or a user-selected search
+ result.
+- The browser save/update action writes through KeePassGO's existing secure
+ gRPC mutation API and stays scoped to the browser token's allowed groups.
+
For extension-side regression checks, run:
```bash
diff --git a/internal/browserbridge/bridge.go b/internal/browserbridge/bridge.go
index 410743a..a1a5798 100644
--- a/internal/browserbridge/bridge.go
+++ b/internal/browserbridge/bridge.go
@@ -2,12 +2,15 @@ package browserbridge
import (
"context"
+ "crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/binary"
+ "encoding/hex"
"encoding/json"
"fmt"
"io"
+ "net/url"
"os"
"path/filepath"
"runtime"
@@ -28,11 +31,15 @@ const (
)
type Request struct {
- Action string `json:"action"`
- BearerToken string `json:"bearerToken,omitempty"`
- URL string `json:"url,omitempty"`
- EntryID string `json:"entryId,omitempty"`
- Query string `json:"query,omitempty"`
+ Action string `json:"action"`
+ BearerToken string `json:"bearerToken,omitempty"`
+ URL string `json:"url,omitempty"`
+ EntryID string `json:"entryId,omitempty"`
+ Query string `json:"query,omitempty"`
+ Title string `json:"title,omitempty"`
+ Username string `json:"username,omitempty"`
+ Password string `json:"password,omitempty"`
+ Path []string `json:"path,omitempty"`
}
type Response struct {
@@ -81,10 +88,13 @@ type Client interface {
FindBrowserLogins(context.Context, string) ([]*keepassgov1.BrowserLoginMatch, error)
ListEntries(context.Context, []string, string) ([]*keepassgov1.Entry, error)
GetBrowserCredential(context.Context, string, string) (*keepassgov1.GetBrowserCredentialResponse, error)
+ UpsertEntry(context.Context, *keepassgov1.Entry) (*keepassgov1.Entry, error)
}
type Browser string
+type actionHandler func(context.Context, Client, Request, string) Response
+
const (
BrowserFirefox Browser = "firefox"
BrowserChrome Browser = "chrome"
@@ -166,43 +176,70 @@ func HandleRequest(ctx context.Context, req Request, grpcAddr string, client Cli
return Response{Success: false, Error: err.Error()}
}
action := strings.TrimSpace(req.Action)
- switch action {
- case "status":
- status, err := statusResponse(ctx, client, conn.GRPCAddress)
- if err != nil {
- return Response{Success: false, Error: err.Error(), Status: disconnectedStatus(conn.GRPCAddress)}
- }
- return Response{Success: true, Status: status, Version: responseVersion}
- case "find-logins":
- matches, err := findMatches(ctx, client, req.URL)
- if err != nil {
- if status := inferredActionStatus(conn.GRPCAddress, err); status != nil {
- return Response{Success: true, Status: status, Matches: nil, Version: responseVersion}
- }
- return Response{Success: false, Error: err.Error(), Status: disconnectedStatus(conn.GRPCAddress)}
- }
- return Response{Success: true, Status: availableStatus(conn.GRPCAddress), Matches: matches, Version: responseVersion}
- case "search-logins":
- results, err := searchEntries(ctx, client, req.Query)
- if err != nil {
- if status := inferredActionStatus(conn.GRPCAddress, err); status != nil {
- return Response{Success: true, Status: status, SearchResults: nil, Version: responseVersion}
- }
- return Response{Success: false, Error: err.Error(), Status: disconnectedStatus(conn.GRPCAddress)}
- }
- return Response{Success: true, Status: availableStatus(conn.GRPCAddress), SearchResults: results, Version: responseVersion}
- case "get-login":
- credential, err := loadCredential(ctx, client, req.EntryID, req.URL)
- if err != nil {
- if status := inferredActionStatus(conn.GRPCAddress, err); status != nil {
- return Response{Success: false, Error: err.Error(), Status: status}
- }
- return Response{Success: false, Error: err.Error(), Status: disconnectedStatus(conn.GRPCAddress)}
- }
- return Response{Success: true, Status: availableStatus(conn.GRPCAddress), Credential: credential, Version: responseVersion}
- default:
+ handler, ok := actionHandlers[action]
+ if !ok {
return Response{Success: false, Error: fmt.Sprintf("unsupported action %q", action)}
}
+ return handler(ctx, client, req, conn.GRPCAddress)
+}
+
+var actionHandlers = map[string]actionHandler{
+ "status": handleStatusAction,
+ "find-logins": handleFindLoginsAction,
+ "search-logins": handleSearchLoginsAction,
+ "get-login": handleGetLoginAction,
+ "save-login": handleSaveLoginAction,
+}
+
+func handleStatusAction(ctx context.Context, client Client, _ Request, grpcAddress string) Response {
+ status, err := statusResponse(ctx, client, grpcAddress)
+ if err != nil {
+ return Response{Success: false, Error: err.Error(), Status: disconnectedStatus(grpcAddress)}
+ }
+ return Response{Success: true, Status: status, Version: responseVersion}
+}
+
+func handleFindLoginsAction(ctx context.Context, client Client, req Request, grpcAddress string) Response {
+ matches, err := findMatches(ctx, client, req.URL)
+ if err != nil {
+ if status := inferredActionStatus(grpcAddress, err); status != nil {
+ return Response{Success: true, Status: status, Matches: nil, Version: responseVersion}
+ }
+ return Response{Success: false, Error: err.Error(), Status: disconnectedStatus(grpcAddress)}
+ }
+ return Response{Success: true, Status: availableStatus(grpcAddress), Matches: matches, Version: responseVersion}
+}
+
+func handleSearchLoginsAction(ctx context.Context, client Client, req Request, grpcAddress string) Response {
+ results, err := searchEntries(ctx, client, req.Query)
+ if err != nil {
+ if status := inferredActionStatus(grpcAddress, err); status != nil {
+ return Response{Success: true, Status: status, SearchResults: nil, Version: responseVersion}
+ }
+ return Response{Success: false, Error: err.Error(), Status: disconnectedStatus(grpcAddress)}
+ }
+ return Response{Success: true, Status: availableStatus(grpcAddress), SearchResults: results, Version: responseVersion}
+}
+
+func handleGetLoginAction(ctx context.Context, client Client, req Request, grpcAddress string) Response {
+ credential, err := loadCredential(ctx, client, req.EntryID, req.URL)
+ if err != nil {
+ if status := inferredActionStatus(grpcAddress, err); status != nil {
+ return Response{Success: false, Error: err.Error(), Status: status}
+ }
+ return Response{Success: false, Error: err.Error(), Status: disconnectedStatus(grpcAddress)}
+ }
+ return Response{Success: true, Status: availableStatus(grpcAddress), Credential: credential, Version: responseVersion}
+}
+
+func handleSaveLoginAction(ctx context.Context, client Client, req Request, grpcAddress string) Response {
+ if err := saveLogin(ctx, client, req); err != nil {
+ if status := inferredActionStatus(grpcAddress, err); status != nil {
+ return Response{Success: false, Error: err.Error(), Status: status}
+ }
+ return Response{Success: false, Error: err.Error(), Status: disconnectedStatus(grpcAddress)}
+ }
+ return Response{Success: true, Status: availableStatus(grpcAddress), Version: responseVersion}
}
func disconnectedStatus(addr string) *Status {
@@ -276,6 +313,95 @@ func loadCredential(ctx context.Context, client Client, entryID, rawURL string)
}, nil
}
+func saveLogin(ctx context.Context, client Client, req Request) error {
+ if strings.TrimSpace(req.Password) == "" {
+ return fmt.Errorf("browser save requires a password")
+ }
+ if strings.TrimSpace(req.EntryID) != "" {
+ entries, err := client.ListEntries(ctx, nil, "")
+ if err != nil {
+ return err
+ }
+ existing := findEntry(entries, req.EntryID)
+ if existing == nil {
+ return fmt.Errorf("entry %q was not found", strings.TrimSpace(req.EntryID))
+ }
+ entry := cloneEntry(existing)
+ entry.Title = coalesceTitle(req.Title, existing.Title, req.URL)
+ entry.Username = strings.TrimSpace(req.Username)
+ entry.Password = strings.TrimSpace(req.Password)
+ entry.Url = strings.TrimSpace(req.URL)
+ _, err = client.UpsertEntry(ctx, entry)
+ return err
+ }
+ path := append([]string(nil), req.Path...)
+ if len(path) == 0 {
+ return fmt.Errorf("browser save requires a target group path")
+ }
+ entry := &keepassgov1.Entry{
+ Id: newBrowserEntryID(),
+ Title: coalesceTitle(req.Title, "", req.URL),
+ Username: strings.TrimSpace(req.Username),
+ Password: strings.TrimSpace(req.Password),
+ Url: strings.TrimSpace(req.URL),
+ Path: path,
+ Fields: map[string]string{},
+ }
+ _, err := client.UpsertEntry(ctx, entry)
+ return err
+}
+
+func findEntry(entries []*keepassgov1.Entry, id string) *keepassgov1.Entry {
+ for _, entry := range entries {
+ if entry.GetId() == strings.TrimSpace(id) {
+ return entry
+ }
+ }
+ return nil
+}
+
+func cloneEntry(entry *keepassgov1.Entry) *keepassgov1.Entry {
+ if entry == nil {
+ return &keepassgov1.Entry{Fields: map[string]string{}}
+ }
+ fields := make(map[string]string, len(entry.GetFields()))
+ for key, value := range entry.GetFields() {
+ fields[key] = value
+ }
+ return &keepassgov1.Entry{
+ Id: entry.GetId(),
+ Title: entry.GetTitle(),
+ Username: entry.GetUsername(),
+ Password: entry.GetPassword(),
+ Url: entry.GetUrl(),
+ Notes: entry.GetNotes(),
+ Tags: append([]string(nil), entry.GetTags()...),
+ Path: append([]string(nil), entry.GetPath()...),
+ Fields: fields,
+ }
+}
+
+func coalesceTitle(title, fallback, rawURL string) string {
+ if trimmed := strings.TrimSpace(title); trimmed != "" {
+ return trimmed
+ }
+ if trimmed := strings.TrimSpace(fallback); trimmed != "" {
+ return trimmed
+ }
+ if parsed, err := url.Parse(strings.TrimSpace(rawURL)); err == nil && strings.TrimSpace(parsed.Hostname()) != "" {
+ return strings.ToLower(strings.TrimSpace(parsed.Hostname()))
+ }
+ return "Browser Login"
+}
+
+func newBrowserEntryID() string {
+ var buf [16]byte
+ if _, err := rand.Read(buf[:]); err != nil {
+ return fmt.Sprintf("browser-%d", os.Getpid())
+ }
+ return hex.EncodeToString(buf[:])
+}
+
func searchEntries(ctx context.Context, client Client, query string) ([]Match, error) {
resp, err := client.ListEntries(ctx, nil, strings.TrimSpace(query))
if err != nil {
diff --git a/internal/browserbridge/bridge_test.go b/internal/browserbridge/bridge_test.go
index e69019d..f57e4f2 100644
--- a/internal/browserbridge/bridge_test.go
+++ b/internal/browserbridge/bridge_test.go
@@ -170,6 +170,89 @@ func TestHandleRequestSearchLogins(t *testing.T) {
}
}
+func TestHandleRequestSaveLoginUpdatesExistingEntry(t *testing.T) {
+ t.Parallel()
+
+ client := &fakeClient{
+ entries: []*keepassgov1.Entry{
+ {
+ Id: "vault-console",
+ Title: "Vault Console",
+ Username: "dannyocean",
+ Password: "old-password",
+ Url: "https://vault.example.invalid/login",
+ Path: []string{"Crew", "Internet"},
+ Fields: map[string]string{
+ "URL1": "vault.example.invalid",
+ "X-Role": "inside-man",
+ },
+ Tags: []string{"vault"},
+ Notes: "Original notes stay intact.",
+ },
+ },
+ }
+
+ resp := HandleRequest(context.Background(), Request{
+ Action: "save-login",
+ BearerToken: "secret",
+ EntryID: "vault-console",
+ Username: "dannyocean",
+ Password: "new-password",
+ URL: "https://vault.example.invalid/login",
+ }, "", client)
+ if !resp.Success {
+ t.Fatalf("HandleRequest(save-login update) success = false, error = %q", resp.Error)
+ }
+ if client.upserted == nil {
+ t.Fatal("HandleRequest(save-login update) did not upsert an entry")
+ }
+ if got := client.upserted.Id; got != "vault-console" {
+ t.Fatalf("upserted.Id = %q, want vault-console", got)
+ }
+ if got := client.upserted.Password; got != "new-password" {
+ t.Fatalf("upserted.Password = %q, want new-password", got)
+ }
+ if got := client.upserted.Fields["X-Role"]; got != "inside-man" {
+ t.Fatalf("upserted.Fields[X-Role] = %q, want inside-man", got)
+ }
+ if got := client.upserted.Notes; got != "Original notes stay intact." {
+ t.Fatalf("upserted.Notes = %q, want original notes", got)
+ }
+}
+
+func TestHandleRequestSaveLoginCreatesNewEntryInChosenPath(t *testing.T) {
+ t.Parallel()
+
+ client := &fakeClient{}
+ resp := HandleRequest(context.Background(), Request{
+ Action: "save-login",
+ BearerToken: "secret",
+ Title: "Bellagio Login",
+ Username: "linuscaldwell",
+ Password: "yellow-chip",
+ URL: "https://bellagio.example.invalid/login",
+ Path: []string{"Crew", "Internet"},
+ }, "", client)
+ if !resp.Success {
+ t.Fatalf("HandleRequest(save-login create) success = false, error = %q", resp.Error)
+ }
+ if client.upserted == nil {
+ t.Fatal("HandleRequest(save-login create) did not upsert an entry")
+ }
+ if got := client.upserted.Title; got != "Bellagio Login" {
+ t.Fatalf("upserted.Title = %q, want Bellagio Login", got)
+ }
+ if got := client.upserted.Username; got != "linuscaldwell" {
+ t.Fatalf("upserted.Username = %q, want linuscaldwell", got)
+ }
+ if got := client.upserted.Path; !slices.Equal(got, []string{"Crew", "Internet"}) {
+ t.Fatalf("upserted.Path = %v, want [Crew Internet]", got)
+ }
+ if got := client.upserted.Id; got == "" {
+ t.Fatal("upserted.Id = empty, want generated id")
+ }
+}
+
func TestHandleRequestFindLoginsInfersLockedStatusFromRPC(t *testing.T) {
t.Parallel()
@@ -332,10 +415,12 @@ type fakeClient struct {
matches []*keepassgov1.BrowserLoginMatch
entries []*keepassgov1.Entry
credential *keepassgov1.GetBrowserCredentialResponse
+ upserted *keepassgov1.Entry
err error
matchesErr error
entriesErr error
credentialErr error
+ upsertErr error
statusCalls int
}
@@ -427,3 +512,11 @@ func (f *fakeClient) GetBrowserCredential(context.Context, string, string) (*kee
}
return f.credential, nil
}
+
+func (f *fakeClient) UpsertEntry(_ context.Context, entry *keepassgov1.Entry) (*keepassgov1.Entry, error) {
+ if f.upsertErr != nil {
+ return nil, f.upsertErr
+ }
+ f.upserted = entry
+ return entry, nil
+}
diff --git a/internal/browserbridge/client.go b/internal/browserbridge/client.go
index e80ba19..048129b 100644
--- a/internal/browserbridge/client.go
+++ b/internal/browserbridge/client.go
@@ -82,3 +82,11 @@ func (c *GRPCClient) GetBrowserCredential(ctx context.Context, entryID, pageURL
PageUrl: strings.TrimSpace(pageURL),
})
}
+
+func (c *GRPCClient) UpsertEntry(ctx context.Context, entry *keepassgov1.Entry) (*keepassgov1.Entry, error) {
+ resp, err := c.client.UpsertEntry(ctx, &keepassgov1.UpsertEntryRequest{Entry: entry})
+ if err != nil {
+ return nil, err
+ }
+ return resp.GetEntry(), nil
+}