From eea6076d7f0354c0fda2015153fa3bd2f572cce1 Mon Sep 17 00:00:00 2001 From: Daniel Ehrhardt Date: Sun, 25 Oct 2026 08:57:40 +0100 Subject: [PATCH] Vault: auto-link 2FA codes to logins A login without linked 2FA now gets the most likely free 2FA entry (fuzzy brand + account match, known aliases). vault_fill_totp types it on the login's own site, watches the page and links the pair once the site takes the code; a rejected guess moves on to the next candidate. The 2FA page offers clear matches for one-click linking, and missing-2FA suggestions use the same matcher. --- .../components/vault/totp-autolink-panel.tsx | 166 ++++++++++++++++ apps/desktop/src/lib/api.ts | 4 + apps/desktop/src/lib/queryKeys.ts | 1 + apps/desktop/src/lib/realtime.ts | 2 +- apps/desktop/src/pages/vault/totp-page.tsx | 3 + packages/core/src/browser/fill.ts | 59 ++++++ packages/core/src/browser/manager.ts | 9 +- packages/core/src/mcp/tools.ts | 56 +++++- packages/core/src/server/routes/vault.ts | 16 ++ packages/core/src/services/missingLogins.ts | 4 +- packages/core/src/vault/credentials.ts | 11 ++ packages/core/src/vault/totpGuess.ts | 187 ++++++++++++++++++ packages/core/src/vault/totpTrial.ts | 108 ++++++++++ packages/core/src/vm/guest.ts | 15 +- packages/core/test/fixtures/runner-harness.ts | 6 + packages/core/test/mcp.test.ts | 53 ++++- packages/core/test/totp-guess.test.ts | 131 ++++++++++++ packages/shared/src/models.ts | 14 ++ 18 files changed, 833 insertions(+), 12 deletions(-) create mode 100644 apps/desktop/src/components/vault/totp-autolink-panel.tsx create mode 100644 packages/core/src/vault/totpGuess.ts create mode 100644 packages/core/src/vault/totpTrial.ts create mode 100644 packages/core/test/totp-guess.test.ts diff --git a/apps/desktop/src/components/vault/totp-autolink-panel.tsx b/apps/desktop/src/components/vault/totp-autolink-panel.tsx new file mode 100644 index 00000000..85419b76 --- /dev/null +++ b/apps/desktop/src/components/vault/totp-autolink-panel.tsx @@ -0,0 +1,166 @@ +import { useMemo, useState } from "react"; +import { motion } from "motion/react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { toast } from "sonner"; +import { ArrowRight, Link2, X } from "lucide-react"; +import type { Credential, TotpEntry } from "@godmode/shared"; +import { Button } from "@/components/ui/button"; +import { Checkbox } from "@/components/ui/checkbox"; +import { Spinner } from "@/components/ui/spinner"; +import { api } from "@/lib/api"; +import { qk } from "@/lib/queryKeys"; +import { cn } from "@/lib/utils"; +import { Favicon } from "./favicon"; +import { issuerDomain } from "./use-totp-codes"; +import { domainFromUrl, toastApiError } from "./vault-utils"; + +const DISMISS_KEY = "godmode-totp-autolink-dismissed"; + +const pairKey = (p: { credentialId: string; totpId: string }) => `${p.credentialId}:${p.totpId}`; + +/** "Stripe:dan@x.de" without an issuer reads as service "Stripe", account "dan@x.de". */ +function entryLabel(t: TotpEntry): { service: string; account: string } { + if (t.issuer) return { service: t.issuer, account: t.accountName }; + const i = t.accountName.indexOf(":"); + return i > 0 ? { service: t.accountName.slice(0, i), account: t.accountName.slice(i + 1) } : { service: t.accountName, account: "" }; +} + +function loadDismissed(): Set { + try { + return new Set(JSON.parse(localStorage.getItem(DISMISS_KEY) ?? "[]") as string[]); + } catch { + return new Set(); + } +} + +/** Logins and 2FA codes that clearly belong together, linked in one click. Agents also link them on their own once a guessed code works. */ +export function TotpAutoLinkPanel({ entries, credById }: { entries: TotpEntry[]; credById: Map }) { + const qc = useQueryClient(); + const [dismissed, setDismissed] = useState(loadDismissed); + const [unchecked, setUnchecked] = useState>(new Set()); + const [checkedLikely, setCheckedLikely] = useState>(new Set()); + const suggestions = useQuery({ queryKey: qk.totpAutolink, queryFn: api.totp.autolinkSuggestions, enabled: entries.length > 0 }); + + const totpById = useMemo(() => new Map(entries.map((t) => [t.id, t])), [entries]); + const rows = useMemo( + () => + (suggestions.data ?? []) + .filter((s) => !dismissed.has(pairKey(s))) + .map((s) => ({ ...s, key: pairKey(s), totp: totpById.get(s.totpId), cred: credById.get(s.credentialId) })) + .filter((r): r is typeof r & { totp: TotpEntry; cred: Credential } => !!r.totp && !!r.cred), + [suggestions.data, dismissed, totpById, credById], + ); + const isChecked = (r: { key: string; sure: boolean }) => (unchecked.has(r.key) ? false : r.sure || checkedLikely.has(r.key)); + const selected = rows.filter(isChecked); + + const toggle = (r: { key: string; sure: boolean }, on: boolean) => { + const set = (s: Set, add: boolean) => { + const next = new Set(s); + if (add) next.add(r.key); + else next.delete(r.key); + return next; + }; + if (r.sure) setUnchecked((s) => set(s, !on)); + else setCheckedLikely((s) => set(s, on)); + }; + + const dismiss = (keys: string[]) => { + setDismissed((d) => { + const next = new Set([...d, ...keys]); + try { + localStorage.setItem(DISMISS_KEY, JSON.stringify([...next].slice(-500))); + } catch { + /* ignore */ + } + return next; + }); + }; + + const link = useMutation({ + mutationFn: () => api.totp.autolink(selected.map(({ credentialId, totpId }) => ({ credentialId, totpId }))), + onSuccess: (res) => { + void qc.invalidateQueries({ queryKey: qk.totp }); + void qc.invalidateQueries({ queryKey: qk.credentials }); + void qc.invalidateQueries({ queryKey: qk.bootstrap }); + const n = res.linked.length; + if (n) toast.success(`${n} 2FA ${n === 1 ? "code" : "codes"} linked`, { description: "Agents fill them in by themselves on the next sign-in." }); + else toast.info("Nothing to link anymore", { description: "These logins got a 2FA code in the meantime." }); + }, + onError: (e) => toastApiError(e, "Could not link the 2FA codes", qc), + }); + + if (!rows.length) return null; + const sureCount = rows.filter((r) => r.sure).length; + + return ( + +
+ + + +
+

+ {rows.length === 1 ? "1 code matches a login" : `${rows.length} codes match a login`} +

+

+ Matched by service and account. Link them and agents finish two-factor on their own. + {sureCount < rows.length ? " Likely matches stay unchecked; agents also try them and link the ones that work." : ""} +

+
+ +
+ +
    + {rows.map((r) => { + const checked = isChecked(r); + const credDomain = r.cred.domains[0] ?? domainFromUrl(r.cred.url); + const { service, account } = entryLabel(r.totp); + return ( +
  • + +
  • + ); + })} +
