From f82ddf74359a87937e1dcc7166e7776607b78338 Mon Sep 17 00:00:00 2001 From: Joe Julian Date: Thu, 23 Apr 2026 21:00:29 -0700 Subject: [PATCH] Add browser save and update workflow --- browser/extension/background.js | 177 ++++++++++++++++++++++ browser/extension/background.test.cjs | 59 ++++++++ browser/extension/content.js | 36 ++++- browser/extension/content.test.cjs | 14 ++ browser/extension/popup.html | 7 + browser/extension/popup.js | 98 ++++++++++-- browser/extension/style.css | 12 ++ docs/browser-extension.md | 25 ++++ internal/browserbridge/bridge.go | 206 +++++++++++++++++++++----- internal/browserbridge/bridge_test.go | 93 ++++++++++++ internal/browserbridge/client.go | 8 + 11 files changed, 683 insertions(+), 52 deletions(-) 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.

+

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 +}