diff --git a/CHANGELOG.md b/CHANGELOG.md index 3d6a073..8f5007d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,15 @@ # Changelog +## [0.1.60] - 01-07-2026 + +### Added + +- **Encrypted credential vault + TOTP** (`browser_vault`, 49 → **50** tools) — store credentials locally (AES-256-GCM, key at `~/.fuse-browser/vault.key` `0600` or `FUSE_VAULT_KEY`) and fill them by reference: `browser_login { credentialRef }` and `browser_fill { credentialRef, field }` resolve username / password / TOTP **server-side**, so the secret never enters the MCP arguments, the tool result, or the model context. RFC 6238 TOTP is generated on the fly (zero dependencies, `node:crypto`). +- **Origin binding (anti-phishing)** — every credential is bound to one or more `scheme://host` origins; a fill is refused on any other origin (`FUSE_VAULT_ALLOW_ANY_ORIGIN=1` opts out). A prompt-injected login on a look-alike domain is rejected. +- **Snapshot redaction** — Tier 1 nulls `input[type=password]` values browser-side (with a `hasValue` flag); Tier 2 scrubs any vault-filled secret (password/TOTP) from `browser_snapshot` / `browser_act` output before it reaches the LLM. +- **CLI `vault set|list|rm|test`** — the only write path; secrets are read from stdin **without echo**, never from argv. `list` returns metadata only; `test` prints the current TOTP code. +- Verified: 22 new unit tests (crypto round-trip/tamper, RFC 6238 vectors, origin binding, fill wiring, list-only tool, redaction) + a real-Chromium integration test for Tier-1 masking. + ## [0.1.59] - 13-06-2026 ### Added diff --git a/README.md b/README.md index ae40cf5..f72bdb9 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ Shadow DOM + iframes), multi-step plans, structured extraction, visual diff, and guardrails** for payments and bookings. It drives real Chromium, so it reads **Next.js / SPA** pages after hydration — not just static HTML. -> 49 MCP tools · stealth + rotating proxies · HTTP fast-path (single, batch & crawl) · full-site content + screenshot snapshots · structured per-card product extraction · form fill + file upload · hover + drag&drop · PDF export · cookies / permissions · network mocking · clipboard · virtualized-list scraping + autoscroll · tabs / dialogs / downloads · console + network logs · MCP screenshot resources · `FUSE_CAPS` tool-group filtering · named auth profiles (cookies + localStorage + IndexedDB, saved at login) · `blockResources` · HAR record/replay · pixel visual-diff · human handoff + live view. +> 50 MCP tools · stealth + rotating proxies · HTTP fast-path (single, batch & crawl) · full-site content + screenshot snapshots · structured per-card product extraction · form fill + file upload · encrypted credential vault + TOTP (fill-by-reference, origin-bound, never exposed to the LLM) · hover + drag&drop · PDF export · cookies / permissions · network mocking · clipboard · virtualized-list scraping + autoscroll · tabs / dialogs / downloads · console + network logs · MCP screenshot resources · `FUSE_CAPS` tool-group filtering · named auth profiles (cookies + localStorage + IndexedDB, saved at login) · `blockResources` · HAR record/replay · pixel visual-diff · human handoff + live view. ## Install @@ -68,7 +68,7 @@ Full reference in **[`docs/`](./docs/README.md)**: [Installation](./docs/installation.md) · [CLI](./docs/cli.md) · -[MCP tools (49)](./docs/mcp-tools.md) · +[MCP tools (50)](./docs/mcp-tools.md) · [Configuration](./docs/configuration.md) · [Sessions](./docs/sessions.md) · [Extraction](./docs/extraction.md) · diff --git a/docs/README.md b/docs/README.md index f5d77a7..fc4deac 100644 --- a/docs/README.md +++ b/docs/README.md @@ -7,7 +7,7 @@ New here? Start with the root [README](../README.md), then dive in: | --- | --- | | [Installation](./installation.md) | Requirements, install, Chromium, MCP registration, the three ways to get a browser | | [CLI](./cli.md) | `probe` / `fetch` / `fetch-batch` / `crawl` / `collect-batch` / `shots` / `shots-batch` / `site-shots` / `serp-batch` + one-shot page commands (`run` / `products` / `extract` / `snapshot` / `screenshot` / `inspect`) + every flag | -| [MCP tools](./mcp-tools.md) | All 49 tools with parameters and examples | +| [MCP tools](./mcp-tools.md) | All 50 tools with parameters and examples | | [Configuration](./configuration.md) | `AgentOptions`, `FUSE_*` env vars, identity, retry, output location | | [Sessions](./sessions.md) | Session lifecycle, auto crash recovery, `storageState` auto-save, HAR record/replay, CDP attach | | [Extraction](./extraction.md) | `browser_extract` / `extract_schema` / `collect` + the clean→validate→dedupe→emit pipeline | diff --git a/docs/cli.md b/docs/cli.md index be07c40..6535792 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -174,6 +174,21 @@ fuse-browser inspect https://example.com --ref 0 --- +## `vault ` + +Manage the local encrypted credential vault (AES-256-GCM). Secrets are read from stdin **without echo**, never from argv, and never leave the machine except to fill a field in the browser. This is the only vault write path — the MCP surface is read-only. + +```bash +fuse-browser vault set github # prompts: username, password, TOTP (optional), allowed origins +fuse-browser vault list # ref, username, totp?, origins — no secrets +fuse-browser vault test github # prints the current TOTP code + confirms the password is set +fuse-browser vault rm github +``` + +Each credential is bound to one or more origins (`scheme://host`); a fill is refused on any other origin (anti-phishing). Reference it from an agent with `browser_login { credentialRef: "github" }` or `browser_fill { credentialRef, field }` — the secret is resolved server-side and never enters the model context. Set `FUSE_VAULT_KEY` to inject the master key from a secret manager; see [configuration](./configuration.md). + +--- + ## Approval guardrail Sensitive actions (pay / book / checkout / confirm) are blocked unless `--approved` is passed. When blocked, `probe` prints `BLOCKED: ` to stderr and exits `2`. `--approved` sets the `humanApproved` flag on the probe. diff --git a/docs/configuration.md b/docs/configuration.md index fca35c6..902c3a0 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -78,8 +78,10 @@ Read by `envAgentDefaults` (`src/server/env-defaults.ts`) and the proxy loader ( | `FUSE_STORAGE_STATE` | `storageStatePath` | Path to a storage-state JSON. | | `FUSE_OUTPUT_DIR` | `outputDir` | Override the artifact output directory. | | `FUSE_PROXIES` | proxy pool | Comma- or newline-separated proxy URLs; deduped, blanks dropped. Merged with `proxiesPath`. Treat as a secret. | -| `FUSE_CAPS` | tool-group filter | Comma-separated [capability groups](./mcp-tools.md#capability-groups-fuse_caps) to register (`core`/`batch`/`extract`/`debug`/`live`). Case-insensitive, whitespace-tolerant; unknown names are ignored. Blank/unset (or only-unknown) = all 49 tools. Server-only (no per-call/library equivalent). | +| `FUSE_CAPS` | tool-group filter | Comma-separated [capability groups](./mcp-tools.md#capability-groups-fuse_caps) to register (`core`/`batch`/`extract`/`debug`/`live`). Case-insensitive, whitespace-tolerant; unknown names are ignored. Blank/unset (or only-unknown) = all 50 tools. Server-only (no per-call/library equivalent). | | `FUSE_NETLOG_MAX` | network/console log cap | Max entries kept per session in `browser_console` / `browser_network` (oldest dropped). Positive integer; default `250`. | +| `FUSE_VAULT_KEY` | vault master key | Base64 of exactly 32 bytes. When unset, a random key is generated at `/vault.key` (`0600`). Lets you inject the key from a secret manager instead of the disk. At-rest AES-256-GCM protects the blob when it travels without its key (backup, sync, accidental `git add`) — it is **not** a defense against local malware running as the same user. Treat as a secret. | +| `FUSE_VAULT_ALLOW_ANY_ORIGIN` | vault origin binding | Set to `1` to disable origin binding on credential fills (off by default). Removes the anti-phishing guard that refuses a fill on any origin other than the credential's bound one — only for trusted automation. | ### MCP config example diff --git a/docs/mcp-tools.md b/docs/mcp-tools.md index e6fa2fd..1b887c8 100644 --- a/docs/mcp-tools.md +++ b/docs/mcp-tools.md @@ -1,6 +1,6 @@ # MCP tools -Complete reference for the 49 `browser_*` tools exposed by the fuse-browser MCP server. +Complete reference for the 50 `browser_*` tools exposed by the fuse-browser MCP server. Tools fall into two families: @@ -14,11 +14,11 @@ The shared identity/profile options (the `agentOptionShape`) are listed once und ## Capability groups (`FUSE_CAPS`) -By default all 49 tools are registered. Set the `FUSE_CAPS` env var (comma-separated group names) to expose fewer tools — a lighter context for the LLM client: +By default all 50 tools are registered. Set the `FUSE_CAPS` env var (comma-separated group names) to expose fewer tools — a lighter context for the LLM client: | Group | Tools | | --- | --- | -| `core` | Session lifecycle (`browser_open`/`browser_status`/`browser_close`/`browser_connect`), navigation (`browser_navigate`/`browser_back`/`browser_forward`), actions (`browser_click`/`browser_fill`/`browser_login`/`browser_scroll`/`browser_press`/`browser_select`), `browser_tabs`, `browser_dialog`/`browser_downloads`, `browser_snapshot`/`browser_act`, `browser_wait`/`browser_wait_for`, `browser_screenshot`, `browser_autoscroll`. | +| `core` | Session lifecycle (`browser_open`/`browser_status`/`browser_close`/`browser_connect`), navigation (`browser_navigate`/`browser_back`/`browser_forward`), actions (`browser_click`/`browser_fill`/`browser_login`/`browser_scroll`/`browser_press`/`browser_select`), `browser_tabs`, `browser_dialog`/`browser_downloads`, `browser_snapshot`/`browser_act`, `browser_wait`/`browser_wait_for`, `browser_screenshot`, `browser_autoscroll`, `browser_vault`. | | `batch` | `browser_probe`, `browser_probe_html`, `browser_fetch`, `browser_fetch_batch`, `browser_crawl`, `browser_collect_batch`, `browser_shots_batch`, `browser_site_shots`, `browser_serp_batch`. | | `extract` | `browser_collect`, `browser_run`, `browser_extract`, `browser_extract_schema`, `browser_products`. | | `debug` | `browser_inspect`, `browser_console`, `browser_network`, `browser_visual_diff`, `browser_metrics`, `browser_pdf`, `browser_cookies`. | @@ -462,10 +462,12 @@ Fill a field in the session. | --- | --- | --- | --- | | `sessionId` | string | yes | Target session. | | `target` | string | yes | Selector or label of the field. | -| `value` | string | yes | Value to type. | +| `value` | string | no | Value to type (omit when using `credentialRef`). | +| `credentialRef` | string | no | Fill a vault secret by reference instead of `value` — resolved server-side, never exposed to the LLM. | +| `field` | enum `username`/`password`/`totp` | no | Which credential field to fill when `credentialRef` is set (default `password`). | ```json -{ "sessionId": "s_abc123", "target": "#email", "value": "a@b.com" } +{ "sessionId": "s_abc123", "target": "#otp", "credentialRef": "github", "field": "totp" } ``` ### browser_login @@ -475,14 +477,15 @@ Structured login (username + password + submit). | Param | Type | Required | Description | | --- | --- | --- | --- | | `sessionId` | string | yes | Target session. | -| `username` | string | yes | Username/email value. | -| `password` | string | yes | Password value. | +| `username` | string | no | Username/email value (omit when using `credentialRef`). | +| `password` | string | no | Password value (omit when using `credentialRef`). | +| `credentialRef` | string | no | Fill username + password from the vault — resolved server-side, never exposed to the LLM, and refused off the credential's bound origin (anti-phishing). | | `usernameTarget` | string | no | Selector for the username field (auto-detected otherwise). | | `passwordTarget` | string | no | Selector for the password field. | | `submitTarget` | string | no | Selector for the submit button. | ```json -{ "sessionId": "s_abc123", "username": "a@b.com", "password": "secret" } +{ "sessionId": "s_abc123", "credentialRef": "github" } ``` ### browser_scroll @@ -992,3 +995,18 @@ Stop a session's live view and shut down its local server. Returns `{ stopped: t ```json { "sessionId": "s_abc123" } ``` + +### browser_vault + +List stored credential references — **metadata only, never secrets**. Writing is CLI-only (`fuse-browser vault set `); the MCP surface is read-only by design so a secret never travels through a tool argument. + +| Param | Type | Required | Description | +| --- | --- | --- | --- | +| `action` | enum `list` | yes | Only `list` is supported. | +| `sessionId` | string | no | When set, only credentials whose bound origin matches the live page are returned (origin-scoped discovery). | + +Returns `{ credentials: [{ ref, username, hasTotp, origins }] }`. To actually use a secret, pass `credentialRef` to `browser_login` or `browser_fill` — the value is resolved server-side and never enters the model context. See [CLI `vault`](./cli.md#vault-setlistrmtest) to store credentials and [configuration](./configuration.md) for `FUSE_VAULT_KEY` / `FUSE_VAULT_ALLOW_ANY_ORIGIN`. + +```json +{ "action": "list" } +``` diff --git a/package.json b/package.json index 93d097a..930233a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@fusengine/browser-mcp", - "version": "0.1.59", + "version": "0.1.60", "description": "MCP server + CLI giving AI agents a real, stealth browser (Patchright/Playwright) — per-country identity, self-healing actions, snapshots, multi-step plans, structured extraction, CDP attach.", "license": "MIT", "author": "Fusengine", @@ -55,7 +55,7 @@ "typecheck": "tsc -p tsconfig.json --noEmit", "lint": "biome check src tests", "test": "bun test tests/unit", - "test:integration": "node --test --import tsx tests/integration/mcp.test.ts tests/integration/probe.test.ts tests/integration/snapshot.test.ts tests/integration/snapshot-frames.test.ts tests/integration/collect.test.ts tests/integration/collect-batch.test.ts tests/integration/selectors.test.ts tests/integration/visual-diff.test.ts tests/integration/session-state.test.ts tests/integration/pipeline.test.ts tests/integration/run.test.ts tests/integration/extract-schema.test.ts tests/integration/recovery.test.ts tests/integration/live-view.test.ts", + "test:integration": "node --test --import tsx tests/integration/mcp.test.ts tests/integration/probe.test.ts tests/integration/snapshot.test.ts tests/integration/snapshot-frames.test.ts tests/integration/collect.test.ts tests/integration/collect-batch.test.ts tests/integration/selectors.test.ts tests/integration/visual-diff.test.ts tests/integration/session-state.test.ts tests/integration/pipeline.test.ts tests/integration/run.test.ts tests/integration/extract-schema.test.ts tests/integration/recovery.test.ts tests/integration/live-view.test.ts tests/integration/vault.test.ts", "browsers": "patchright install chromium", "mcp": "node --import tsx src/bin/mcp.ts", "cli": "node --import tsx src/bin/cli.ts" diff --git a/src/bin/cli-usage.ts b/src/bin/cli-usage.ts index e2cfcc2..1eaa94d 100644 --- a/src/bin/cli-usage.ts +++ b/src/bin/cli-usage.ts @@ -26,6 +26,12 @@ Page commands (one-shot, JSON on stdout): screenshot Capture a PNG (--full-page, --output or base64) inspect Computed style + WCAG contrast for one element (--ref ) +Vault (local encrypted credentials — secrets never touch argv or the LLM): + vault set Store a credential (prompts: username/password/TOTP/origins) + vault list List stored refs — metadata only, no secrets + vault rm Delete a stored credential + vault test Print the current TOTP code + confirm the password is set + Common options: --engine playwright | patchright | firefox | webkit --country Geo/locale identity (e.g. CH, FR) --currency e.g. CHF, EUR diff --git a/src/bin/cli.ts b/src/bin/cli.ts index 2cd2e1d..1e1f005 100644 --- a/src/bin/cli.ts +++ b/src/bin/cli.ts @@ -19,6 +19,7 @@ import { runSerpBatch } from "./serp-batch-cli.js"; import { runShotsBatch } from "./shots-batch-cli.js"; import { runShots } from "./shots-cli.js"; import { runSiteShotsCli } from "./site-shots-cli.js"; +import { runVaultCli } from "./vault-cli.js"; const argv = process.argv.slice(2); handleMetaFlags(argv, CLI_USAGE); @@ -50,6 +51,8 @@ if (command === "serp-batch") { await runFetchCli(rest[0], opts); } else if (command === "probe" && rest[0]) { await runProbeCli(rest[0], opts); +} else if (command === "vault") { + await runVaultCli(rest); } else if (command && (await routePageCommand(command, rest, opts))) { // Handled by a one-shot page command (run/products/extract/snapshot/screenshot/inspect). } else { diff --git a/src/bin/prompt-hidden.ts b/src/bin/prompt-hidden.ts new file mode 100644 index 0000000..5e3cf86 --- /dev/null +++ b/src/bin/prompt-hidden.ts @@ -0,0 +1,43 @@ +/** + * Read lines from stdin for CLI prompts. `promptHidden` suppresses the echo of + * typed characters (for secrets) by muting readline's output writer once the + * prompt has been shown — so secrets never land on screen or in scrollback. + * @module bin/prompt-hidden + */ +import { createInterface, type Interface } from "node:readline"; + +type MutableInterface = Interface & { + _writeToOutput?: (chunk: string) => void; + _muted?: boolean; +}; + +/** Ask `query` and resolve the typed line with the echo suppressed. */ +export function promptHidden(query: string): Promise { + const rl = createInterface({ input: process.stdin, output: process.stdout, terminal: true }); + const iface = rl as MutableInterface; + const original = iface._writeToOutput?.bind(iface); + iface._writeToOutput = (chunk: string) => { + if (!iface._muted) original?.(chunk); + }; + const answer = new Promise((resolve) => { + rl.question(query, (line) => { + iface._muted = false; + rl.close(); + process.stdout.write("\n"); + resolve(line); + }); + }); + iface._muted = true; // the prompt is already written; mute the typed reply + return answer; +} + +/** Ask `query` and resolve the typed line, trimmed (visible, non-secret). */ +export function promptLine(query: string): Promise { + const rl = createInterface({ input: process.stdin, output: process.stdout }); + return new Promise((resolve) => { + rl.question(query, (line) => { + rl.close(); + resolve(line.trim()); + }); + }); +} diff --git a/src/bin/vault-cli.ts b/src/bin/vault-cli.ts new file mode 100644 index 0000000..e78efcc --- /dev/null +++ b/src/bin/vault-cli.ts @@ -0,0 +1,70 @@ +/** + * `fuse-browser vault `: manage the local credential vault. + * Secrets are read from stdin without echo, NEVER from argv (which would leak + * via the process list / shell history). This is the only vault write path — + * the MCP surface is read-only by design. + * @module bin/vault-cli + */ +import { assertRef, listEntries, loadVault, removeEntry, setEntry } from "../vault/store.js"; +import { totp } from "../vault/totp.js"; +import { promptHidden, promptLine } from "./prompt-hidden.js"; + +/** Prompt for and store a credential under `ref` (origin binding mandatory). */ +async function setCmd(ref: string): Promise { + assertRef(ref); + const username = await promptLine("Username: "); + const password = await promptHidden("Password: "); + const totpSecret = await promptHidden("TOTP secret (base32 or otpauth://, empty to skip): "); + const origins = (await promptLine("Allowed origins (comma-separated, e.g. https://github.com): ")) + .split(",") + .map((o) => o.trim()) + .filter(Boolean); + if (origins.length === 0) { + throw new Error("At least one origin is required — origin binding is mandatory."); + } + setEntry(ref, { username, password, totp: totpSecret || undefined, origins }); + process.stdout.write(`Saved credential "${ref}" (${origins.length} origin(s)).\n`); +} + +/** Print non-secret metadata for every stored credential. */ +function listCmd(): void { + const rows = listEntries(loadVault()); + if (rows.length === 0) { + process.stdout.write("Vault is empty.\n"); + return; + } + for (const r of rows) { + process.stdout.write(`${r.ref}\t${r.username}\ttotp:${r.hasTotp ? "yes" : "no"}\t${r.origins.join(",")}\n`); + } +} + +/** Print the current TOTP code + confirm the password length (no secret echo). */ +function testCmd(ref: string): void { + const entry = loadVault().entries[ref]; + if (!entry) throw new Error(`No credential "${ref}".`); + const code = entry.totp ? totp(entry.totp) : "(none)"; + process.stdout.write(`ref="${ref}" username="${entry.username}" password=(${entry.password.length} chars) totp=${code}\n`); +} + +/** Route a `vault` subcommand. Returns true when handled. */ +export async function runVaultCli(rest: string[]): Promise { + const [sub, ref] = rest; + if (sub === "list") { + listCmd(); + return true; + } + if (sub === "set" && ref) { + await setCmd(ref); + return true; + } + if (sub === "rm" && ref) { + process.stdout.write(removeEntry(ref) ? `Removed "${ref}".\n` : `No credential "${ref}".\n`); + return true; + } + if (sub === "test" && ref) { + testCmd(ref); + return true; + } + process.stderr.write("Usage: fuse-browser vault [ref]\n"); + return false; +} diff --git a/src/extraction/redact.ts b/src/extraction/redact.ts new file mode 100644 index 0000000..7d16896 --- /dev/null +++ b/src/extraction/redact.ts @@ -0,0 +1,41 @@ +/** + * Tier 2 vault protection: scrub known session secrets from snapshot output + * before it reaches the LLM. Tier 1 already nulls `input[type=password]` + * values browser-side; this catches any tainted secret (password/TOTP) that + * still surfaced in a `value` or `text` field. Exact substring replacement is + * safe for the high-entropy secrets the vault holds. + * @module extraction/redact + */ +import type { InteractiveElement } from "../interfaces/extraction.js"; + +/** Placeholder shown in place of a redacted secret. */ +const MASK = "•••"; + +/** Replace every occurrence of each secret in `text` with the mask. */ +function scrub(text: string, secrets: Set): string { + let out = text; + for (const secret of secrets) { + if (secret && out.includes(secret)) out = out.split(secret).join(MASK); + } + return out; +} + +/** + * Redact tainted secrets from each element's `value`/`text` in place, then + * return the same array. No-op (and no allocation) when the taint-set is empty. + * + * @param elements - Snapshot elements about to be returned to the client. + * @param secrets - Session taint-set of live secret values. + * @returns The (possibly mutated) `elements` array. + */ +export function redactElements( + elements: InteractiveElement[], + secrets: Set, +): InteractiveElement[] { + if (secrets.size === 0) return elements; + for (const el of elements) { + if (typeof el.value === "string") el.value = scrub(el.value, secrets); + if (typeof el.text === "string") el.text = scrub(el.text, secrets); + } + return elements; +} diff --git a/src/extraction/snapshot-walk.ts b/src/extraction/snapshot-walk.ts index 8e704ca..0b40035 100644 --- a/src/extraction/snapshot-walk.ts +++ b/src/extraction/snapshot-walk.ts @@ -41,7 +41,8 @@ export const SNAPSHOT_SCRIPT = `(arg) => { text: (el.innerText || el.getAttribute('aria-label') || el.getAttribute('placeholder') || '').trim().slice(0, 120), role: el.getAttribute('role'), id: el.id || null, name: el.getAttribute('name'), type: el.getAttribute('type'), href: el.getAttribute('href'), - value: val ? val.slice(0, 120) : null, placeholder: el.getAttribute('placeholder'), + value: el.type === 'password' ? null : (val ? val.slice(0, 120) : null), + hasValue: !!val, placeholder: el.getAttribute('placeholder'), disabled: !!el.disabled || el.getAttribute('aria-disabled') === 'true', checked: isCheck ? !!el.checked : undefined, options: el.tagName === 'SELECT' ? [...el.options].slice(0, 12).map((o) => o.label || o.value) : undefined, diff --git a/src/identity/profiles.ts b/src/identity/profiles.ts index 649f1fa..e648486 100644 --- a/src/identity/profiles.ts +++ b/src/identity/profiles.ts @@ -5,17 +5,12 @@ * works on `storageStatePath`; profiles are just a friendly path resolver. * @module identity/profiles */ -import { homedir } from "node:os"; import { join } from "node:path"; +import { fuseBrowserHome } from "../lib/home.js"; /** Allowed profile names: alnum start, then alnum/`-`/`_`, 1-41 chars. */ const PROFILE_NAME = /^[a-z0-9][a-z0-9_-]{0,40}$/i; -/** Fuse-browser home dir (`FUSE_BROWSER_HOME` override, else `~/.fuse-browser`). */ -function fuseBrowserHome(): string { - return process.env.FUSE_BROWSER_HOME ?? join(homedir(), ".fuse-browser"); -} - /** * Resolve the storage-state file for a named auth profile. * diff --git a/src/interfaces/extraction.ts b/src/interfaces/extraction.ts index bad4f29..ddef0d4 100644 --- a/src/interfaces/extraction.ts +++ b/src/interfaces/extraction.ts @@ -58,6 +58,8 @@ export interface InteractiveElement { visible: boolean; box: { x: number; y: number; width: number; height: number }; value?: string | null; + /** Present when a value exists but is withheld (password field / redacted). */ + hasValue?: boolean; placeholder?: string | null; disabled?: boolean; checked?: boolean; diff --git a/src/interfaces/vault.ts b/src/interfaces/vault.ts new file mode 100644 index 0000000..eab87c8 --- /dev/null +++ b/src/interfaces/vault.ts @@ -0,0 +1,62 @@ +/** + * Vault domain types: encrypted credential store + fill-by-reference. + * Secrets never cross the MCP boundary — the LLM only ever sees a `ref`. + * @module interfaces/vault + */ + +/** Which field of a stored credential to resolve/fill. */ +export type VaultField = "username" | "password" | "totp"; + +/** A single stored credential. Persisted only inside the encrypted blob. */ +export interface VaultEntry { + /** Login/email/handle typed into the username field. */ + username: string; + /** Secret typed into the password field. */ + password: string; + /** Optional TOTP source: an `otpauth://` URI or a raw base32 secret. */ + totp?: string; + /** Allowlist of `scheme://host` origins this credential may be filled on. */ + origins: string[]; +} + +/** Decrypted vault contents (in memory only, never written in clear). */ +export interface VaultData { + /** Credentials keyed by their free-form reference alias. */ + entries: Record; +} + +/** On-disk encrypted envelope (`vault.json`). No secret is recoverable without the key. */ +export interface VaultBlob { + /** Schema version. */ + v: 1; + /** Cipher identifier, always `aes-256-gcm`. */ + alg: "aes-256-gcm"; + /** Base64 12-byte random IV, fresh on every write. */ + iv: string; + /** Base64 16-byte GCM authentication tag. */ + tag: string; + /** Base64 ciphertext of the JSON-encoded `VaultData`. */ + ct: string; +} + +/** Non-secret metadata returned by `browser_vault { action: "list" }`. */ +export interface VaultMeta { + /** Credential reference alias. */ + ref: string; + /** Username (not a secret — surfaced to help the agent pick a ref). */ + username: string; + /** Whether a TOTP secret is stored (the secret itself is never returned). */ + hasTotp: boolean; + /** Origins this credential is bound to. */ + origins: string[]; +} + +/** Outcome of resolving a credential field for a fill. */ +export interface ResolveResult { + /** Resolved value to type into the field (real secret, server-side only). */ + value: string; + /** True when the value is a live secret (password/totp) → taint it in snapshots. */ + secret: boolean; + /** Stable placeholder surfaced in action reports instead of the value. */ + placeholder: string; +} diff --git a/src/lib/home.ts b/src/lib/home.ts new file mode 100644 index 0000000..f36d132 --- /dev/null +++ b/src/lib/home.ts @@ -0,0 +1,12 @@ +/** + * Shared resolver for the fuse-browser home directory. Single source of + * truth so profiles, vault, and sessions never re-declare the same logic. + * @module lib/home + */ +import { homedir } from "node:os"; +import { join } from "node:path"; + +/** Fuse-browser home dir (`FUSE_BROWSER_HOME` override, else `~/.fuse-browser`). */ +export function fuseBrowserHome(): string { + return process.env.FUSE_BROWSER_HOME ?? join(homedir(), ".fuse-browser"); +} diff --git a/src/server/registry.ts b/src/server/registry.ts index 3b38faa..200e570 100644 --- a/src/server/registry.ts +++ b/src/server/registry.ts @@ -38,6 +38,7 @@ import { registerShotsBatchTool } from "./tools/shots-batch.js"; import { registerSiteShotsTool } from "./tools/site-shots.js"; import { registerSnapshotTools } from "./tools/snapshot.js"; import { registerTabsTool } from "./tools/tabs.js"; +import { registerVaultTool } from "./tools/vault.js"; import { registerVisualDiffTool } from "./tools/visual-diff.js"; import { registerWaitTool } from "./tools/wait.js"; @@ -58,6 +59,7 @@ export function toolGroups( () => registerWaitTool(server, sessions), () => registerScreenshotTool(server, sessions), () => registerAutoScrollTool(server, sessions), + () => registerVaultTool(server, sessions), ], batch: [ () => registerProbeTools(server), diff --git a/src/server/tools/act.ts b/src/server/tools/act.ts index 7e2b6d8..03bcd90 100644 --- a/src/server/tools/act.ts +++ b/src/server/tools/act.ts @@ -1,6 +1,7 @@ /** * Action tools for a live session: click / fill / login / wait / scroll / - * press / select / back / forward. + * press / select / back / forward. `browser_fill` and `browser_login` accept + * a `credentialRef` to fill vault secrets without the LLM ever seeing them. * @module server/tools/act */ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; @@ -9,7 +10,8 @@ import { type ActionInput, performAction } from "../../actions/perform.js"; import type { SessionManager } from "../../session/manager.js"; import { persistStorageState } from "../../session/persist-auth.js"; import { runWithMemory } from "../../state/action-memory.js"; -import { jsonResult } from "../result.js"; +import { applyCredential } from "../../vault/fill.js"; +import { errorResult, jsonResult } from "../result.js"; import { withSession } from "./with-session.js"; type Shape = Record; @@ -27,6 +29,11 @@ function actTool( const a = args as Record; return withSession(sessions, String(a.sessionId), async (s) => { const action = build(a); + try { + applyCredential(a, action, s.page.url(), s.secrets); + } catch (err) { + return errorResult(err instanceof Error ? err.message : String(err), "credential_failed"); + } const result = await runWithMemory(s.config.siteMemoryDir, s.page, action, (act) => performAction(s.page, act, s.config.humanMode), ); @@ -43,57 +50,14 @@ function actTool( /** Register every per-session action tool. */ export function registerActTools(server: McpServer, sessions: SessionManager): void { const sessionId = z.string(); - actTool(server, sessions, "browser_click", "Click a target in the session.", { sessionId, target: z.string() }, (a) => ({ - type: "click", - target: String(a.target), - })); - actTool(server, sessions, "browser_fill", "Fill a field in the session.", { sessionId, target: z.string(), value: z.string() }, (a) => ({ - type: "fill", - target: String(a.target), - value: String(a.value), - })); - actTool(server, sessions, "browser_scroll", "Scroll by a pixel delta (positive deltaY scrolls down). Pass `selector` to scroll a specific scrollable container (auto-detected if omitted with `to`), or `to:\"end\"` to jump to its bottom.", { sessionId, deltaY: z.number().optional(), deltaX: z.number().optional(), selector: z.string().optional(), to: z.enum(["end"]).optional() }, (a) => ({ - type: "scroll", - deltaY: a.deltaY ?? 600, - deltaX: a.deltaX ?? 0, - selector: a.selector as string | undefined, - to: a.to as string | undefined, - })); - actTool(server, sessions, "browser_press", "Press a key or shortcut (Enter, ArrowDown, Control+a...).", { sessionId, key: z.string() }, (a) => ({ - type: "press", - key: String(a.key), - })); - actTool(server, sessions, "browser_select", "Select an option in a by value, label or index.", { sessionId, target: z.string(), value: z.string() }, (a) => ({ type: "select", target: String(a.target), value: String(a.value) })); actTool(server, sessions, "browser_back", "Navigate back in session history.", { sessionId }, () => ({ type: "back" })); actTool(server, sessions, "browser_forward", "Navigate forward in session history.", { sessionId }, () => ({ type: "forward" })); - actTool(server, sessions, "browser_wait", "Wait for a number of milliseconds.", { sessionId, ms: z.number().int() }, (a) => ({ - type: "wait", - ms: Number(a.ms), - })); - actTool( - server, - sessions, - "browser_login", - "Structured login (username + password + submit).", - { - sessionId, - username: z.string(), - password: z.string(), - usernameTarget: z.string().optional(), - passwordTarget: z.string().optional(), - submitTarget: z.string().optional(), - }, - (a) => ({ - type: "login", - username: String(a.username), - password: String(a.password), - usernameTarget: a.usernameTarget as string | undefined, - passwordTarget: a.passwordTarget as string | undefined, - submitTarget: a.submitTarget as string | undefined, - }), - ); + actTool(server, sessions, "browser_wait", "Wait for a number of milliseconds.", { sessionId, ms: z.number().int() }, (a) => ({ type: "wait", ms: Number(a.ms) })); + actTool(server, sessions, "browser_login", "Structured login. Pass `credentialRef` to fill username+password from the vault without exposing them (for TOTP, use browser_fill with field:\"totp\"), or inline `username`+`password`.", { sessionId, username: z.string().optional(), password: z.string().optional(), credentialRef, usernameTarget: z.string().optional(), passwordTarget: z.string().optional(), submitTarget: z.string().optional() }, (a) => ({ type: "login", username: a.username ? String(a.username) : undefined, password: a.password ? String(a.password) : undefined, usernameTarget: a.usernameTarget as string | undefined, passwordTarget: a.passwordTarget as string | undefined, submitTarget: a.submitTarget as string | undefined })); } diff --git a/src/server/tools/snapshot.ts b/src/server/tools/snapshot.ts index 02e61ba..5ebddd3 100644 --- a/src/server/tools/snapshot.ts +++ b/src/server/tools/snapshot.ts @@ -8,6 +8,7 @@ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { z } from "zod"; import { annotatedScreenshot } from "../../extraction/annotate.js"; +import { redactElements } from "../../extraction/redact.js"; import { captureSnapshot } from "../../extraction/snapshot.js"; import { diffSnapshots } from "../../extraction/snapshot-diff.js"; import type { SessionManager } from "../../session/manager.js"; @@ -28,7 +29,7 @@ export function registerSnapshotTools(server: McpServer, sessions: SessionManage async (args) => { const a = args as Record; return withSession(sessions, String(a.sessionId), async (s) => { - const elements = await captureSnapshot(s.page, a.selectors === true); + const elements = redactElements(await captureSnapshot(s.page, a.selectors === true), s.secrets); const payload = { url: s.page.url(), count: elements.length, elements }; if (a.annotate !== true) return jsonResult(payload); const shot = await annotatedScreenshot(s.page); @@ -58,11 +59,11 @@ export function registerSnapshotTools(server: McpServer, sessions: SessionManage async (args) => { const a = args as Record; return withSession(sessions, String(a.sessionId), async (s) => { - const before = await captureSnapshot(s.page); + const before = redactElements(await captureSnapshot(s.page), s.secrets); const urlBefore = s.page.url(); const result = await runAct(s.page, a, s.config.humanMode, s.config.siteMemoryDir); if (!result) return errorResult("browser_act requires either `ref` or `target`"); - const after = await captureSnapshot(s.page); + const after = redactElements(await captureSnapshot(s.page), s.secrets); const diff = diffSnapshots(before, after, s.page.url() !== urlBefore); const out = { result, url: s.page.url(), diff }; if (a.annotate !== true) return jsonResult(out); diff --git a/src/server/tools/vault.ts b/src/server/tools/vault.ts new file mode 100644 index 0000000..9121483 --- /dev/null +++ b/src/server/tools/vault.ts @@ -0,0 +1,41 @@ +/** + * `browser_vault`: read-only discovery of stored credentials. Returns only + * non-secret metadata (ref, username, hasTotp, origins) — never a password or + * TOTP. Writing is CLI-only (`fuse-browser vault set`), by design: secrets + * must never travel through an MCP argument. + * @module server/tools/vault + */ +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; +import type { SessionManager } from "../../session/manager.js"; +import { originAllowed } from "../../vault/resolve.js"; +import { listEntries, loadVault } from "../../vault/store.js"; +import { errorResult, jsonResult } from "../result.js"; + +/** Register `browser_vault` (list-only, metadata-only). */ +export function registerVaultTool(server: McpServer, sessions: SessionManager): void { + server.registerTool( + "browser_vault", + { + title: "Vault", + description: + "List stored credential references — metadata only, NEVER secrets. Returns {credentials:[{ref, username, hasTotp, origins}]}. Pass `sessionId` to only show credentials bound to the live page's origin (use before a browser_login / browser_fill `credentialRef`). Writing is CLI-only: `fuse-browser vault set `.", + inputSchema: { + action: z.literal("list"), + sessionId: z.string().optional(), + }, + }, + async (args) => { + const a = args as Record; + try { + const all = listEntries(loadVault()); + const sid = a.sessionId; + if (typeof sid !== "string" || !sid) return jsonResult({ credentials: all }); + const url = sessions.get(sid).page.url(); + return jsonResult({ credentials: all.filter((c) => originAllowed(c.origins, url)) }); + } catch (err) { + return errorResult(err instanceof Error ? err.message : String(err), "vault_failed"); + } + }, + ); +} diff --git a/src/session/session.ts b/src/session/session.ts index bea14dc..dfef2bd 100644 --- a/src/session/session.ts +++ b/src/session/session.ts @@ -26,6 +26,8 @@ export interface SessionData { health: SessionHealth; /** Last main-frame URL, tracked for recovery re-navigation. */ lastUrl: string; + /** Secrets filled from the vault this session; redacted from snapshots. */ + secrets: Set; createdAt: number; expiresAt: number; } @@ -55,6 +57,7 @@ export async function openSession( connected: opened.connected ?? false, health: "ok", lastUrl: page.url(), + secrets: new Set(), createdAt: now, expiresAt: now + ttlMs, }; diff --git a/src/vault/crypto.ts b/src/vault/crypto.ts new file mode 100644 index 0000000..a8cb841 --- /dev/null +++ b/src/vault/crypto.ts @@ -0,0 +1,66 @@ +/** + * AES-256-GCM encryption for the credential vault + master-key management. + * The key comes from `FUSE_VAULT_KEY` (base64, 32 bytes) or a `0600` key file. + * At-rest encryption protects the blob when it travels without its key + * (backup, sync, accidental `git add`); it is not a defense against local + * malware running as the same user. + * @module vault/crypto + */ +import { createCipheriv, createDecipheriv, randomBytes } from "node:crypto"; +import { existsSync, readFileSync, writeFileSync } from "node:fs"; +import { dirname } from "node:path"; +import type { VaultBlob } from "../interfaces/vault.js"; +import { ensureDir } from "../lib/fs.js"; + +/** Read a 32-byte master key from `FUSE_VAULT_KEY` (base64) or `keyPath` (0600). */ +export function loadKey(keyPath: string): Buffer { + const env = process.env.FUSE_VAULT_KEY; + if (env) { + const key = Buffer.from(env, "base64"); + if (key.length !== 32) { + throw new Error("FUSE_VAULT_KEY must be base64 of exactly 32 bytes."); + } + return key; + } + if (existsSync(keyPath)) { + const key = readFileSync(keyPath); + if (key.length !== 32) { + throw new Error(`Vault key at ${keyPath} must be exactly 32 bytes (got ${key.length}).`); + } + return key; + } + return generateKey(keyPath); +} + +/** Create a fresh 32-byte key, persist it `0600`, and return it. */ +export function generateKey(keyPath: string): Buffer { + const key = randomBytes(32); + ensureDir(dirname(keyPath)); + writeFileSync(keyPath, key, { mode: 0o600 }); + return key; +} + +/** Encrypt UTF-8 `plaintext` into a self-describing GCM envelope. */ +export function encrypt(plaintext: string, key: Buffer): VaultBlob { + const iv = randomBytes(12); + const cipher = createCipheriv("aes-256-gcm", key, iv); + const ct = Buffer.concat([cipher.update(plaintext, "utf-8"), cipher.final()]); + return { + v: 1, + alg: "aes-256-gcm", + iv: iv.toString("base64"), + tag: cipher.getAuthTag().toString("base64"), + ct: ct.toString("base64"), + }; +} + +/** Decrypt a GCM envelope back to UTF-8; throws if the key or tag mismatch. */ +export function decrypt(blob: VaultBlob, key: Buffer): string { + const decipher = createDecipheriv("aes-256-gcm", key, Buffer.from(blob.iv, "base64")); + decipher.setAuthTag(Buffer.from(blob.tag, "base64")); + const pt = Buffer.concat([ + decipher.update(Buffer.from(blob.ct, "base64")), + decipher.final(), + ]); + return pt.toString("utf-8"); +} diff --git a/src/vault/fill.ts b/src/vault/fill.ts new file mode 100644 index 0000000..eba6b2c --- /dev/null +++ b/src/vault/fill.ts @@ -0,0 +1,45 @@ +/** + * Resolve vault credentials into a live-session action, server-side. The + * secret never enters the MCP arguments — only a `credentialRef` does — and + * resolved secrets are recorded for snapshot redaction. + * @module vault/fill + */ +import type { ActionInput } from "../actions/perform.js"; +import type { VaultField } from "../interfaces/vault.js"; +import { resolveCredential } from "./resolve.js"; +import { loadVault } from "./store.js"; + +/** Field filled when `browser_fill` gets a ref without an explicit `field`. */ +const DEFAULT_FIELD: VaultField = "password"; + +/** + * If `args` carries a `credentialRef`, resolve it and patch `action` in place + * with the real values. No-op when no ref is present. + * + * @param args - Raw MCP tool arguments. + * @param action - The built action to patch. + * @param currentUrl - Live page URL, used for origin binding. + * @param secrets - Session taint-set; live secrets are added here. + * @throws When the ref is unknown, the origin is refused, or TOTP is missing. + */ +export function applyCredential( + args: Record, + action: ActionInput, + currentUrl: string, + secrets: Set, +): void { + const ref = args.credentialRef; + if (typeof ref !== "string" || !ref) return; + const data = loadVault(); + if (action.type === "login") { + action.username = resolveCredential(data, ref, "username", currentUrl).value; + const pw = resolveCredential(data, ref, "password", currentUrl); + action.password = pw.value; + secrets.add(pw.value); + return; + } + const field = (args.field as VaultField) ?? DEFAULT_FIELD; + const resolved = resolveCredential(data, ref, field, currentUrl); + action.value = resolved.value; + if (resolved.secret) secrets.add(resolved.value); +} diff --git a/src/vault/resolve.ts b/src/vault/resolve.ts new file mode 100644 index 0000000..2272105 --- /dev/null +++ b/src/vault/resolve.ts @@ -0,0 +1,61 @@ +/** + * Resolve a credential field for a fill, enforcing origin binding. The + * resolved value is a real secret returned server-side only; the LLM sees + * the placeholder, never the value. Origin binding is the defense against a + * prompt-injected login on a phishing page. + * @module vault/resolve + */ +import type { ResolveResult, VaultData, VaultField } from "../interfaces/vault.js"; +import { totp } from "./totp.js"; + +/** Best-effort host of a URL (lowercased), or "" when unparseable. */ +function hostOf(url: string): string { + try { + return new URL(url).host.toLowerCase(); + } catch { + return ""; + } +} + +/** True when `currentUrl`'s host matches one of the allowed origins. */ +export function originAllowed(origins: string[], currentUrl: string): boolean { + if (process.env.FUSE_VAULT_ALLOW_ANY_ORIGIN === "1") return true; + const host = hostOf(currentUrl); + if (!host) return false; + return origins.some((o) => { + const oHost = hostOf(o); + return (oHost || o.toLowerCase()) === host; + }); +} + +/** + * Resolve `field` of credential `ref` for the page at `currentUrl`. + * + * @param data - Decrypted vault. + * @param ref - Credential reference alias. + * @param field - Which field to resolve (username/password/totp). + * @param currentUrl - URL of the live page (for origin binding). + * @returns The resolved value plus its secret flag and placeholder. + * @throws When the ref is unknown, the origin is refused, or TOTP is missing. + */ +export function resolveCredential( + data: VaultData, + ref: string, + field: VaultField, + currentUrl: string, +): ResolveResult { + const entry = data.entries[ref]; + if (!entry) { + throw new Error(`credential "${ref}" not found — run: fuse-browser vault set ${ref}`); + } + if (!originAllowed(entry.origins, currentUrl)) { + throw new Error( + `credential "${ref}" is bound to [${entry.origins.join(", ")}] — origin ${hostOf(currentUrl) || currentUrl} refused.`, + ); + } + const placeholder = `{{cred:${ref}:${field}}}`; + if (field === "username") return { value: entry.username, secret: false, placeholder }; + if (field === "password") return { value: entry.password, secret: true, placeholder }; + if (!entry.totp) throw new Error(`credential "${ref}" has no TOTP secret stored.`); + return { value: totp(entry.totp), secret: true, placeholder }; +} diff --git a/src/vault/store.ts b/src/vault/store.ts new file mode 100644 index 0000000..b025142 --- /dev/null +++ b/src/vault/store.ts @@ -0,0 +1,95 @@ +/** + * Encrypted credential store: resolve vault paths, load/save the blob + * atomically, and CRUD entries. Writes are temp+rename in the SAME dir + * (guaranteed same filesystem → atomic, no EXDEV). + * @module vault/store + */ +import { randomBytes } from "node:crypto"; +import { existsSync, readFileSync, renameSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import type { VaultBlob, VaultData, VaultEntry, VaultMeta } from "../interfaces/vault.js"; +import { ensureDir } from "../lib/fs.js"; +import { fuseBrowserHome } from "../lib/home.js"; +import { decrypt, encrypt, loadKey } from "./crypto.js"; + +/** Allowed credential refs: alnum start, then alnum/`-`/`_`, 1-41 chars. */ +const REF_NAME = /^[a-z0-9][a-z0-9_-]{0,40}$/i; + +/** Absolute path of the encrypted vault file. */ +export function vaultPath(): string { + return join(fuseBrowserHome(), "vault.json"); +} + +/** Absolute path of the master-key file. */ +export function vaultKeyPath(): string { + return join(fuseBrowserHome(), "vault.key"); +} + +/** Validate a credential reference alias, or throw. */ +export function assertRef(ref: string): void { + if (!REF_NAME.test(ref)) { + throw new Error( + `Invalid credential ref "${ref}" — use 1-41 chars: letters/digits, then "-" or "_".`, + ); + } +} + +/** Load and decrypt the vault; returns an empty vault when the file is absent. */ +export function loadVault(): VaultData { + const path = vaultPath(); + if (!existsSync(path)) return { entries: {} }; + const keyPath = vaultKeyPath(); + if (!process.env.FUSE_VAULT_KEY && !existsSync(keyPath)) { + throw new Error( + `${path} exists but its master key is missing — set FUSE_VAULT_KEY or restore ${keyPath}; the vault cannot be decrypted without the original key.`, + ); + } + const key = loadKey(keyPath); + try { + const blob = JSON.parse(readFileSync(path, "utf-8")) as VaultBlob; + return JSON.parse(decrypt(blob, key)) as VaultData; + } catch (cause) { + throw new Error( + `Failed to read ${path} — wrong key (check FUSE_VAULT_KEY / ${keyPath}) or corrupted vault file.`, + { cause }, + ); + } +} + +/** Encrypt and atomically persist the vault (temp + rename, mode 0600). */ +export function saveVault(data: VaultData): void { + const path = vaultPath(); + ensureDir(fuseBrowserHome()); + const key = loadKey(vaultKeyPath()); + const blob = encrypt(JSON.stringify(data), key); + const tmp = `${path}.tmp-${randomBytes(4).toString("hex")}`; + writeFileSync(tmp, JSON.stringify(blob), { mode: 0o600 }); + renameSync(tmp, path); +} + +/** Upsert a credential entry under `ref`. */ +export function setEntry(ref: string, entry: VaultEntry): void { + assertRef(ref); + const data = loadVault(); + data.entries[ref] = entry; + saveVault(data); +} + +/** Remove a credential entry; returns true when it existed. */ +export function removeEntry(ref: string): boolean { + const data = loadVault(); + if (!(ref in data.entries)) return false; + delete data.entries[ref]; + saveVault(data); + return true; +} + +/** Non-secret metadata for every entry — never returns a secret. */ +export function listEntries(data: VaultData): VaultMeta[] { + return Object.entries(data.entries).map(([ref, e]) => ({ + ref, + username: e.username, + hasTotp: Boolean(e.totp), + origins: e.origins, + })); +} diff --git a/src/vault/totp.ts b/src/vault/totp.ts new file mode 100644 index 0000000..403d730 --- /dev/null +++ b/src/vault/totp.ts @@ -0,0 +1,57 @@ +/** + * RFC 6238 TOTP generation with zero dependencies (node:crypto only). + * Accepts a raw base32 secret or an `otpauth://` URI. + * @module vault/totp + */ +import { createHmac } from "node:crypto"; + +/** RFC 4648 base32 alphabet. */ +const B32 = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567"; + +/** Decode an RFC 4648 base32 string (case-insensitive, padding/space tolerant). */ +function base32Decode(input: string): Buffer { + const clean = input.toUpperCase().replace(/[=\s]/g, ""); + if (!clean) throw new Error("Empty base32 TOTP secret."); + let bits = 0; + let value = 0; + const out: number[] = []; + for (const ch of clean) { + const idx = B32.indexOf(ch); + if (idx === -1) throw new Error("Invalid base32 character in TOTP secret."); + value = (value << 5) | idx; + bits += 5; + if (bits >= 8) { + bits -= 8; + out.push((value >>> bits) & 0xff); + } + } + return Buffer.from(out); +} + +/** Extract the base32 secret from an `otpauth://` URI, or return the input as-is. */ +function extractSecret(source: string): string { + if (!source.toLowerCase().startsWith("otpauth://")) return source.trim(); + const secret = new URL(source).searchParams.get("secret"); + if (!secret) throw new Error("otpauth:// URI is missing the `secret` parameter."); + return secret; +} + +/** + * Generate the current TOTP code (RFC 6238, HMAC-SHA1). + * + * @param source - Raw base32 secret or `otpauth://` URI. + * @param nowMs - Epoch milliseconds (defaults to now; injectable for tests). + * @param digits - Number of output digits (default 6). + * @param step - Time step in seconds (default 30). + * @returns Zero-padded numeric code of length `digits`. + */ +export function totp(source: string, nowMs = Date.now(), digits = 6, step = 30): string { + const key = base32Decode(extractSecret(source)); + const counter = Math.floor(nowMs / 1000 / step); + const buf = Buffer.alloc(8); + buf.writeBigUInt64BE(BigInt(counter)); + const hash = createHmac("sha1", key).update(buf).digest(); + const offset = hash.readUInt8(hash.length - 1) & 0x0f; + const bin = hash.readUInt32BE(offset) & 0x7fffffff; + return (bin % 10 ** digits).toString().padStart(digits, "0"); +} diff --git a/tests/integration/mcp.test.ts b/tests/integration/mcp.test.ts index 6a23922..61c1e9d 100644 --- a/tests/integration/mcp.test.ts +++ b/tests/integration/mcp.test.ts @@ -68,6 +68,7 @@ const EXPECTED = [ "browser_route", "browser_permissions", "browser_clipboard", + "browser_vault", ]; test("MCP exposes the expected tool set with no duplicates", async () => { diff --git a/tests/integration/vault.test.ts b/tests/integration/vault.test.ts new file mode 100644 index 0000000..c1a3d64 --- /dev/null +++ b/tests/integration/vault.test.ts @@ -0,0 +1,30 @@ +/** + * End-to-end: the vault's Tier-1 redaction nulls password-input values in the + * snapshot, browser-side, so a filled password never reaches the LLM — while + * non-password fields keep their value. Runs under Node with real Chromium. + */ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { resolveConfig } from "../../src/agent/config.js"; +import { captureSnapshot } from "../../src/extraction/snapshot.js"; +import { SessionManager } from "../../src/session/manager.js"; + +const PAGE = ""; +const URL = `data:text/html,${encodeURIComponent(PAGE)}`; + +test("Tier 1: password value is nulled in the snapshot", { timeout: 120_000 }, async () => { + const sessions = new SessionManager(); + const session = await sessions.open(resolveConfig({ headless: true, engine: "patchright" })); + try { + await session.page.goto(URL, { waitUntil: "domcontentloaded", timeout: 30_000 }); + const els = await captureSnapshot(session.page); + const pw = els.find((e) => e.type === "password"); + assert.ok(pw, "password input present"); + assert.equal(pw.value, null, "password value must be nulled"); + assert.equal(pw.hasValue, true, "hasValue flags a filled field"); + const user = els.find((e) => e.id === "u"); + assert.equal(user?.value, "alice", "non-password value is preserved"); + } finally { + await sessions.close(session.id); + } +}); diff --git a/tests/unit/vault-crypto-totp.test.ts b/tests/unit/vault-crypto-totp.test.ts new file mode 100644 index 0000000..a06e2ca --- /dev/null +++ b/tests/unit/vault-crypto-totp.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, test } from "bun:test"; +import { randomBytes } from "node:crypto"; +import { decrypt, encrypt } from "../../src/vault/crypto.js"; +import { totp } from "../../src/vault/totp.js"; + +const KEY = randomBytes(32); + +describe("vault crypto (AES-256-GCM)", () => { + test("encrypt → decrypt round-trips UTF-8", () => { + const blob = encrypt('{"a":1}', KEY); + expect(blob.alg).toBe("aes-256-gcm"); + expect(decrypt(blob, KEY)).toBe('{"a":1}'); + }); + + test("a fresh IV is used on every write", () => { + expect(encrypt("x", KEY).iv).not.toBe(encrypt("x", KEY).iv); + }); + + test("tampered ciphertext fails authentication", () => { + const blob = encrypt("secret", KEY); + const forged = { ...blob, ct: Buffer.from("deadbeef", "hex").toString("base64") }; + expect(() => decrypt(forged, KEY)).toThrow(); + }); + + test("a wrong key throws", () => { + const blob = encrypt("secret", KEY); + expect(() => decrypt(blob, randomBytes(32))).toThrow(); + }); +}); + +describe("vault totp (RFC 6238 SHA1)", () => { + // RFC 6238 App. B secret "12345678901234567890" is ASCII → base32-encoded here. + const SECRET = "GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ"; + + test("matches the official 6-digit vectors", () => { + expect(totp(SECRET, 59_000)).toBe("287082"); + expect(totp(SECRET, 1_111_111_109_000)).toBe("081804"); + expect(totp(SECRET, 1_234_567_890_000)).toBe("005924"); + }); + + test("parses an otpauth:// URI", () => { + expect(totp(`otpauth://totp/Acme:me?secret=${SECRET}&issuer=Acme`, 59_000)).toBe("287082"); + }); + + test("rejects an empty or invalid secret", () => { + expect(() => totp("", 0)).toThrow(); + expect(() => totp("0189!", 0)).toThrow(); + }); +}); diff --git a/tests/unit/vault-resolve.test.ts b/tests/unit/vault-resolve.test.ts new file mode 100644 index 0000000..c174959 --- /dev/null +++ b/tests/unit/vault-resolve.test.ts @@ -0,0 +1,75 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import type { VaultData } from "../../src/interfaces/vault.js"; +import { applyCredential } from "../../src/vault/fill.js"; +import { resolveCredential } from "../../src/vault/resolve.js"; +import { setEntry } from "../../src/vault/store.js"; + +const ENTRY = { + username: "u", + password: "p@ss", + totp: "GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ", + origins: ["https://github.com"], +}; +const DATA: VaultData = { entries: { gh: ENTRY } }; + +afterEach(() => { + delete process.env.FUSE_VAULT_ALLOW_ANY_ORIGIN; +}); + +describe("resolve + origin binding", () => { + test("a matching origin resolves the value", () => { + expect(resolveCredential(DATA, "gh", "password", "https://github.com/login").value).toBe("p@ss"); + }); + + test("a mismatched origin is refused (anti-phishing)", () => { + expect(() => resolveCredential(DATA, "gh", "password", "https://github.com.attacker.com")).toThrow(/refused/); + expect(() => resolveCredential(DATA, "gh", "password", "https://evil.github.com")).toThrow(/refused/); + }); + + test("an unknown ref throws a helpful message", () => { + expect(() => resolveCredential(DATA, "nope", "password", "https://github.com")).toThrow(/vault set/); + }); + + test("FUSE_VAULT_ALLOW_ANY_ORIGIN bypasses binding", () => { + process.env.FUSE_VAULT_ALLOW_ANY_ORIGIN = "1"; + expect(resolveCredential(DATA, "gh", "username", "https://anywhere.example").value).toBe("u"); + }); + + test("the totp field generates a live 6-digit code", () => { + expect(resolveCredential(DATA, "gh", "totp", "https://github.com").value).toMatch(/^\d{6}$/); + }); +}); + +describe("applyCredential (fill wiring)", () => { + const prevHome = process.env.FUSE_BROWSER_HOME; + afterEach(() => { + if (prevHome === undefined) delete process.env.FUSE_BROWSER_HOME; + else process.env.FUSE_BROWSER_HOME = prevHome; + delete process.env.FUSE_VAULT_KEY; + }); + + test("patches a login action and taints the password", () => { + const dir = mkdtempSync(join(tmpdir(), "fuse-fill-")); + process.env.FUSE_BROWSER_HOME = dir; + process.env.FUSE_VAULT_KEY = Buffer.alloc(32, 3).toString("base64"); + setEntry("gh", ENTRY); + const secrets = new Set(); + const action: Record & { type: string } = { type: "login" }; + applyCredential({ credentialRef: "gh" }, action, "https://github.com", secrets); + expect(action.username).toBe("u"); + expect(action.password).toBe("p@ss"); + expect(secrets.has("p@ss")).toBe(true); + rmSync(dir, { recursive: true, force: true }); + }); + + test("no-op without a credentialRef", () => { + const secrets = new Set(); + const action: Record & { type: string } = { type: "fill", value: "x" }; + applyCredential({}, action, "https://x.example", secrets); + expect(action.value).toBe("x"); + expect(secrets.size).toBe(0); + }); +}); diff --git a/tests/unit/vault-store.test.ts b/tests/unit/vault-store.test.ts new file mode 100644 index 0000000..f620959 --- /dev/null +++ b/tests/unit/vault-store.test.ts @@ -0,0 +1,57 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { listEntries, loadVault, removeEntry, setEntry } from "../../src/vault/store.js"; + +const prevHome = process.env.FUSE_BROWSER_HOME; +let dir = ""; + +const ENTRY = { + username: "u", + password: "p@ss-w0rd", + totp: "GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ", + origins: ["https://github.com"], +}; + +beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), "fuse-vault-")); + process.env.FUSE_BROWSER_HOME = dir; + process.env.FUSE_VAULT_KEY = Buffer.alloc(32, 7).toString("base64"); +}); + +afterEach(() => { + rmSync(dir, { recursive: true, force: true }); + if (prevHome === undefined) delete process.env.FUSE_BROWSER_HOME; + else process.env.FUSE_BROWSER_HOME = prevHome; + delete process.env.FUSE_VAULT_KEY; +}); + +describe("vault store", () => { + test("an absent vault loads as empty", () => { + expect(loadVault().entries).toEqual({}); + }); + + test("set → load round-trips through the encrypted file", () => { + setEntry("gh", ENTRY); + expect(loadVault().entries.gh).toEqual(ENTRY); + }); + + test("list returns metadata only — never a secret", () => { + setEntry("gh", ENTRY); + const [row] = listEntries(loadVault()); + expect(row).toEqual({ ref: "gh", username: "u", hasTotp: true, origins: ["https://github.com"] }); + expect(JSON.stringify(row)).not.toContain("p@ss-w0rd"); + }); + + test("remove deletes the entry", () => { + setEntry("gh", ENTRY); + expect(removeEntry("gh")).toBe(true); + expect(loadVault().entries.gh).toBeUndefined(); + expect(removeEntry("gh")).toBe(false); + }); + + test("an invalid ref is rejected", () => { + expect(() => setEntry("bad ref!", ENTRY)).toThrow(/Invalid credential ref/); + }); +}); diff --git a/tests/unit/vault-tool-redact.test.ts b/tests/unit/vault-tool-redact.test.ts new file mode 100644 index 0000000..5de869f --- /dev/null +++ b/tests/unit/vault-tool-redact.test.ts @@ -0,0 +1,60 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { redactElements } from "../../src/extraction/redact.js"; +import type { InteractiveElement } from "../../src/interfaces/extraction.js"; +import type { SessionManager } from "../../src/session/manager.js"; +import { registerVaultTool } from "../../src/server/tools/vault.js"; +import { setEntry } from "../../src/vault/store.js"; + +type Handler = (args: Record) => Promise; + +const prevHome = process.env.FUSE_BROWSER_HOME; +let dir = ""; + +beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), "fuse-vtool-")); + process.env.FUSE_BROWSER_HOME = dir; + process.env.FUSE_VAULT_KEY = Buffer.alloc(32, 9).toString("base64"); +}); + +afterEach(() => { + rmSync(dir, { recursive: true, force: true }); + if (prevHome === undefined) delete process.env.FUSE_BROWSER_HOME; + else process.env.FUSE_BROWSER_HOME = prevHome; + delete process.env.FUSE_VAULT_KEY; +}); + +describe("browser_vault (list-only)", () => { + test("returns metadata only, never a secret", async () => { + setEntry("gh", { username: "u", password: "p@ss", origins: ["https://github.com"] }); + let handler: Handler | undefined; + const server = { + registerTool: (_n: string, _c: unknown, fn: Handler) => { + handler = fn; + }, + } as unknown as McpServer; + registerVaultTool(server, {} as unknown as SessionManager); + const res = await (handler as Handler)({ action: "list" }); + const creds = (res.structuredContent as { credentials: unknown[] }).credentials; + expect(creds).toEqual([{ ref: "gh", username: "u", hasTotp: false, origins: ["https://github.com"] }]); + expect(JSON.stringify(res)).not.toContain("p@ss"); + }); +}); + +describe("redactElements (Tier 2)", () => { + test("scrubs tainted secrets from value and text", () => { + const els = [{ value: "p@ss", text: "logged in as p@ss" }] as unknown as InteractiveElement[]; + redactElements(els, new Set(["p@ss"])); + expect(els[0]?.value).toBe("•••"); + expect(els[0]?.text).toBe("logged in as •••"); + }); + + test("is a no-op with an empty taint-set", () => { + const els = [{ value: "keep" }] as unknown as InteractiveElement[]; + expect(redactElements(els, new Set())[0]?.value).toBe("keep"); + }); +});