+ +
+ + +
+
+ ); +} diff --git a/apps/desktop/src/lib/api.ts b/apps/desktop/src/lib/api.ts index 812b14ad..6a432310 100644 --- a/apps/desktop/src/lib/api.ts +++ b/apps/desktop/src/lib/api.ts @@ -203,6 +203,8 @@ import type { DeviceGroup, DeviceGroupPatch, DeviceInvite, HandoffInput, Handoff TotpImportInput, TotpImportResult, TotpInput, + TotpAutoLinkResult, + TotpAutoLinkSuggestion, UpdateReport, UsageSummary, VaultStatus, @@ -498,6 +500,8 @@ export const api = { update: (id: string, input: Partial) => patch(`/api/totp/${id}`, input), delete: (id: string) => del<{ ok: true }>(`/api/totp/${id}`), import: (input: TotpImportInput) => post("/api/totp/import", input), + autolinkSuggestions: () => get("/api/totp/autolink"), + autolink: (pairs?: { credentialId: string; totpId: string }[]) => post("/api/totp/autolink", { pairs }), }, workspaces: { diff --git a/apps/desktop/src/lib/queryKeys.ts b/apps/desktop/src/lib/queryKeys.ts index 9556f527..58a222f9 100644 --- a/apps/desktop/src/lib/queryKeys.ts +++ b/apps/desktop/src/lib/queryKeys.ts @@ -29,6 +29,7 @@ export const qk = { totp: ["totp"] as unknown[], totpList: (workspaceId: string) => ["totp", "list", workspaceId], totpCodes: ["totp", "codes"] as unknown[], + totpAutolink: ["totp", "autolink"] as unknown[], cards: ["cards"] as unknown[], cardList: (workspaceId: string) => ["cards", "list", workspaceId], cardPurchases: ["cards", "purchases"] as unknown[], diff --git a/apps/desktop/src/lib/realtime.ts b/apps/desktop/src/lib/realtime.ts index 94e6b89f..7ee1adc7 100644 --- a/apps/desktop/src/lib/realtime.ts +++ b/apps/desktop/src/lib/realtime.ts @@ -49,7 +49,7 @@ const ENTITY_KEYS: Record = { workspaces: [qk.workspaces], agents: [qk.agents, qk.spend, qk.bootstrap], routines: [qk.routines, qk.bootstrap], - credentials: [qk.credentials], + credentials: [qk.credentials, qk.totpAutolink], totp: [qk.totp], passkeys: [qk.passkeys], "payment-cards": [qk.cards], diff --git a/apps/desktop/src/pages/vault/totp-page.tsx b/apps/desktop/src/pages/vault/totp-page.tsx index c2ad3f9f..c304737c 100644 --- a/apps/desktop/src/pages/vault/totp-page.tsx +++ b/apps/desktop/src/pages/vault/totp-page.tsx @@ -11,6 +11,7 @@ import { EmptyState, PageBody, PageHeader } from "@/components/common"; import { TotpCard, TotpCardSkeleton } from "@/components/vault/totp-card"; import { TotpEditDialog } from "@/components/vault/totp-edit-dialog"; import { TotpImportDialog } from "@/components/vault/totp-import-dialog"; +import { TotpAutoLinkPanel } from "@/components/vault/totp-autolink-panel"; import { ConfirmDeleteDialog } from "@/components/vault/confirm-dialog"; import { ScopeFilterSelect } from "@/components/vault/workspace-select"; import { useTotpCodes } from "@/components/vault/use-totp-codes"; @@ -148,6 +149,8 @@ export default function TotpPage() { )} + {hasEntries && creds.data && } + {list.isLoading ? (
diff --git a/packages/core/src/browser/fill.ts b/packages/core/src/browser/fill.ts index e1c91e52..40ee440c 100644 --- a/packages/core/src/browser/fill.ts +++ b/packages/core/src/browser/fill.ts @@ -97,6 +97,24 @@ interface LocateResult { focusInFrame?: boolean; } +/** Is a visible 2FA code input (or a row of one-digit boxes) on this document, and does it say a code was wrong? */ +const OTP_PROBE = String.raw`() => { + const OTP_RE = /one[-_ ]?time|otp|totp|2fa|mfa|two[-_ ]?factor|verification|verify|security[-_ ]?code|auth(entication)?[-_ ]?code|\bcode\b|token|\bpin\b/; + const visible = (el) => { + const st = getComputedStyle(el); + if (st.display === "none" || st.visibility === "hidden") return false; + const r = el.getBoundingClientRect(); + return r.width >= 2 && r.height >= 2; + }; + const attrText = (el) => ["name", "id", "autocomplete", "placeholder", "aria-label", "data-testid"].map((a) => el.getAttribute(a) || "").join(" ").toLowerCase(); + const inputs = [...document.querySelectorAll("input")].filter((el) => !el.disabled && ["", "text", "tel", "number", "password"].includes((el.getAttribute("type") || "").toLowerCase()) && visible(el)); + const field = inputs.some((el) => el.getAttribute("autocomplete") === "one-time-code" || (OTP_RE.test(attrText(el)) && !/user|e-?mail|search/.test(attrText(el)))); + const boxes = inputs.filter((el) => el.maxLength === 1).length >= 4; + const text = (document.body && document.body.innerText || "").slice(0, 20000); + const error = /incorrect|invalid|wrong code|didn.t work|not valid|expired|too many|try again|falsch|ung(\u00fc|ue)ltig|abgelaufen|zu viele|erneut versuchen|nicht korrekt/i.test(text); + return { otp: field || boxes, error }; +}`; + /* ------------------------------------------------------------------ */ /* In-page scripts (run in an isolated world; state kept on its global) */ /* ------------------------------------------------------------------ */ @@ -688,3 +706,44 @@ export async function fillIntoActivePage( return { ok: false, url: "", detail: scrub(`Could not fill the field: ${err instanceof Error ? err.message : String(err)}`) }; } } + +export interface OtpProbe { + url: string; + otpField: boolean; + /** The page says a code was wrong, expired or tried too often. */ + error: boolean; +} + +/** Whether a 2FA code field is still showing on the page or one of its frames — gone means the site took the code. */ +export async function otpFieldOnPage(page: PageSession): Promise { + await page.waitForReady(3000).catch(() => {}); + const url = await page.evaluate("location.href", { timeoutMs: 3000 }).catch(() => ""); + const contexts: FrameContext[] = [{ session: page, contextId: await page.isolatedWorld(), isChildFrame: false }, ...(await frameContexts(page).catch(() => []))]; + const out: OtpProbe = { url, otpField: false, error: false }; + for (const ctx of contexts) { + const res = await call<{ otp: boolean; error: boolean }>(ctx, OTP_PROBE, {}).catch(() => null); + out.otpField ||= !!res?.otp; + out.error ||= !!res?.error; + } + return out; +} + +/** otpFieldOnPage for the page fillIntoActivePage would pick; null when there is no page to look at. */ +export async function probeOtpInActivePage(browser: { + client: CdpClient; + port?: number; + chatPage?: (urlContains?: string) => PageTarget | null; +}): Promise { + try { + const target = browser.chatPage ? browser.chatPage() : await pickActivePage(browser.client, { port: browser.port }); + if (!target) return null; + const page = await attachToPage(browser.client, target.targetId); + try { + return await otpFieldOnPage(page); + } finally { + await page.detach(); + } + } catch { + return null; + } +} diff --git a/packages/core/src/browser/manager.ts b/packages/core/src/browser/manager.ts index f87145ea..574659fb 100644 --- a/packages/core/src/browser/manager.ts +++ b/packages/core/src/browser/manager.ts @@ -19,7 +19,7 @@ import { resolveUvx, toolPath } from "../services/doctor"; import { hasBrowserSubscribers, hasBrowserWatchers } from "../server/ws"; import { CdpClient, attachToPage, getCookies, pickActivePage, probeCdp, isUserPage, type CdpCookie, type CdpCookieParam, type PageSession, type PageTarget } from "./cdp"; import { clearLaunchMarker, findChrome, isProcessAlive, launchChrome, readLaunchMarker, writeLaunchMarker, type ChromeProcess } from "./chrome"; -import { fillIntoActivePage, fillPrecheck, type FillKind } from "./fill"; +import { fillIntoActivePage, fillPrecheck, probeOtpInActivePage, type OtpProbe, type FillKind } from "./fill"; import { solveInBrowser, type SolveOptions, type SolveResult } from "./captcha"; import { browserUseCommand, browserUseEnv, writeBrowserUseConfig } from "./browserUse"; import { stealthArgs, stopProbes, windowedUserAgent } from "./stealth"; @@ -822,6 +822,13 @@ export async function fillIntoPage( } } +/** Whether the chat's page still shows a 2FA code field (see probeOtpInActivePage); null when the browser or tab is gone. */ +export async function probeOtpPage(profileId: string, conversationId?: string): Promise { + const rb = getRunning(profileId); + if (!rb) return null; + return probeOtpInActivePage({ client: rb.client, port: rb.port, ...(conversationId ? { chatPage: () => chatPage(rb, conversationId) } : {}) }); +} + /** Solve the CAPTCHA on the chat's page (without a chat: the active page) through the selected solving service. */ export async function solveCaptchaInPage(profileId: string, opts: SolveOptions & { conversationId?: string }): Promise { requireRow(profileId); diff --git a/packages/core/src/mcp/tools.ts b/packages/core/src/mcp/tools.ts index c6ad1856..a2fd5ef0 100644 --- a/packages/core/src/mcp/tools.ts +++ b/packages/core/src/mcp/tools.ts @@ -100,6 +100,7 @@ import { findCredentialsForAgent, generatePassword, getCredential, + linkCredentialTotpIfFree, listCredentials, markCredentialUsed, revealForAgent, @@ -108,6 +109,8 @@ import { import { passkeysForHost } from "../vault/passkeys"; import { passkeysEnabled } from "../browser/passkeys"; import { codeForAgent, listTotp, totpForAgent } from "../vault/totp"; +import { rankTotpGuesses } from "../vault/totpGuess"; +import { triedTotp, watchTotpGuess } from "../vault/totpTrial"; import { CARD_FIELDS, attachQuestion, @@ -125,9 +128,9 @@ import { type CardField, } from "../vault/cards"; import { nameGuessMatchesHost } from "../vault/match"; -import { chatWorkspaceId, createProfile, currentPage, deleteProfile, fillIntoPage, listProfiles, resolveProfileForAgent, solveCaptchaInPage } from "../browser/manager"; +import { chatWorkspaceId, createProfile, currentPage, deleteProfile, fillIntoPage, listProfiles, probeOtpPage, resolveProfileForAgent, solveCaptchaInPage } from "../browser/manager"; import { SOLVERS, activeSolver, captchaSolverReady } from "../browser/captcha"; -import { currentVmPage, fillIntoVm, solveCaptchaInVm } from "../vm/guest"; +import { currentVmPage, fillIntoVm, probeOtpInVm, solveCaptchaInVm } from "../vm/guest"; import { getMcpServer, mcpServerInAgentScope } from "../integrations/mcpServers"; import { apiToolEnvOwners, apiToolKey, apiToolsForAgent, findApiToolForAgent, hasApiTools, markApiToolUsed } from "../integrations/apiTools"; import { callApiTool, METHODS, type ApiCallResult, type CallPlaces } from "../integrations/apiToolRequest"; @@ -612,6 +615,10 @@ function pageOf(target: FillTarget) { return "vmId" in target ? currentVmPage(target.vmId) : currentPage(target.profileId, target.conversationId); } +function probeOtp(target: FillTarget) { + return "vmId" in target ? probeOtpInVm(target.vmId) : probeOtpPage(target.profileId, target.conversationId); +} + function fillInto(target: FillTarget, opts: Parameters[1]) { return "vmId" in target ? fillIntoVm(target.vmId, opts) : fillIntoPage(target.profileId, { ...opts, conversationId: target.conversationId }); } @@ -1156,7 +1163,7 @@ const TOOLS: ToolDef[] = [ defineTool({ name: "vault_fill_totp", description: - "Type the current 2FA (TOTP / authenticator) code into the browser page — Godmode fills it, you never see it. Pass the login's credentialId (its linked 2FA is used) or a totpId. Only works on the site of the login the 2FA entry is linked to (an unlinked entry also needs the credentialId of the site's login). The code input is found automatically (incl. one-digit-per-box inputs); pass a selector only if needed.", + "Type the current 2FA (TOTP / authenticator) code into the browser page — Godmode fills it, you never see it. Pass the login's credentialId (its linked 2FA is used) or a totpId. Only works on the site of the login the 2FA entry is linked to (an unlinked entry also needs the credentialId of the site's login). When the login has no 2FA linked yet, Godmode tries the 2FA entry that most likely belongs to it and links the two by itself once the site accepts the code; if the site rejects it, call this again to try the next likely one. The code input is found automatically (incl. one-digit-per-box inputs); pass a selector only if needed.", schema: z.object({ credentialId: z.string().optional().describe("Login id whose linked 2FA should be used"), totpId: z.string().optional().describe("2FA entry id (alternative to credentialId)"), @@ -1167,14 +1174,23 @@ const TOOLS: ToolDef[] = [ const browser = requireBrowser(agent, ctx); let id = totpId ?? null; let site: Credential | null = null; + let guess: { score: number; left: number } | null = null; if (credentialId) { const cred = credentialsForAgent(agent).find((c) => c.id === credentialId); if (!cred) return fail(`Login ${credentialId} is not available to you.`); site = cred; id ??= cred.totpId ?? totpForAgent(agent).find((t) => t.credentialId === credentialId)?.id ?? sameAccountTotp(agent, cred) ?? null; if (!id) { + const ranked = rankTotpGuesses(cred, totpForAgent(agent), triedTotp(ctx.conversationId, cred.id)); + if (ranked[0]) { + id = ranked[0].entry.id; + guess = { score: ranked[0].score, left: ranked.length - 1 }; + } + } + if (!id) { + const tried = triedTotp(ctx.conversationId, cred.id).size; return fail( - `No 2FA code is linked to "${cred.name}". Call report_missing_login with kind "missing_totp" and the site's URL: the human can pick the code if it is already in the vault or add it. Don't give the human a task to type the code; continue with other work.`, + `${tried ? `None of the ${tried} 2FA ${tried === 1 ? "entry" : "entries"} that looked like they belong to "${cred.name}" worked, and no other one fits. ` : `No 2FA code is linked to "${cred.name}". `}Call report_missing_login with kind "missing_totp" and the site's URL: the human can pick the code if it is already in the vault or add it. Don't give the human a task to type the code; continue with other work.`, ); } } @@ -1196,11 +1212,39 @@ const TOOLS: ToolDef[] = [ } const { scope, guessHost } = await fillScopeFor(browser, site); const result = await fillInto(browser, { text: code.code, kind: "totp", selector, submit, ...scope }); - audit(`agent:${agent.id}`, "totp.fill", id, { field: "totp", runId: ctx.runId, credentialId: credentialId ?? null, ok: result.ok, ...(guessHost ? { guessedSite: guessHost } : {}) }); + audit(`agent:${agent.id}`, "totp.fill", id, { field: "totp", runId: ctx.runId, credentialId: credentialId ?? null, ok: result.ok, ...(guessHost ? { guessedSite: guessHost } : {}), ...(guess ? { guessed: true } : {}) }); if (!result.ok) return fail(`Could not fill the 2FA code: ${scrub(result.detail, code.code)}`); const remembered = guessHost && addCredentialDomain(site.id, guessHost); if (remembered) audit(`agent:${agent.id}`, "credential.autofix_domain", site.id, { domain: guessHost, runId: ctx.runId }); - return `Filled the current 2FA code into ${result.url}${submit ? " and submitted" : ""}.${remembered ? ` Added ${guessHost} to "${site.name}" (its name matched the site).` : ""}`; + const filled = `Filled the current 2FA code into ${result.url}${submit ? " and submitted" : ""}.${remembered ? ` Added ${guessHost} to "${site.name}" (its name matched the site).` : ""}`; + if (!guess) return filled; + + const login = site; + const label = entry.accountName && entry.issuer ? `${entry.issuer} (${entry.accountName})` : entry.issuer || entry.accountName; + let linked = false; + const outcome = watchTotpGuess(ctx.conversationId, login.id, entry.id, { + probe: () => probeOtp(browser), + onSite: (url) => { + const host = hostnameOf(url); + return !!host && scope.allowedHosts.some((d) => domainMatches(host, d)); + }, + onAccepted: () => { + linked = linkCredentialTotpIfFree(login.id, entry.id); + if (linked) audit(`agent:${agent.id}`, "totp.autolink", entry.id, { credentialId: login.id, runId: ctx.runId, score: guess.score }); + }, + }); + const retry = guess.left + ? `If the page says the code is wrong, call vault_fill_totp again with the same credentialId: Godmode tries the next likely 2FA entry (${guess.left} left).` + : `If the page says the code is wrong, no other saved 2FA entry fits: call report_missing_login with kind "missing_totp".`; + if (!submit) { + return `${filled} "${login.name}" had no 2FA linked, so Godmode used its best guess, the 2FA entry "${label}". Submit the code now; once the site accepts it, Godmode links the two by itself. ${retry}`; + } + const verdict = await Promise.race([outcome, sleep(12_000).then(() => "pending" as const)]); + if (verdict === "accepted") { + return `${filled} "${login.name}" had no 2FA linked, so Godmode tried its best guess, the 2FA entry "${label}". The site accepted it${linked ? `, so the two are linked now and future sign-ins use it right away` : ""}.`; + } + if (verdict === "rejected") return `${filled} Godmode's best guess for "${login.name}", the 2FA entry "${label}", looks rejected. ${retry}`; + return `${filled} "${login.name}" had no 2FA linked, so Godmode used its best guess, the 2FA entry "${label}". The 2FA field is still on the page, so it is not clear yet whether the site took it; Godmode keeps watching and links the two once it does. ${retry}`; }, }), diff --git a/packages/core/src/server/routes/vault.ts b/packages/core/src/server/routes/vault.ts index 262e1378..eb0d844f 100644 --- a/packages/core/src/server/routes/vault.ts +++ b/packages/core/src/server/routes/vault.ts @@ -6,9 +6,11 @@ import { createCredential, deleteCredential, getCredential, + linkCredentialTotpIfFree, listCredentials, updateCredential, } from "../../vault/credentials"; +import { suggestTotpLinks } from "../../vault/totpGuess"; import { deletePasskey, getPasskey, listPasskeys } from "../../vault/passkeys"; import { createTotp, currentCodes, deleteTotp, importTotpUris, listTotp, updateTotp } from "../../vault/totp"; import { importPasswords, MAX_IMPORT_BYTES, previewPasswordImport } from "../../vault/passwordImport"; @@ -328,6 +330,20 @@ export function registerVaultRoutes(app: Hono): void { return c.json(currentCodes(ids)); }); + app.get("/api/totp/autolink", (c) => c.json(suggestTotpLinks(listCredentials({ workspaceId: "all" }), listTotp({ workspaceId: "all" })))); + + /** Link suggested pairs (default: every sure one). A pair is only linked while it is still a suggestion. */ + app.post("/api/totp/autolink", async (c) => { + const input = await body(c, z.object({ pairs: z.array(z.object({ credentialId: idSchema, totpId: idSchema })).max(1000).optional() })); + const suggestions = suggestTotpLinks(listCredentials({ workspaceId: "all" }), listTotp({ workspaceId: "all" })); + const wanted = input.pairs + ? suggestions.filter((s) => input.pairs!.some((p) => p.credentialId === s.credentialId && p.totpId === s.totpId)) + : suggestions.filter((s) => s.sure); + const linked = wanted.filter((s) => linkCredentialTotpIfFree(s.credentialId, s.totpId)).map(({ credentialId, totpId }) => ({ credentialId, totpId })); + for (const l of linked) audit("user", "totp.autolink", l.totpId, { credentialId: l.credentialId }); + return c.json({ linked }); + }); + app.post("/api/totp/import", async (c) => { const input = await body( c, diff --git a/packages/core/src/services/missingLogins.ts b/packages/core/src/services/missingLogins.ts index 936850e2..ecf00def 100644 --- a/packages/core/src/services/missingLogins.ts +++ b/packages/core/src/services/missingLogins.ts @@ -16,6 +16,7 @@ import { all, get, insert, run, tx, update } from "../db"; import { bus } from "../events/bus"; import { addCredentialDomain, getCredential, linkCredentialTotp, listCredentials } from "../vault/credentials"; import { getTotp, issuerMatchesCredential, listTotp } from "../vault/totp"; +import { brandScore } from "../vault/totpGuess"; import { audit } from "./audit"; import { notify } from "./notifications"; import { badRequest, domainMatches, hostnameOf, newId, notFound, now, truncate } from "../util"; @@ -195,11 +196,12 @@ export function totpChoicesForMissingLogin(id: string): MissingLoginTotpChoices const logins = siteLogins(row, host); const credentials = new Map(listCredentials({ workspaceId: "all" }).map((c) => [c.id, c])); const site = { name: row.service, url: row.url || `https://${host}`, domains: JSON.stringify([host]) }; + const siteBrand = { name: row.service, url: site.url, domains: [host] }; const scored = listTotp({ workspaceId: "all" }) .map((t) => { const linked = t.credentialId ? credentials.get(t.credentialId) : undefined; let score = 0; - if (t.issuer && issuerMatchesCredential(t.issuer, site)) score += 3; + if ((t.issuer && issuerMatchesCredential(t.issuer, site)) || brandScore(siteBrand, t) >= 0.75) score += 3; if (logins.some((l) => sameUser(l.username, t.accountName))) score += 3; if (linked && logins.some((l) => sameUser(l.username, linked.username))) score += 2; if (linked && covers(linked, host)) score += 2; diff --git a/packages/core/src/vault/credentials.ts b/packages/core/src/vault/credentials.ts index 6aeea5b2..4edc2002 100644 --- a/packages/core/src/vault/credentials.ts +++ b/packages/core/src/vault/credentials.ts @@ -104,6 +104,17 @@ export function linkCredentialTotp(credentialId: string, totpId: string): void { }); } +/** Link a pair unless either side got a 2FA link meanwhile (an auto-link must never replace one). Returns true if linked. */ +export function linkCredentialTotpIfFree(credentialId: string, totpId: string): boolean { + const cred = get<{ linked: string | null }>(`SELECT (SELECT t.id FROM totp t WHERE t.id = credentials.totp_id) AS linked FROM credentials WHERE id = ?`, credentialId); + const totp = get<{ linked: string | null }>(`SELECT (SELECT c.id FROM credentials c WHERE c.id = totp.credential_id) AS linked FROM totp WHERE id = ?`, totpId); + if (!cred || !totp || cred.linked || totp.linked) return false; + linkCredentialTotp(credentialId, totpId); + bus.changed("totp"); + bus.changed("credentials"); + return true; +} + /** Remove every link involving this credential. Returns true if a TOTP entry was changed. */ export function unlinkCredential(credentialId: string): boolean { const ts = now(); diff --git a/packages/core/src/vault/totpGuess.ts b/packages/core/src/vault/totpGuess.ts new file mode 100644 index 00000000..d1d418e4 --- /dev/null +++ b/packages/core/src/vault/totpGuess.ts @@ -0,0 +1,187 @@ +/** + * Best guess which 2FA entry belongs to a login that has none linked: the issuer is compared with the login's + * brand (name, URL and domains, fuzzy and with a few known aliases), the account name with its username. + * A guess is tried on the login's own site only; when the site accepts the code the pair is linked for good + * (see vault_fill_totp). Pairs that are sure enough are offered to the human to link in one click. + */ +import type { Credential, TotpAutoLinkSuggestion, TotpEntry } from "@godmode/shared"; +import { hostnameOf } from "../util"; +import { siteLabel } from "./match"; + +const compact = (s: string) => s.toLowerCase().replace(/[^a-z0-9]/g, ""); + +/** Brands that sign in under another name (the issuer an authenticator shows vs. the site's domain). */ +const ALIASES: string[][] = [ + ["aws", "amazonwebservices", "amazonaws", "awsamazon"], + ["google", "gmail", "youtube", "googleworkspace", "gsuite"], + ["microsoft", "live", "outlook", "office", "office365", "microsoftonline", "azure", "xbox", "hotmail"], + ["x", "twitter"], + ["meta", "facebook", "instagram", "fb", "whatsapp"], + ["apple", "icloud", "appleid"], + ["atlassian", "jira", "confluence", "bitbucket", "trello"], + ["shopify", "myshopify"], + ["godaddy", "secureserver"], + ["digitalocean", "do"], + ["cloudflare", "cf"], + ["proton", "protonmail", "protonme"], +]; +const ALIAS_OF = new Map(); +ALIASES.forEach((group, i) => group.forEach((name) => ALIAS_OF.set(name, i))); + +/** Words that say nothing about the brand. */ +const NOISE = new Set([ + ...["www", "login", "signin", "account", "accounts", "auth", "app", "my", "admin", "portal", "secure", "id", "sso", "the", "and"], + ...["com", "de", "net", "org", "io", "inc", "gmbh", "ltd", "llc", "business", "work", "personal", "private", "privat", "dashboard"], + ...["console", "online", "cloud", "mail", "test", "dev", "prod", "team", "company", "new", "old"], +]); + +function levenshtein(a: string, b: string): number { + if (a === b) return 0; + let prev = Array.from({ length: b.length + 1 }, (_, i) => i); + for (let i = 1; i <= a.length; i++) { + const cur = [i]; + for (let j = 1; j <= b.length; j++) cur[j] = Math.min(prev[j]! + 1, cur[j - 1]! + 1, prev[j - 1]! + (a[i - 1] === b[j - 1] ? 0 : 1)); + prev = cur; + } + return prev[b.length]!; +} + +/** How alike two brand tokens are, 0..1. */ +function tokenSimilarity(a: string, b: string): number { + if (!a || !b) return 0; + if (a === b) return 1; + const ga = ALIAS_OF.get(a); + if (ga !== undefined && ga === ALIAS_OF.get(b)) return 0.95; + const [short, long] = a.length <= b.length ? [a, b] : [b, a]; + // A short brand inside a much longer one ("meta" in "metabase") is only worth trying, never sure. + const share = short.length / long.length; + if (short.length >= 4 && long.startsWith(short)) return share > 0.5 ? 0.85 : 0.65; + if (short.length >= 4 && long.includes(short)) return share > 0.5 ? 0.75 : 0.62; + if (short.length < 4) return 0; + const ratio = 1 - levenshtein(a, b) / long.length; + return ratio >= 0.75 ? ratio * 0.85 : 0; +} + +function words(s: string): string[] { + return s + .toLowerCase() + .split(/[^a-z0-9]+/) + .filter((w) => w.length >= 2 && !NOISE.has(w)); +} + +/** Brand tokens of a login: its name (whole + words) and the label of each of its sites. */ +export function loginBrandTokens(cred: Pick): Set { + const out = new Set(); + const whole = compact(cred.name); + if (whole) out.add(whole); + for (const w of words(cred.name)) out.add(w); + for (const site of [...cred.domains, cred.url]) { + const label = site ? siteLabel(site) : ""; + if (label && !NOISE.has(label)) out.add(label); + } + return out; +} + +/** Brand tokens of a 2FA entry: its issuer, or (when there is none) the part of the account name before a colon or a site in it. */ +export function totpBrandTokens(entry: Pick): Set { + const out = new Set(); + const add = (s: string) => { + const whole = compact(s); + if (whole) out.add(whole); + for (const w of words(s)) out.add(w); + const host = hostnameOf(s.trim()); + if (host.includes(".") && !/\s/.test(s.trim())) { + const label = siteLabel(host); + if (label) out.add(label); + } + }; + if (entry.issuer.trim()) add(entry.issuer); + const prefix = entry.accountName.includes(":") ? entry.accountName.split(":")[0]! : ""; + if (prefix.trim()) add(prefix); + if (!out.size && entry.accountName && !entry.accountName.includes("@")) add(entry.accountName); + return out; +} + +/** How well the 2FA entry's site matches the login's, 0..1. */ +export function brandScore(cred: Pick, entry: Pick): number { + const a = loginBrandTokens(cred); + const b = totpBrandTokens(entry); + let best = 0; + for (const x of a) for (const y of b) best = Math.max(best, tokenSimilarity(x, y)); + return best; +} + +/** How well the 2FA entry's account matches the login's username, 0..1. */ +export function accountScore(username: string, accountName: string): number { + const u = username.trim().toLowerCase(); + const raw = accountName.trim().toLowerCase(); + const acc = raw.includes(":") ? raw.slice(raw.lastIndexOf(":") + 1).trim() : raw; + if (!u || !acc) return 0; + if (u === acc) return 1; + const local = (s: string) => (s.includes("@") ? s.slice(0, s.indexOf("@")) : s); + if (local(u).length >= 3 && local(u) === local(acc)) return 0.7; + const [short, long] = u.length <= acc.length ? [u, acc] : [acc, u]; + if (short.length >= 4 && long.includes(short)) return 0.5; + return 0; +} + +/** Likelihood (0..1) that `entry` is the 2FA of `cred`. The site has to match: a shared email alone proves nothing. */ +export function totpMatchScore( + cred: Pick, + entry: Pick, +): number { + const brand = brandScore(cred, entry); + if (brand < 0.6) return 0; + const account = accountScore(cred.username, entry.accountName); + const hasAccount = !!entry.accountName.trim() && !!cred.username.trim(); + // A different account on the same site is a weaker guess, not a wrong one (an alias or display name). + return Math.round((brand * 0.7 + (hasAccount ? account : 0.5) * 0.3) * 1000) / 1000; +} + +/** Lowest score worth typing a guessed code for. */ +export const GUESS_MIN_SCORE = 0.5; +/** A pair this sure (and without a close rival) is offered for one-click linking. */ +export const SURE_SCORE = 0.8; +const RIVAL_GAP = 0.1; + +const linkCompatible = (credWs: string | null, totpWs: string | null) => totpWs === null || totpWs === credWs; + +/** Free 2FA entries that could belong to this login, most likely first. */ +export function rankTotpGuesses(cred: Credential, entries: TotpEntry[], exclude: ReadonlySet = new Set()): { entry: TotpEntry; score: number }[] { + return entries + .filter((t) => !t.credentialId && !exclude.has(t.id) && linkCompatible(cred.workspaceId, t.workspaceId)) + .map((entry) => ({ entry, score: totpMatchScore(cred, entry) })) + .filter((g) => g.score >= GUESS_MIN_SCORE) + .sort((a, b) => b.score - a.score); +} + +/** + * One-to-one pairs of logins without 2FA and free 2FA entries, best first. `sure` marks pairs that win clearly on + * both sides (no rival within RIVAL_GAP for the login or the entry). + */ +export function suggestTotpLinks(creds: Credential[], entries: TotpEntry[]): TotpAutoLinkSuggestion[] { + const freeCreds = creds.filter((c) => !c.totpId); + const freeTotp = entries.filter((t) => !t.credentialId); + const pairs: { credentialId: string; totpId: string; score: number }[] = []; + for (const c of freeCreds) { + for (const t of freeTotp) { + if (!linkCompatible(c.workspaceId, t.workspaceId)) continue; + const score = totpMatchScore(c, t); + if (score >= GUESS_MIN_SCORE) pairs.push({ credentialId: c.id, totpId: t.id, score }); + } + } + pairs.sort((a, b) => b.score - a.score); + const usedCred = new Set(); + const usedTotp = new Set(); + const out: TotpAutoLinkSuggestion[] = []; + for (const p of pairs) { + if (usedCred.has(p.credentialId) || usedTotp.has(p.totpId)) continue; + usedCred.add(p.credentialId); + usedTotp.add(p.totpId); + const rival = pairs.some( + (q) => q !== p && (q.credentialId === p.credentialId || q.totpId === p.totpId) && q.score > p.score - RIVAL_GAP, + ); + out.push({ ...p, sure: p.score >= SURE_SCORE && !rival }); + } + return out; +} diff --git a/packages/core/src/vault/totpTrial.ts b/packages/core/src/vault/totpTrial.ts new file mode 100644 index 00000000..87f14925 --- /dev/null +++ b/packages/core/src/vault/totpTrial.ts @@ -0,0 +1,108 @@ +/** + * Trying a guessed 2FA entry on a login's site: which entries a chat already tried for a login, and a watcher that + * decides from the page whether the site took the code (the code field is gone, no error, still on the login's + * site). Only one guess per chat and login is watched at a time; a new guess means the last one failed. + */ +import type { OtpProbe } from "../browser/fill"; + +export type TrialOutcome = "accepted" | "rejected" | "timeout" | "superseded"; + +interface Trial { + tried: Set; + watch: { totpId: string; cancel: () => void } | null; + touchedAt: number; +} + +const trials = new Map(); +const TRIAL_TTL_MS = 60 * 60_000; + +const keyOf = (conversationId: string, credentialId: string) => `${conversationId}:${credentialId}`; + +function trialFor(conversationId: string, credentialId: string): Trial { + const now = Date.now(); + for (const [k, t] of trials) if (now - t.touchedAt > TRIAL_TTL_MS && !t.watch) trials.delete(k); + const key = keyOf(conversationId, credentialId); + let t = trials.get(key); + if (!t) trials.set(key, (t = { tried: new Set(), watch: null, touchedAt: now })); + t.touchedAt = now; + return t; +} + +/** Entries this chat already tried for the login (they failed or are being checked). */ +export function triedTotp(conversationId: string, credentialId: string): ReadonlySet { + return trialFor(conversationId, credentialId).tried; +} + +export interface WatchOptions { + probe: () => Promise; + /** The page is still on the login's site. */ + onSite: (url: string) => boolean; + /** Called once when the site took the code. */ + onAccepted: () => void; + timeoutMs?: number; + intervalMs?: number; +} + +/** + * Start checking whether the site takes the guessed entry's code. Cancels the watch of an earlier guess for the + * same login (the agent asking again means that one was wrong). Resolves when the outcome is known. + */ +export function watchTotpGuess(conversationId: string, credentialId: string, totpId: string, opts: WatchOptions): Promise { + const trial = trialFor(conversationId, credentialId); + trial.watch?.cancel(); + trial.tried.add(totpId); + const timeoutMs = opts.timeoutMs ?? 90_000; + const intervalMs = opts.intervalMs ?? 1200; + let cancelled = false; + let timer: ReturnType | null = null; + let wake: (() => void) | null = null; + const watch = { + totpId, + cancel: () => { + cancelled = true; + if (timer) clearTimeout(timer); + wake?.(); + }, + }; + trial.watch = watch; + const pause = () => + new Promise((resolve) => { + wake = resolve; + timer = setTimeout(resolve, intervalMs); + }); + + return (async (): Promise => { + const deadline = Date.now() + timeoutMs; + let clear = 0; + try { + while (Date.now() < deadline) { + await pause(); + if (cancelled) return "superseded"; + const page = await opts.probe(); + if (cancelled) return "superseded"; + if (!page) continue; + if (!opts.onSite(page.url)) return "rejected"; + if (page.otpField && page.error) return "rejected"; + if (page.otpField || page.error) { + clear = 0; + continue; + } + // Two clean looks in a row, so a page that is just re-rendering its form doesn't count. + if (++clear >= 2) { + opts.onAccepted(); + trial.tried.delete(totpId); + return "accepted"; + } + } + return "timeout"; + } finally { + if (trial.watch === watch) trial.watch = null; + } + })(); +} + +/** For tests. */ +export function resetTotpTrials(): void { + for (const t of trials.values()) t.watch?.cancel(); + trials.clear(); +} diff --git a/packages/core/src/vm/guest.ts b/packages/core/src/vm/guest.ts index 9c5ca4e1..887d6ea0 100644 --- a/packages/core/src/vm/guest.ts +++ b/packages/core/src/vm/guest.ts @@ -22,7 +22,7 @@ import type { Subprocess } from "bun"; import { BROWSER_USE_PYTHON, BROWSER_USE_SPEC, BROWSER_USE_VERSION, browserUseConfig } from "../browser/browserUse"; import { CdpClient, pickActivePage, probeCdp } from "../browser/cdp"; import { findFreePort } from "../browser/chrome"; -import { fillIntoActivePage, fillPrecheck, type FillOptions, type FillResult } from "../browser/fill"; +import { fillIntoActivePage, fillPrecheck, probeOtpInActivePage, type OtpProbe, type FillOptions, type FillResult } from "../browser/fill"; import { solveInBrowser, type SolveOptions, type SolveResult } from "../browser/captcha"; import { stealthArgs } from "../browser/stealth"; import { CUA_DRIVER_SPEC, CUA_DRIVER_VERSION } from "../computer/cua"; @@ -526,6 +526,19 @@ export async function fillIntoVm(vmId: string, opts: FillOptions & { urlContains } } +/** Whether the VM's active page still shows a 2FA code field; null when its browser can't be reached. */ +export async function probeOtpInVm(vmId: string): Promise { + let browser: Awaited> = null; + try { + browser = await guestBrowser(vmId); + return browser ? await probeOtpInActivePage(browser) : null; + } catch { + return null; + } finally { + browser?.client.close(); + } +} + /** Solve the CAPTCHA on the page the VM's Chrome shows through the selected solving service. */ export async function solveCaptchaInVm(vmId: string, opts: SolveOptions): Promise { let browser: Awaited>; diff --git a/packages/core/test/fixtures/runner-harness.ts b/packages/core/test/fixtures/runner-harness.ts index 6cc3ceda..2612255a 100644 --- a/packages/core/test/fixtures/runner-harness.ts +++ b/packages/core/test/fixtures/runner-harness.ts @@ -40,6 +40,8 @@ export const fills: { }[] = []; /** Runs that asked for browser tools, with the chat they are bound to. */ export const browserRuns: { agentId: string; runId: string; conversationId: string; profileId: string | null }[] = []; +/** What the fake page shows after a 2FA fill, one look per entry (the last one repeats); empty = the code was taken. */ +export const otpPages: { url: string; otpField: boolean; error: boolean }[] = []; /** When set, the fake fill fails with an error message that echoes the typed text (leak test). */ export const fillFailure = { echoText: false }; @@ -88,6 +90,10 @@ mock.module("../../src/browser/manager", () => ({ if (fillFailure.echoText) return { ok: false, url: "https://example.com/login", detail: `Typing failed near "${opts.text}"` }; return { ok: true, url: "https://example.com/login", detail: "filled" }; }, + probeOtpPage: async (profileId: string, conversationId?: string) => { + if (profileId !== FAKE_PROFILE_ID) return realBrowser.probeOtpPage(profileId, conversationId); + return (otpPages.length > 1 ? otpPages.shift() : otpPages[0]) ?? { url: "https://example.com/home", otpField: false, error: false }; + }, currentPage: async (profileId: string, conversationId?: string) => profileId === FAKE_PROFILE_ID ? { url: "https://app.example.com/login", title: "Login" } : realBrowser.currentPage(profileId, conversationId), })); diff --git a/packages/core/test/mcp.test.ts b/packages/core/test/mcp.test.ts index 5a4bce3e..b8b3d138 100644 --- a/packages/core/test/mcp.test.ts +++ b/packages/core/test/mcp.test.ts @@ -4,10 +4,10 @@ import { mkdtempSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import type { Agent, Credential, TotpEntry } from "@godmode/shared"; -import { argValue, fillFailure, fills, invocations, makeAgent, setupEnv, type TestEnv } from "./fixtures/runner-harness"; +import { argValue, fillFailure, fills, invocations, otpPages, makeAgent, setupEnv, type TestEnv } from "./fixtures/runner-harness"; import * as vault from "../src/vault/vault"; import { createCredential, deleteCredential, getCredential, listCredentials } from "../src/vault/credentials"; -import { createTotp } from "../src/vault/totp"; +import { createTotp, deleteTotp, getTotp } from "../src/vault/totp"; import { listAudit } from "../src/services/audit"; import { listMissingLogins } from "../src/services/missingLogins"; import { listNotifications } from "../src/services/notifications"; @@ -280,6 +280,55 @@ describe("vault tools", () => { expect([fills[2]!.allowedHosts, fills[2]!.httpHosts]).toEqual([["127.0.0.1"], ["127.0.0.1"]]); }); + test("vault_fill_totp tries the most likely 2FA for a login without one and links it once the site takes it", async () => { + fills.length = 0; + otpPages.length = 0; + const login = createCredential({ name: "Acme Portal", url: "https://example.com/acme", username: "zoe@acme.test", password: "acme-pass-1234" }); + const right = createTotp({ issuer: "Acme", accountName: "zoe@acme.test", secret: "MFRGGZDFMZTWQ2LK" }); + const wrong = createTotp({ issuer: "Acme Inc", accountName: "", secret: "GEZDGNBVGY3TQOJQ" }); + try { + const r = await call(tokens[worker.id]!, "vault_fill_totp", { credentialId: login.id, submit: true }); + expect(r.isError).toBeUndefined(); + expect(fills).toHaveLength(1); + expect(fills[0]!.allowedHosts).toEqual(["example.com"]); + expect(r.content[0]!.text).toContain("The site accepted it"); + expect(getCredential(login.id).totpId).toBe(right.id); + expect(getTotp(wrong.id).credentialId).toBeNull(); + expect(listAudit(50, "totp.autolink").some((a) => a.target === right.id)).toBe(true); + } finally { + deleteCredential(login.id); + deleteTotp(right.id); + deleteTotp(wrong.id); + } + }, 30_000); + + test("vault_fill_totp moves on to the next guess when the site rejects one, and never links a rejected one", async () => { + fills.length = 0; + otpPages.length = 0; + otpPages.push({ url: "https://example.com/2fa", otpField: true, error: true }); + const login = createCredential({ name: "Globex", url: "https://example.com/globex", username: "hank", password: "globex-pass-1234" }); + const first = createTotp({ issuer: "Globex", accountName: "hank", secret: "MFRGGZDFMZTWQ2LK" }); + const second = createTotp({ issuer: "Globex Corp", accountName: "", secret: "GEZDGNBVGY3TQOJQ" }); + try { + const a = await call(tokens[worker.id]!, "vault_fill_totp", { credentialId: login.id, submit: true }); + expect(a.content[0]!.text).toContain("looks rejected"); + expect(a.content[0]!.text).toContain("1 left"); + const b = await call(tokens[worker.id]!, "vault_fill_totp", { credentialId: login.id, submit: true }); + expect(b.content[0]!.text).toContain("looks rejected"); + expect(fills.map((f) => f.text)).toHaveLength(2); + const c = await call(tokens[worker.id]!, "vault_fill_totp", { credentialId: login.id, submit: true }); + expect(c.isError).toBe(true); + expect(c.content[0]!.text).toContain("report_missing_login"); + expect(fills).toHaveLength(2); + expect(getCredential(login.id).totpId).toBeNull(); + } finally { + otpPages.length = 0; + deleteCredential(login.id); + deleteTotp(first.id); + deleteTotp(second.id); + } + }, 30_000); + test("vault_fill_totp uses the 2FA of the same account's other login for the site", async () => { fills.length = 0; const twin = createCredential({ name: "Example support", url: "https://example.com/support", username: "Alice", password: "twin-pass-1234" }); diff --git a/packages/core/test/totp-guess.test.ts b/packages/core/test/totp-guess.test.ts new file mode 100644 index 00000000..e6e95528 --- /dev/null +++ b/packages/core/test/totp-guess.test.ts @@ -0,0 +1,131 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import type { Credential, TotpEntry } from "@godmode/shared"; +import { rankTotpGuesses, suggestTotpLinks, totpMatchScore } from "../src/vault/totpGuess"; +import { resetTotpTrials, triedTotp, watchTotpGuess } from "../src/vault/totpTrial"; + +const cred = (id: string, name: string, url: string, username = "", extra: Partial = {}): Credential => + ({ id, workspaceId: null, name, url, domains: [], username, hasPassword: true, totpId: null, tags: [], notes: null, lastUsedAt: null, createdAt: "", updatedAt: "", ...extra }) as Credential; +const totp = (id: string, issuer: string, accountName = "", extra: Partial = {}): TotpEntry => + ({ id, workspaceId: null, issuer, accountName, algorithm: "SHA1", digits: 6, period: 30, credentialId: null, icon: null, lastUsedAt: null, createdAt: "", updatedAt: "", ...extra }) as TotpEntry; + +describe("totpMatchScore", () => { + test("same brand and account scores highest", () => { + const c = cred("c1", "GitHub", "https://github.com/login", "daniel@codext.de"); + expect(totpMatchScore(c, totp("t", "GitHub", "daniel@codext.de"))).toBe(1); + expect(totpMatchScore(c, totp("t", "github.com", "daniel@codext.de"))).toBe(1); + }); + + test("fuzzy brand names, prefixes and aliases still match", () => { + expect(totpMatchScore(cred("c", "Paypal Business", "https://www.paypal.com"), totp("t", "PayPal"))).toBeGreaterThan(0.8); + expect(totpMatchScore(cred("c", "AWS Console", "https://console.aws.amazon.com"), totp("t", "Amazon Web Services"))).toBeGreaterThan(0.6); + expect(totpMatchScore(cred("c", "Twitter", "https://x.com"), totp("t", "X"))).toBeGreaterThan(0.6); + expect(totpMatchScore(cred("c", "Hetzner", "https://accounts.hetzner.com"), totp("t", "Hetzner Online"))).toBeGreaterThan(0.6); + expect(totpMatchScore(cred("c", "Stripe", "https://dashboard.stripe.com"), totp("t", "", "Stripe:daniel@codext.de"))).toBeGreaterThan(0.6); + }); + + test("a short brand inside a longer one is only a weak guess", () => { + const score = totpMatchScore(cred("c", "Metabase", "https://metabase.example.com", "dan@x.de"), totp("t", "Meta", "dan@x.de")); + expect(score).toBeGreaterThan(0); + expect(score).toBeLessThan(0.8); + expect(totpMatchScore(cred("c", "Google Work", "https://accounts.google.com"), totp("t", "Workday"))).toBe(0); + }); + + test("a shared email alone is not a match", () => { + expect(totpMatchScore(cred("c", "GitHub", "https://github.com", "daniel@codext.de"), totp("t", "Shopify", "daniel@codext.de"))).toBe(0); + expect(totpMatchScore(cred("c", "Google", "https://accounts.google.com"), totp("t", "GitLab"))).toBe(0); + }); + + test("the right account beats another account of the same site", () => { + const c = cred("c", "Google", "https://accounts.google.com", "daniel@solakon.de"); + expect(totpMatchScore(c, totp("a", "Google", "daniel@solakon.de"))).toBeGreaterThan(totpMatchScore(c, totp("b", "Google", "other@gmail.com"))); + }); +}); + +describe("rankTotpGuesses", () => { + test("skips linked, tried and out-of-scope entries", () => { + const c = cred("c", "GitHub", "https://github.com", "dan", { workspaceId: "ws1" }); + const entries = [ + totp("linked", "GitHub", "dan", { credentialId: "other" }), + totp("tried", "GitHub", "dan"), + totp("ws2", "GitHub", "dan", { workspaceId: "ws2" }), + totp("ok", "GitHub", ""), + totp("nope", "Slack", "dan"), + ]; + expect(rankTotpGuesses(c, entries, new Set(["tried"])).map((g) => g.entry.id)).toEqual(["ok"]); + }); +}); + +describe("suggestTotpLinks", () => { + test("pairs one to one and marks clear winners as sure", () => { + const creds = [ + cred("gh", "GitHub", "https://github.com", "dan"), + cred("g1", "Google", "https://accounts.google.com", "a@x.de"), + cred("g2", "Google Work", "https://accounts.google.com", "b@x.de"), + cred("done", "Slack", "https://slack.com", "dan", { totpId: "slack" }), + ]; + const entries = [totp("tgh", "GitHub", "dan"), totp("tg1", "Google", "a@x.de"), totp("tg2", "Google", "b@x.de"), totp("slack", "Slack", "dan", { credentialId: "done" })]; + const out = suggestTotpLinks(creds, entries); + const by = new Map(out.map((s) => [s.credentialId, s])); + expect(by.get("gh")).toMatchObject({ totpId: "tgh", sure: true }); + expect(by.get("g1")?.totpId).toBe("tg1"); + expect(by.get("g2")?.totpId).toBe("tg2"); + expect(by.has("done")).toBe(false); + expect(new Set(out.map((s) => s.totpId)).size).toBe(out.length); + }); + + test("two equally good codes for one login are not sure", () => { + const out = suggestTotpLinks([cred("gh", "GitHub", "https://github.com")], [totp("a", "GitHub"), totp("b", "GitHub")]); + expect(out).toHaveLength(1); + expect(out[0]!.sure).toBe(false); + }); +}); + +describe("watchTotpGuess", () => { + afterEach(resetTotpTrials); + const page = (otpField: boolean, error = false, url = "https://github.com/") => ({ url, otpField, error }); + const run = (pages: ReturnType[], onSite = (u: string) => u.startsWith("https://github.com")) => { + let i = 0; + let accepted = 0; + const outcome = watchTotpGuess("chat", "cred", "t1", { + probe: async () => pages[Math.min(i++, pages.length - 1)]!, + onSite, + onAccepted: () => accepted++, + intervalMs: 5, + timeoutMs: 400, + }); + return { outcome, accepted: () => accepted }; + }; + + test("accepted once the code field is gone twice in a row", async () => { + const r = run([page(true), page(false), page(false)]); + expect(await r.outcome).toBe("accepted"); + expect(r.accepted()).toBe(1); + expect(triedTotp("chat", "cred").has("t1")).toBe(false); + }); + + test("rejected when the page shows an error next to the field", async () => { + const r = run([page(true), page(true, true)]); + expect(await r.outcome).toBe("rejected"); + expect(r.accepted()).toBe(0); + expect(triedTotp("chat", "cred").has("t1")).toBe(true); + }); + + test("leaving the login's site is not a success", async () => { + const r = run([page(false, false, "https://evil.example/")]); + expect(await r.outcome).toBe("rejected"); + expect(r.accepted()).toBe(0); + }); + + test("a new guess supersedes the previous watch", async () => { + const first = run([page(true)]); + const second = watchTotpGuess("chat", "cred", "t2", { probe: async () => page(true, true), onSite: () => true, onAccepted: () => {}, intervalMs: 5, timeoutMs: 200 }); + expect(await first.outcome).toBe("superseded"); + expect(await second).toBe("rejected"); + expect([...triedTotp("chat", "cred")].sort()).toEqual(["t1", "t2"]); + }); + + test("times out while the field stays without an error", async () => { + const r = run([page(true)]); + expect(await r.outcome).toBe("timeout"); + }); +}); diff --git a/packages/shared/src/models.ts b/packages/shared/src/models.ts index 155c14c2..129bcd96 100644 --- a/packages/shared/src/models.ts +++ b/packages/shared/src/models.ts @@ -949,6 +949,20 @@ export interface TotpEntry { updatedAt: ISODate; } +/** A login without 2FA and the free 2FA entry that most likely belongs to it. */ +export interface TotpAutoLinkSuggestion { + credentialId: ID; + totpId: ID; + /** 0..1 */ + score: number; + /** Clear winner on both sides: safe to link in one click. */ + sure: boolean; +} + +export interface TotpAutoLinkResult { + linked: { credentialId: ID; totpId: ID }[]; +} + export interface TotpCode { id: ID; code: string;