From 4657765d2cbff32717ec54c2c73d6cd90d706a57 Mon Sep 17 00:00:00 2001 From: Wolfgang Schoenberger <221313372+wolfiesch@users.noreply.github.com> Date: Fri, 30 Oct 2026 17:22:32 -0700 Subject: [PATCH 1/2] Add origin-bound email verification code entry --- CHANGELOG.md | 3 + README.md | 4 +- docs/adr/0001-agenttab-runtime.md | 8 +- docs/mcp.md | 20 +- docs/security.md | 24 +- docs/setup.md | 62 + .../crates/agenttab-host/src/credentials.rs | 22 +- .../crates/agenttab-host/src/email_codes.rs | 2814 +++++++++++++++++ .../crates/agenttab-host/src/guardrails.rs | 154 +- host-rs/crates/agenttab-host/src/journal.rs | 67 + host-rs/crates/agenttab-host/src/lib.rs | 1 + host-rs/crates/agenttab-host/src/runtime.rs | 593 +++- host-rs/crates/agenttab-protocol/src/lib.rs | 352 +++ packages/extension/src/background.ts | 45 +- packages/extension/src/browser.ts | 139 + packages/extension/src/email-code-fill.ts | 137 + packages/extension/src/email-code-page.ts | 405 +++ packages/extension/src/page-agent-protocol.ts | 4 +- packages/extension/src/page-agent.ts | 11 + packages/extension/src/protocol.ts | 93 + packages/extension/src/routes.ts | 2 +- packages/extension/test/email-code-dom.ts | 149 + .../extension/test/email-code-fill.test.ts | 417 +++ packages/extension/test/extension.test.ts | 300 +- packages/extension/test/profile.test.ts | 4 +- packages/mcp/src/server.ts | 4 +- packages/mcp/test/server.test.ts | 48 + packages/omp/src/index.ts | 28 +- packages/omp/src/pi-schema.ts | 19 + packages/omp/src/render.ts | 5 +- packages/omp/test/extension.test.ts | 62 + packages/sdk-python/agenttab/client.py | 4 + packages/sdk-python/tests/test_client.py | 37 +- packages/sdk-typescript/src/index.ts | 25 + packages/sdk-typescript/test/client.test.ts | 27 +- .../rpc/v1/browser-credentials.schema.json | 37 +- 36 files changed, 6085 insertions(+), 41 deletions(-) create mode 100644 host-rs/crates/agenttab-host/src/email_codes.rs create mode 100644 packages/extension/src/email-code-fill.ts create mode 100644 packages/extension/src/email-code-page.ts create mode 100644 packages/extension/test/email-code-dom.ts create mode 100644 packages/extension/test/email-code-fill.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index d4ca186..a2630d5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,9 @@ - Added an optional `intent` to `browser_act` across Core RPC, the extension, MCP, OMP, Pi, and the TypeScript SDK. When an agent states the purpose of a batch in one line of up to 120 characters, such as "Open the bookmarks folder", the agent pointer shows it as its caption instead of the generated action label, and captions now wrap to two lines. Requests with an empty, overlong, or multi-line intent fail validation. - Fixed `hover`, `double_click`, `context_click`, and `drag` failing on targets below the fold or on scrolled pages, most visibly in `placement: "new_window"` task windows, which are shorter than the user's window. These gestures now scroll the target into view first, the obscured-target check hit-tests in the document coordinates Chrome expects instead of viewport coordinates, and the agent pointer is hidden during that check so it is not mistaken for an element covering the target. A target that still lies outside the viewport fails with `action_failed` naming the viewport instead of a raw DevTools error. - Restored the Agent pointer setting, which had no effect since the 2.0 runtime rewrite. In a visible task tab, Standard actions now show a floating cursor with a soft glow and a caption naming the action and its target, gliding between targets over a dot-matrix veil that lights up near the cursor. Clicks, double-clicks, and right-clicks play a ripple through the dot field; typing, filling, selecting, and uploads show a focus ring; hovers move the cursor; drags travel from source to target. Same-origin iframe targets are mapped into the top viewport, and targets in cross-origin frames or on the scripting route show no pointer. The overlay lives in a closed, `aria-hidden` shadow root, receives no input, fades after 20 seconds of inactivity, is excluded from accessibility, text, and HTML snapshots, and is hidden while screenshots are captured. Captions read visible labels only, never field values. Turn it off in the popup to skip the extra glide before each action. +- Preserve safe email-verification failure codes across the host boundary while discarding native messages, recovery text, and details that could contain a code. Report missing recipient evidence separately from missing email-verification context. +- Added policy-gated email verification through `browser_credentials` with `action: "fill_email"` for signup and email sign-in. Use one fresh, authenticated Gmail message for an exact origin, pathname, recipient, purpose, and owned tab. Fill a complete-code input or ordered split inputs without exposing the code or issuing an explicit submit action. Keep existing password, MFA, human-verification, origin, and Commit safeguards. +- Accept a compatible masked recipient automatically when it fits the exact granted recipient and no conflicting address is shown. Fill the email code directly without staging or popup approval, while retaining password, MFA, and human-challenge safeguards. - Recognize image-only ChatGPT turns, including authenticated generated-image URLs and images outside the empty assistant text body. Ignore progressive previews until rendering completes, and ignore the stale Stop button ChatGPT leaves in its hidden home composer after the first prompt. - Save generated images through the owned tab's Download control, using its attributed download cursor and filename. Locate each image's Share button within its own gallery, so thread length and missing `alt` text do not matter, and reload the owned tab once when Chrome blocks a second download on the same page. Distinguish modal dialogs from the conversation container, preserve browser downloads, and reject ambiguous local file matches. - Bound GPT-Control diagnostic PNG captures to a 1024 by 768 CSS-pixel viewport while retaining the 750 KB payload limit, avoiding oversized full-resolution captures on large displays. diff --git a/README.md b/README.md index 3689978..3a81505 100644 --- a/README.md +++ b/README.md @@ -54,8 +54,8 @@ Standard mode exposes exactly ten tools: | `browser_wait` | Wait for load, URL, text, selector, network-idle, task-attributed download, or challenge-cleared conditions supported by the tab's route. | | `browser_tabs` | List only tabs owned by the current task, including each tab's automation route. | | `browser_handoff` | Post a non-blocking attention notice asking the user to complete human-only input, then verify the page and resolve or dismiss it by notice ID. A `challenge_cleared` completion ties the notice to a detected bot challenge. | -| `browser_commit` | Execute one staged consequential action, including an approved bookmark removal. | -| `browser_credentials` | Prepare and fill an origin-matching 1Password login through opaque, short-lived host tokens. Available by default when the local `op` CLI is usable; owner-only policy can disable or constrain it. | +| `browser_commit` | Execute one human-approved staged action, including bookmark removal. | +| `browser_credentials` | Prepare and fill an origin-matching 1Password login through opaque, short-lived host tokens, or enter a policy-approved signup or email sign-in code privately. A compatible masked recipient is accepted when it fits the exact granted recipient and no conflicting address is shown. | | `browser_profile` | Search and organize bookmarks, search history, list recently closed or synced sessions and reopen one as background task tabs, and find, cancel, pause, or resume downloads. Works on the Chrome profile rather than a task tab and never focuses Chrome. Bookmark removal always stages for popup review; history is read-only. | | `browser_finish` | Finish the task, apply its cleanup policy, return closed and retained tab receipts, and release ownership. | diff --git a/docs/adr/0001-agenttab-runtime.md b/docs/adr/0001-agenttab-runtime.md index 9cea0e4..ada6a84 100644 --- a/docs/adr/0001-agenttab-runtime.md +++ b/docs/adr/0001-agenttab-runtime.md @@ -28,6 +28,8 @@ A task workspace is visible in Chrome. Task-owned tabs are grouped for display, **Needs your attention** is the human-only input boundary. AgentTab MUST route passkeys, security keys, CAPTCHA, payment secrets, account recovery, unsupported verification, and any credential workflow that returns `needs_user` to an advisory attention notice. The managed 1Password broker is available by default and MAY fill an origin-matching Login item through the private host-to-extension path, but MUST NOT expose the value to an agent or submit the form. Owner-only policy MAY disable or constrain the broker. +*Amended 2026-10-30:* An explicitly enabled email-verification broker MAY fill signup or email-sign-in codes through the same private host-to-extension boundary. It MUST bind an exact managed origin, pathname, recipient, purpose, and authenticated Gmail message, return metadata only, and leave password, MFA, passkey, recovery, human-verification, and Commit safeguards unchanged. + *Amended 2026-09-07:* Notices are non-blocking display metadata. A request MUST NOT pause the scheduler, gate command admission, focus a tab, activate a window, or open the popup; no global observation blackout or host handoff admission state exists. The agent asks the user in chat, the user reports back, and the agent MUST verify the page itself before resolving or dismissing the notice by ID. AgentTab MUST NOT capture human keystrokes, and no tool result may report the user's work as completed on creation. Only the user's explicit popup Open tab action may focus the noticed tab. ### Commit @@ -47,7 +49,7 @@ Task ownership is an execution and coordination boundary. It is not cookie, iden Standard mode MUST NOT expose: - raw cookies or browser storage -- credential values, saved-item lists, passkeys, payment secrets, or human-only input; an explicitly enabled managed broker MAY fill one origin-matching Login item without returning its value +- credential values, saved-item lists, passkeys, payment secrets, or human-only input; a managed broker MAY privately fill an origin-matching Login item or an explicitly granted email-verification code without returning its value - arbitrary JavaScript execution - raw Chrome DevTools Protocol access - coordinate-based actions @@ -93,7 +95,9 @@ MCP, OMP, CLI, TypeScript, and Python are adapters over Core RPC. They are not a 9. `browser_profile` 10. `browser_finish` -`browser_credentials` is enabled by default. Managed policy MAY disable or constrain it. It MUST derive the page origin and task ownership in the host, enforce a candidate and attempt limit no greater than three, use one-use short-lived tokens, and keep credential values out of Core RPC, adapters, responses, and audit output. +The 1Password actions of `browser_credentials` are enabled by default. Managed policy MAY disable or constrain them. They MUST derive the page origin and task ownership in the host, enforce a candidate and attempt limit no greater than three, use one-use short-lived tokens, and keep credential values out of Core RPC, adapters, responses, and audit output. The separate `fill_email` action MUST be disabled by default and require an exact email-code grant, fresh authenticated mail, and the same ownership, revision, and private-value boundaries. + +On a compatible masked-recipient form, `fill_email` MUST enter the code directly when the bounded ASCII `*` mask fits the exact granted recipient and no conflicting address is shown. Every mask on the page MUST be the same bounded mask. An absent or incompatible recipient MUST fail before any write. No email-specific stage, popup approval, or `browser_commit` step is required. Retain every password, MFA, passkey, and human-challenge safeguard. `browser_profile` operates on the Chrome profile rather than a task tab. Its read operations MUST NOT carry an idempotency key and its write operations MUST. Bookmark removal MUST always stage for popup review regardless of YOLO mode, session restore MUST open owned background tabs instead of calling `chrome.sessions.restore`, and the host MUST refuse session restore while origin policy is constrained. Audit MUST NOT record its titles, URLs, or query text. diff --git a/docs/mcp.md b/docs/mcp.md index c1edc4a..af22d2e 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -68,8 +68,8 @@ For MCP, the capability store namespace is `mcp`; OMP uses `omp`; Pi uses `pi`. | `browser_wait` | Requires `tab_id` and one load, URL, text, selector, value, network-idle, download, or `challenge_cleared` condition. Selector waits may set `attached`, `detached`, `visible`, `hidden`, or `enabled` state and scope to a `frame_id`; value waits require selector and expected value and may scope to a frame. A download wait may resume after only its opaque tab-owned `after` cursor. A `challenge_cleared` wait takes the `challenge_id` of an active challenge episode on the tab and completes with `matched: true` once the episode clears or is superseded; if the episode reports it no longer expects automatic clearing, the wait returns early with outcome `needs_user` and result `status: "challenge_needs_user"`; an unknown, ended, or other-task episode fails with `challenge_not_found` and outcome `not_started`, and a timeout carries the latest event in `error.details.challenge`. `timeout_ms` is at most 120 seconds. Load and URL waits also follow Chrome's top-frame navigation events, with polling as a fallback: a load condition matches a top-frame load that completes after the wait starts, and a URL condition also matches committed navigations, same-document history changes (`pushState` and `replaceState`), and fragment changes seen during the wait. A top-frame navigation error during a load or URL wait fails with `navigation_failed`, `outcome: "unknown"`, and Chrome's network error name, never the URL; a superseded or cancelled navigation does not fail the wait. A `tab_only` route accepts load and URL conditions only; frame-scoped reads, value/selector state, network-idle, and download attribution, and challenge waits require the tab-scoped debugger connection available on the `full` route. | | `browser_tabs` | Takes an empty object and lists only the current task's tabs, including each tab's `automation_route`. | | `browser_handoff` | Takes one of four operations. `request` requires a task tab, expected page revision, and prompt; optional `completion: {kind: "url"\|"selector"\|"challenge_cleared", value}` and `timeout_ms` (default five minutes, reminder expiry only). It creates a `notice_id` scoped to the task and tab and returns immediately without pausing, focusing, or blocking any agent work. `status`, `resolve`, and `dismiss` take that `notice_id`. `status` also reports `user_idle_state` as `active`, `idle`, or `locked` from Chrome's idle detection, where `idle` means no keyboard or mouse input for 60 seconds; the field is omitted when Chrome cannot report it. The agent tells the user in chat, verifies the page itself, then resolves or dismisses; a `resolve` against an unmet condition fails with `completion_not_met` and leaves the notice open. Works on a `tab_only` route, but selector and `challenge_cleared` completion require the `full` route. A `challenge_cleared` completion takes the `challenge_id` of an active challenge episode on the request's tab; an unknown, ended, or other-task episode, or one on another tab, fails with `challenge_not_found` and outcome `not_started` and creates no notice. The notice then binds to the episode, so each later challenge event carries its `notice_id`. `resolve` rescans the tab first and succeeds only when the episode has cleared or been superseded, returning the terminal event as `challenge`; otherwise it fails with `completion_not_met`, `error.details.challenge` holds the current active event, and the notice stays open. | -| `browser_commit` | Requires the staged token returned by a prior `commit_required` action and executes that one staged operation. On a `tab_only` route, only a staged close can execute; page-dependent staged actions require the `full` route. A `browser_profile` bookmark removal stage is bound to the task rather than a tab. | -| `browser_credentials` | `prepare` requires a task tab and expected page revision, then returns an opaque short-lived token when the default-enabled 1Password broker is available and one through three Login items match the host-derived current origin. Owner-only policy can disable it. `fill` consumes that token and selected username, password, or one-time-code field refs without returning any value. `next` advances to another bounded candidate. It never submits the form. | +| `browser_commit` | Requires the staged token from a prior `commit_required` result and human popup approval. Execute one bound action. On a `tab_only` route, execute only a staged close; page-dependent actions require the `full` route. A `browser_profile` bookmark removal stage is bound to the task rather than a tab. | +| `browser_credentials` | `prepare` requires a task tab and expected page revision, then returns an opaque short-lived token when the default-enabled 1Password broker is available and one through three Login items match the host-derived current origin. `fill` consumes that token and selected username, password, or one-time-code field refs without returning any value; `next` advances to another bounded candidate. Separately, opt-in `fill_email` takes a Gmail message ID, recipient, signup or email-sign-in purpose, and ordered `otp_refs`, bound to an exact managed origin and pathname. A compatible masked recipient is accepted when it fits the exact granted recipient and no conflicting address is shown. Use `browser_act` for explicit submission; you may complete verification on entering the final code character. | | `browser_profile` | Requires one `operation` over the Chrome profile rather than a task tab: bookmark search, tree, update, create, move, and remove; history search; recently closed and synced sessions, plus restore; download search, cancel, pause, and resume. Read operations take no idempotency key and write operations require one. `bookmarks_remove` always returns `commit_required`. Never focuses Chrome. See [Profile operations](#profile-operations). | | `browser_finish` | Accepts `disposition: "auto" | "close" | "keep"` and optional task-owned `keep_tab_ids`. Automatic mode follows the popup cleanup policy: close task-created tabs while retaining adopted tabs, ask for confirmation, or retain all tabs. Successful finalization ungroups retained tabs, releases ownership, closes the Core connection, and returns closed and retained tab IDs. A tab Chrome has not closed two seconds after removal, as when a "Leave site?" prompt holds it, appears in neither list and closes if the user confirms leaving. An open handoff notice never defers finalization; staged Commit review and other in-flight work defer it without destroying resumability. When the extension refuses to finish, such as for a `keep_tab_ids` entry the task does not own, the error carries the extension's own code and message, and the task stays open. | @@ -142,6 +142,22 @@ Raw TypeScript and Python SDK clients raise `AgentTabTransportError` for an ambi Call `browser_credentials` on an ordinary sign-in page before requesting manual password entry. The broker is available by default unless owner-only policy disables it. `prepare` derives the current origin from host-owned tab state. `fill` accepts only accessibility refs and returns filled-field booleans; credential material never crosses Core RPC. Submit separately through `browser_act`, inspect the result, and use `next` only after the site rejects the current candidate. +For an explicitly granted signup or email-sign-in page, use `action: "fill_email"` +with `tab_id`, `expected_page_revision`, `message_id`, `recipient`, `purpose` +(`signup` or `email_sign_in`), and `otp_refs`. One ref receives the complete +code; multiple refs receive one character each in document order. No code, +origin, credential token, or mailbox query belongs in this request. The host +reads only that message from the configured Gmail mailbox and returns +`status: "filled"`, `filled_email_code: true`, `filled_fields`, and +`page_revision`. The email broker is disabled by default; see +[setup](setup.md#email-verification-broker). Submit separately and retain +normal attention notices for MFA, passkeys, recovery, and human verification. + +`fill_email` accepts a full recipient or one compatible bounded ASCII `*` +mask repeated consistently on the page, provided no conflicting full address +that could match the mask is shown. It fills directly without popup review. +Missing or incompatible recipient evidence fails before any write. + Call `browser_handoff` with `operation: "request"` when credential preparation returns `needs_user`, the bounded candidates fail, or the site requires a passkey, security key, CAPTCHA, payment secret, account recovery, or unsupported verification. The request records an advisory notice for that exact task tab and returns its `notice_id`; it does not pause the scheduler, focus or activate the tab, open the popup, or block any browser operation for any task. The popup lists the notice with an optional Open tab and Dismiss control for the user. The agent then tells the user in its own chat what to finish, waits for the user to report back, and verifies the page itself with a fresh snapshot or wait. When the step looks done, it calls `resolve` with the `notice_id`; when a provided completion condition has not been met, `resolve` fails with `completion_not_met` and the notice stays open. `dismiss` drops the reminder without claiming success, and `status` re-reads one notice. Terminal operations are idempotent and scoped server-side to the authenticated task. Expiry only retires the reminder; it never cancels work or fabricates success. diff --git a/docs/security.md b/docs/security.md index dcca13e..cf7bf8c 100644 --- a/docs/security.md +++ b/docs/security.md @@ -27,7 +27,7 @@ Standard mode exposes exactly these ten MCP tools: Developer mode additionally exposes `browser_developer`. It is disabled by default in the host's local managed policy and is visibly marked on a task. Developer mode is intentionally a broader trust decision. -Standard mode has no raw CDP method, arbitrary JavaScript API, raw cookie or storage API, coordinate action, generic browser-global mutation API, or credential-value API. The narrow exceptions are `browser_open` with `placement: "new_window"`, which creates one unfocused normal window for the first tab of an empty task; `browser_credentials`, which passes an origin-matched login field from 1Password directly to a selected field ref without returning the value; and `browser_profile`, which offers a fixed list of bookmark, history, session, and download operations described below. Internally, the extension uses Chrome debugging APIs for task-scoped accessibility snapshots and precise ref-based actions. Those implementation details are not a Standard-mode escape hatch. +Standard mode has no raw CDP method, arbitrary JavaScript API, raw cookie or storage API, coordinate action, generic browser-global mutation API, or credential-value API. The narrow exceptions are `browser_open` with `placement: "new_window"`, which creates one unfocused normal window for the first tab of an empty task; `browser_credentials`, for private origin-matched 1Password fills or explicitly granted email-verification fills; and `browser_profile`, for the fixed bookmark, history, session, and download operations described below. Credential values are never returned. Internally, the extension uses Chrome debugging APIs for task-scoped accessibility snapshots and precise ref-based actions. Those implementation details are not a Standard-mode escape hatch. Passkeys, security keys, CAPTCHA, payment secrets, account recovery, and unsupported verification require `browser_handoff`. The human completes the step in Chrome; no secret is placed in an AgentTab request. @@ -39,6 +39,26 @@ The broker is available unless `policy.json` sets `one_password.enabled` to `fal The host invokes the absolute `one_password.executable` when configured, or `op` from its process `PATH` otherwise. JSON output is captured in memory. The broker selects only username, password, and one-time-code fields, then sends them through the private host-to-extension credential method. Core RPC, MCP, OMP, Pi, and audit responses contain only candidate counts, booleans, attempt counters, and opaque tokens. Credential values are neither serialized into an adapter request nor written to audit output. A 1Password authentication timeout, no match, unsupported item, provider failure, exhausted attempts, or ambiguous candidate set returns `needs_user`. +## Email verification broker + +Email verification is disabled by default and independent of the 1Password policy. Set an owner-managed `email_codes` grant for an exact origin, pathname, recipient, purpose, and sender domain before using `browser_credentials` with `action: "fill_email"`. Supported purposes are signup and email sign-in, not MFA, account recovery, or password entry. + +The host derives the origin and pathname from its verified task-tab inventory, checks the grant before reading mail, verifies the active Gmail mailbox, and reads exactly the supplied message ID through the configured absolute `gws` executable. No mailbox search, mutation, or authentication setup occurs. Require a fresh message, Gmail's topmost `Authentication-Results`, aligned passing DKIM and DMARC, an allowed sender, the exact recipient, and one unambiguous code. Messages in Spam retain these same checks; drafts, sent mail, and trash are ineligible. + +The code passes only over the private host-to-extension channel. Before writing, the extension checks task ownership, the current revision, exact origin and pathname, same-origin frames, every ref, and visible email-verification context for the intended recipient. Password, MFA, passkey, and human-challenge forms are refused. Recheck the origin, pathname, and target integrity around each write; stop and attempt to restore prior values if the page changes. A failure after input may have reached page listeners is `unknown`. On auto-submitting forms, you can complete verification during the final input event. Inspect the page before retrying an `unknown` result. + +The public request contains message metadata and ordered refs, never a code value or caller-supplied origin. Only sanitized outcomes and fill metadata are returned. Use `browser_act` for explicit submission, preserving normal Commit review. See [setup](setup.md#email-verification-broker) for the policy fields. + +A compatible ASCII `*` masked recipient is accepted automatically when it fits the exact granted recipient and no conflicting address is shown. Every ASCII `*` mask on the page must be the same bounded mask; the page must not display another full address that could match it. `fill_email` fills the code directly, without staging, popup approval, or `browser_commit`.[^email-mask] + +Failure responses retain only allowlisted reason codes with fixed host-generated messages and recovery text. `email_code_recipient_unverified` means the page lacks an exact full recipient or a compatible mask. An absent or incompatible recipient remains refused before any write. Refreshing refs does not resolve missing recipient proof. Native error messages and details never cross this boundary. + +Trusted forwarding requires an exact `relay_senders` address and an allowed original-sender domain in `Reply-To`, plus the intended recipient in the forwarded message.[^email-relay] + +[^email-relay]: Gmail authenticates the relay's From domain. A relay's claimed original sender is not a separate cryptographic authentication of that sender; configure a relay only when you trust its forwarding and recipient attribution. + +[^email-mask]: A compatible mask is not proof of an exact recipient on its own. Acceptance also requires the exact managed grant and a fresh authenticated message for that recipient; it does not override password, MFA, passkey, or human-challenge safeguards. + ## Permissions The manifest declares `nativeMessaging`, `debugger`, `tabs`, `tabGroups`, `storage`, `alarms`, `bookmarks`, `history`, `sessions`, `downloads`, `webNavigation`, and `idle`, with `` host access. Chrome does not permit `debugger` in `optional_permissions`, so it is a required install-time permission. `scripting` remains optional: the popup requests it when automation is enabled and removes it when automation is turned off. `cookies`, `contentSettings`, and `activeTab` are not requested. @@ -87,7 +107,7 @@ Before a Standard mutation, the extension prepares the target, classifies recogn Commit is an optional, best-effort semantic barrier, not proof that an action is harmless. In review mode it requires two distinct events: human approval in the popup and the agent's later Commit request. A page can hide an external effect behind an innocent label, alter meaning through script, or use an effect AgentTab does not recognize. Harmless-looking controls may execute without review. Batches are sequential and non-atomic: review mode stops before a staged action and never runs later actions implicitly. -YOLO mode is enabled by default. It bypasses only Commit review. Task ownership, origin policy, expected revisions, restricted-origin routing, credential isolation, and action validation remain enforced. Turning YOLO mode on discards pending staged actions; turning it off restores the two-party Commit flow. YOLO mode never skips review for `browser_profile` bookmark removal. +YOLO mode is enabled by default. It bypasses only optional Commit review. Task ownership, origin policy, expected revisions, restricted-origin routing, credential isolation, and action validation remain enforced. Turning YOLO mode on discards pending staged actions; turning it off restores the two-party Commit flow. Always obtain popup approval for `browser_profile` bookmark removal. ## Attention notices diff --git a/docs/setup.md b/docs/setup.md index e80a163..b2fbab8 100644 --- a/docs/setup.md +++ b/docs/setup.md @@ -172,6 +172,68 @@ number of origin-matching Login items, unavailable CLI state, or an unsupported verification step returns control to the user instead of trying additional credentials. +## Email verification broker + +Email verification is opt-in and independent of 1Password. Use an already +authenticated `gws` Gmail CLI; AgentTab does not install it, configure its +authentication, or change the mailbox. Add `email_codes` to the existing +owner-only policy, preserving any other settings: + +```json +{ + "email_codes": { + "enabled": true, + "executable": "/absolute/path/to/gws", + "mailbox": "owner@example.test", + "max_age_ms": 600000, + "timeout_ms": 30000, + "grants": [ + { + "origin": "https://accounts.service.example", + "paths": ["/signup/verify-email"], + "recipient": "member@example.test", + "purposes": ["signup"], + "sender_domains": ["service.example"], + "relay_senders": [], + "code_pattern": "\\b([A-Z0-9]{6})\\b" + } + ] + } +} +``` + +Replace the example executable and addresses with the approved local values. +Origins and pathnames are exact, with no wildcards or prefixes. Use only +`signup` or `email_sign_in`; MFA, authenticator, passkey, and recovery paths +are not eligible. Each code pattern needs exactly one capture group yielding +4 to 12 ASCII letters or digits. `max_age_ms` must be 30000 through 900000; +`timeout_ms` must be 5000 through 120000. Reload the extension to restart +the native host after a policy change. + +Select a message ID for the expected verification email, including Spam when +appropriate. Call `browser_credentials` with `action: "fill_email"`, the owned +`tab_id`, current `expected_page_revision`, `message_id`, exact `recipient`, +`purpose`, and `otp_refs`. Provide one ref for a complete-code input or one +ref per code character in document order. Never include the code, an origin, +a credential token, or a Gmail query. The host verifies the mailbox, sender, +recipient, freshness, and code before filling. The result contains only fill +metadata. Use `browser_act` for explicit submission. Inspect the page +before retrying an `unknown` result. On auto-submitting forms, you can +complete verification as soon as the last character is entered. + +A compatible masked recipient is accepted automatically when it fits the exact +granted recipient and no conflicting address is shown. Every ASCII `*` mask +on the page must be the same bounded mask. `fill_email` enters the code +directly, with no staged review, popup approval, or `browser_commit` step. +An absent or incompatible recipient is refused before any write. Password, +MFA, passkey, and human-challenge safeguards still apply. + +For forwarding, allow only an exact trusted relay address and review the +[relay trust boundary](security.md#email-verification-broker). Unsupported or +ambiguous messages fail closed. Existing password, MFA, and human-verification +handoff requirements remain unchanged. Load matching host and extension +builds and start a fresh adapter session when adding this action. + ## Migration from Chrome Bridge v1.0.1 AgentTab v2 is side-by-side and recoverable: diff --git a/host-rs/crates/agenttab-host/src/credentials.rs b/host-rs/crates/agenttab-host/src/credentials.rs index daf5290..749b65d 100644 --- a/host-rs/crates/agenttab-host/src/credentials.rs +++ b/host-rs/crates/agenttab-host/src/credentials.rs @@ -134,7 +134,7 @@ trait CredentialProvider: Send + Sync { } #[derive(Debug, Clone, Copy)] -enum ProviderError { +pub(crate) enum ProviderError { Timeout, Unavailable, } @@ -500,10 +500,28 @@ fn run_op( executable: &Path, arguments: &[String], timeout: Duration, +) -> Result, ProviderError> { + run_provider_process( + executable, + arguments, + &[("OP_BIOMETRIC_UNLOCK_ENABLED", "true")], + timeout, + ) +} + +/// Runs a configured credential provider CLI from an argument vector (never a +/// shell), with stdin closed, stdout bounded and captured, stderr bounded and +/// discarded, and a hard deadline. Provider output never reaches callers on +/// failure. +pub(crate) fn run_provider_process( + executable: &Path, + arguments: &[String], + environment: &[(&str, &str)], + timeout: Duration, ) -> Result, ProviderError> { let mut child = Command::new(executable) .args(arguments) - .env("OP_BIOMETRIC_UNLOCK_ENABLED", "true") + .envs(environment.iter().copied()) .stdin(Stdio::null()) .stdout(Stdio::piped()) .stderr(Stdio::piped()) diff --git a/host-rs/crates/agenttab-host/src/email_codes.rs b/host-rs/crates/agenttab-host/src/email_codes.rs new file mode 100644 index 0000000..9f37683 --- /dev/null +++ b/host-rs/crates/agenttab-host/src/email_codes.rs @@ -0,0 +1,2814 @@ +//! Brokered email verification codes. +//! +//! The broker resolves one exact managed grant (origin, URL pathname, +//! recipient, purpose) before it touches the mailbox, then reads exactly one +//! caller-named Gmail message through the configured, already-authenticated +//! `gws` CLI. The message must be fresh, authenticated by Gmail's own +//! `Authentication-Results`, sent by a configured sender domain (directly or +//! through an exact trusted relay), and addressed to the exact recipient. The +//! extracted code stays in host memory; every failure is a fixed, sanitized +//! error that never carries provider output, message content, or the code. + +use crate::credentials::{run_provider_process, ProviderError}; +use base64::alphabet; +use base64::engine::general_purpose::{GeneralPurpose, GeneralPurposeConfig}; +use base64::engine::DecodePaddingMode; +use base64::Engine; +use regex::{Regex, RegexBuilder}; +use serde::Deserialize; +use serde_json::{json, Value}; +use std::net::IpAddr; +use std::path::PathBuf; +use std::sync::{Arc, LazyLock}; +use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; +use url::{Host, Url}; + +pub const EMAIL_CODE_PURPOSES: [&str; 2] = ["signup", "email_sign_in"]; + +const DEFAULT_MAX_AGE_MS: u64 = 600_000; +const MIN_MAX_AGE_MS: u64 = 30_000; +const MAX_MAX_AGE_MS: u64 = 900_000; +const DEFAULT_TIMEOUT_MS: u64 = 30_000; +const MIN_TIMEOUT_MS: u64 = 5_000; +const MAX_TIMEOUT_MS: u64 = 120_000; +/// Gmail `internalDate` may lead the host clock slightly; more than this is +/// treated as a materially future (untrustworthy) timestamp. +const MAX_FUTURE_SKEW_MS: u64 = 120_000; + +const MAX_GRANTS: usize = 64; +const MAX_GRANT_LIST_ITEMS: usize = 32; +const MAX_PATH_CHARS: usize = 2048; +const MAX_PATTERN_CHARS: usize = 512; +const PATTERN_SIZE_LIMIT: usize = 256 * 1024; +const MIN_CODE_CHARS: usize = 4; +const MAX_CODE_CHARS: usize = 12; +const MAX_MESSAGE_ID_CHARS: usize = 128; +const MIN_EMAIL_CHARS: usize = 3; +const MAX_EMAIL_CHARS: usize = 320; +const MAX_LOCAL_PART_CHARS: usize = 64; +const MAX_DOMAIN_CHARS: usize = 253; +const MAX_MIME_DEPTH: usize = 16; +const MAX_MIME_PARTS: usize = 256; +const MAX_DECODED_TEXT_BYTES: usize = 4 * 1024 * 1024; + +const GMAIL_AUTHSERV_ID: &str = "mx.google.com"; +/// Drafts, sent, or trashed mail are not delivered verification messages. +/// Spam classification does not replace the sender-authentication checks. +const INELIGIBLE_LABELS: [&str; 3] = ["TRASH", "DRAFT", "SENT"]; +/// Headers that name the envelope or visible recipient of a message. +const RECIPIENT_HEADERS: [&str; 4] = ["to", "cc", "delivered-to", "x-original-to"]; + +/// Path tokens that designate MFA, authenticator, or hardware-key flows. +/// Email-code grants never cover those flows. +const SECOND_FACTOR_PATH_TOKENS: &[&str] = &[ + "mfa", + "2fa", + "tfa", + "2sv", + "totp", + "authenticator", + "authenticators", + "otpauth", + "twofactor", + "multifactor", + "webauthn", + "passkey", + "passkeys", + "securitykey", + "securitykeys", + "u2f", + "fido", + "fido2", +]; +/// Compact (alphanumeric-only) phrases that designate the same flows when +/// written with separators, such as `two-factor` or `second_factor`. +const SECOND_FACTOR_PATH_PHRASES: &[&str] = &[ + "twofactor", + "multifactor", + "twostep", + "2step", + "secondfactor", + "authenticator", + "securitykey", + "passkey", +]; + +const GMAIL_BASE64: GeneralPurpose = GeneralPurpose::new( + &alphabet::URL_SAFE, + GeneralPurposeConfig::new().with_decode_padding_mode(DecodePaddingMode::Indifferent), +); +const MIME_BASE64: GeneralPurpose = GeneralPurpose::new( + &alphabet::STANDARD, + GeneralPurposeConfig::new().with_decode_padding_mode(DecodePaddingMode::Indifferent), +); + +#[derive(Clone, Deserialize)] +#[serde(default, deny_unknown_fields)] +pub struct EmailCodePolicy { + pub enabled: bool, + pub executable: Option, + pub mailbox: Option, + pub max_age_ms: u64, + pub timeout_ms: u64, + pub grants: Vec, +} + +impl Default for EmailCodePolicy { + fn default() -> Self { + Self { + enabled: false, + executable: None, + mailbox: None, + max_age_ms: DEFAULT_MAX_AGE_MS, + timeout_ms: DEFAULT_TIMEOUT_MS, + grants: Vec::new(), + } + } +} + +impl std::fmt::Debug for EmailCodePolicy { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("EmailCodePolicy") + .field("enabled", &self.enabled) + .field("has_executable", &self.executable.is_some()) + .field("has_mailbox", &self.mailbox.is_some()) + .field("max_age_ms", &self.max_age_ms) + .field("timeout_ms", &self.timeout_ms) + .field("grants", &self.grants) + .finish() + } +} + +#[derive(Clone, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct EmailCodeGrant { + pub origin: String, + pub paths: Vec, + pub recipient: String, + pub purposes: Vec, + pub sender_domains: Vec, + #[serde(default)] + pub relay_senders: Vec, + pub code_pattern: String, +} + +impl std::fmt::Debug for EmailCodeGrant { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("EmailCodeGrant") + .field("origin", &self.origin) + .field("path_count", &self.paths.len()) + .field("purposes", &self.purposes) + .field("sender_domain_count", &self.sender_domains.len()) + .field("relay_sender_count", &self.relay_senders.len()) + .finish() + } +} + +impl EmailCodePolicy { + /// Validates the whole policy, including disabled grants. Messages name + /// the failing field by index and never echo configured values. + pub fn validate(&self) -> Result<(), String> { + if !(MIN_MAX_AGE_MS..=MAX_MAX_AGE_MS).contains(&self.max_age_ms) { + return Err(format!( + "max_age_ms must be between {MIN_MAX_AGE_MS} and {MAX_MAX_AGE_MS}" + )); + } + if !(MIN_TIMEOUT_MS..=MAX_TIMEOUT_MS).contains(&self.timeout_ms) { + return Err(format!( + "timeout_ms must be between {MIN_TIMEOUT_MS} and {MAX_TIMEOUT_MS}" + )); + } + if let Some(executable) = &self.executable { + if !executable.is_absolute() { + return Err("executable must be an absolute path to the gws CLI".into()); + } + } + if let Some(mailbox) = &self.mailbox { + if normalize_email(mailbox).is_none() { + return Err("mailbox must be one valid email address".into()); + } + } + if self.enabled { + if self.executable.is_none() { + return Err("executable is required when email_codes is enabled".into()); + } + if self.mailbox.is_none() { + return Err("mailbox is required when email_codes is enabled".into()); + } + if self.grants.is_empty() { + return Err("at least one grant is required when email_codes is enabled".into()); + } + } + if self.grants.len() > MAX_GRANTS { + return Err(format!("grants must contain at most {MAX_GRANTS} items")); + } + for (index, grant) in self.grants.iter().enumerate() { + validate_grant(grant).map_err(|reason| format!("grants[{index}]: {reason}"))?; + } + for (index, grant) in self.grants.iter().enumerate() { + for (other_index, other) in self.grants.iter().enumerate().skip(index + 1) { + let overlaps = grant.origin == other.origin + && grant.recipient.eq_ignore_ascii_case(&other.recipient) + && grant.paths.iter().any(|path| other.paths.contains(path)) + && grant + .purposes + .iter() + .any(|purpose| other.purposes.contains(purpose)); + if overlaps { + return Err(format!( + "grants[{index}] and grants[{other_index}] overlap on origin, path, recipient, and purpose" + )); + } + } + } + Ok(()) + } +} + +fn validate_grant(grant: &EmailCodeGrant) -> Result<(), String> { + let origin = canonical_secure_origin(&grant.origin) + .ok_or("origin must be one exact HTTPS origin or explicit loopback HTTP origin")?; + if grant.paths.is_empty() || grant.paths.len() > MAX_GRANT_LIST_ITEMS { + return Err(format!( + "paths must contain 1 to {MAX_GRANT_LIST_ITEMS} exact URL pathnames" + )); + } + for (index, path) in grant.paths.iter().enumerate() { + if !is_exact_pathname(&origin, path) { + return Err(format!( + "paths[{index}] must be one exact canonical URL pathname without query, fragment, or wildcard" + )); + } + if designates_second_factor(path) { + return Err(format!( + "paths[{index}] designates an MFA, authenticator, or security-key flow" + )); + } + if grant.paths[..index].contains(path) { + return Err(format!("paths[{index}] is a duplicate")); + } + } + if normalize_email(&grant.recipient).is_none() { + return Err("recipient must be one valid email address".into()); + } + if grant.purposes.is_empty() || grant.purposes.len() > EMAIL_CODE_PURPOSES.len() { + return Err("purposes must contain signup and/or email_sign_in".into()); + } + for (index, purpose) in grant.purposes.iter().enumerate() { + if !EMAIL_CODE_PURPOSES.contains(&purpose.as_str()) { + return Err(format!("purposes[{index}] must be signup or email_sign_in")); + } + if grant.purposes[..index].contains(purpose) { + return Err(format!("purposes[{index}] is a duplicate")); + } + } + if grant.sender_domains.is_empty() || grant.sender_domains.len() > MAX_GRANT_LIST_ITEMS { + return Err(format!( + "sender_domains must contain 1 to {MAX_GRANT_LIST_ITEMS} exact domains" + )); + } + for (index, domain) in grant.sender_domains.iter().enumerate() { + if !is_valid_domain(domain) || domain.bytes().any(|byte| byte.is_ascii_uppercase()) { + return Err(format!( + "sender_domains[{index}] must be one exact lowercase domain" + )); + } + if grant.sender_domains[..index].contains(domain) { + return Err(format!("sender_domains[{index}] is a duplicate")); + } + } + if grant.relay_senders.len() > MAX_GRANT_LIST_ITEMS { + return Err(format!( + "relay_senders must contain at most {MAX_GRANT_LIST_ITEMS} addresses" + )); + } + for (index, sender) in grant.relay_senders.iter().enumerate() { + if normalize_email(sender).as_deref() != Some(sender.as_str()) { + return Err(format!( + "relay_senders[{index}] must be one exact lowercase email address" + )); + } + if grant.relay_senders[..index].contains(sender) { + return Err(format!("relay_senders[{index}] is a duplicate")); + } + } + compile_code_pattern(&grant.code_pattern)?; + Ok(()) +} + +fn compile_code_pattern(pattern: &str) -> Result { + if pattern.is_empty() || pattern.chars().count() > MAX_PATTERN_CHARS { + return Err(format!( + "code_pattern must contain 1 to {MAX_PATTERN_CHARS} characters" + )); + } + let regex = RegexBuilder::new(pattern) + .size_limit(PATTERN_SIZE_LIMIT) + .dfa_size_limit(PATTERN_SIZE_LIMIT) + .build() + .map_err(|_| "code_pattern must be a valid bounded regular expression".to_string())?; + if regex.captures_len() != 2 { + return Err("code_pattern must contain exactly one capture group".into()); + } + Ok(regex) +} + +/// Returns the canonical origin when `origin` is already exactly that +/// canonical HTTPS (or loopback HTTP) origin: no path, slash, userinfo, +/// default port, wildcard, or case variation. +fn canonical_secure_origin(origin: &str) -> Option { + let url = Url::parse(origin).ok()?; + let secure = match url.scheme() { + "https" => true, + "http" => is_loopback_host(&url), + _ => false, + }; + (secure + && url.host_str().is_some_and(|host| !host.contains('*')) + && url.origin().ascii_serialization() == origin) + .then_some(url) +} + +fn is_loopback_host(url: &Url) -> bool { + match url.host() { + Some(Host::Domain(domain)) => domain == "localhost", + Some(Host::Ipv4(address)) => IpAddr::V4(address).is_loopback(), + Some(Host::Ipv6(address)) => IpAddr::V6(address).is_loopback(), + None => false, + } +} + +fn is_exact_pathname(origin: &Url, path: &str) -> bool { + if !path.starts_with('/') + || path.starts_with("//") + || path.chars().count() > MAX_PATH_CHARS + || path.chars().any(|character| { + character.is_control() + || character.is_whitespace() + || matches!(character, '?' | '#' | '*' | '\\') + }) + { + return false; + } + origin + .join(path) + .is_ok_and(|joined| joined.path() == path && joined.origin() == origin.origin()) +} + +fn designates_second_factor(path: &str) -> bool { + let decoded = percent_decode_lossy(path).to_ascii_lowercase(); + let token_match = decoded + .split(|character: char| !character.is_ascii_alphanumeric()) + .any(|token| SECOND_FACTOR_PATH_TOKENS.contains(&token)); + let compact = decoded + .chars() + .filter(char::is_ascii_alphanumeric) + .collect::(); + token_match + || SECOND_FACTOR_PATH_PHRASES + .iter() + .any(|phrase| compact.contains(phrase)) +} + +fn percent_decode_lossy(text: &str) -> String { + let bytes = text.as_bytes(); + let mut decoded = Vec::with_capacity(bytes.len()); + let mut index = 0; + while index < bytes.len() { + if bytes[index] == b'%' && index + 2 < bytes.len() { + if let (Some(high), Some(low)) = + (hex_value(bytes[index + 1]), hex_value(bytes[index + 2])) + { + decoded.push((high << 4) | low); + index += 3; + continue; + } + } + decoded.push(bytes[index]); + index += 1; + } + String::from_utf8_lossy(&decoded).into_owned() +} + +fn hex_value(byte: u8) -> Option { + match byte { + b'0'..=b'9' => Some(byte - b'0'), + b'a'..=b'f' => Some(byte - b'a' + 10), + b'A'..=b'F' => Some(byte - b'A' + 10), + _ => None, + } +} + +/// Non-secret routing metadata for one email-code resolution. The host +/// derives `origin` and `path` from the verified tab URL. +#[derive(Clone, Copy)] +pub struct EmailCodeRequest<'a> { + pub origin: &'a str, + pub path: &'a str, + pub recipient: &'a str, + pub purpose: &'a str, + pub message_id: &'a str, +} + +/// Fixed, sanitized failures. No variant carries provider output, message +/// content, addresses, or code material. +#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)] +pub enum EmailCodeError { + #[error("email code policy failed validation")] + PolicyInvalid, + #[error("email verification code filling is disabled by AgentTab policy")] + Disabled, + #[error("email code request is invalid")] + InvalidRequest, + #[error("no managed email code grant matches this origin, path, recipient, and purpose")] + GrantNotFound, + #[error("the mailbox provider is unavailable")] + ProviderUnavailable, + #[error("the mailbox provider timed out")] + ProviderTimeout, + #[error("the authenticated mailbox does not match the managed mailbox")] + MailboxMismatch, + #[error("the requested message is unavailable or malformed")] + MessageUnavailable, + #[error("the requested message is not eligible as a verification code source")] + MessageIneligible, + #[error("the requested message is not fresh")] + MessageNotFresh, + #[error("the message sender is not trusted by the managed grant")] + SenderUntrusted, + #[error("the message sender is not authenticated by Gmail")] + SenderUnauthenticated, + #[error("the message is not addressed to the requested recipient")] + RecipientMismatch, + #[error("the message does not contain one valid verification code")] + CodeNotFound, + #[error("the message contains more than one candidate verification code")] + CodeAmbiguous, +} + +impl EmailCodeError { + pub fn code(&self) -> &'static str { + match self { + Self::PolicyInvalid => "email_code_policy_invalid", + Self::Disabled => "email_code_provider_disabled", + Self::InvalidRequest => "email_code_request_invalid", + Self::GrantNotFound => "email_code_grant_not_found", + Self::ProviderUnavailable => "email_code_provider_unavailable", + Self::ProviderTimeout => "email_code_provider_timeout", + Self::MailboxMismatch => "email_code_mailbox_mismatch", + Self::MessageUnavailable => "email_code_message_unavailable", + Self::MessageIneligible => "email_code_message_ineligible", + Self::MessageNotFresh => "email_code_message_stale", + Self::SenderUntrusted => "email_code_sender_untrusted", + Self::SenderUnauthenticated => "email_code_sender_unauthenticated", + Self::RecipientMismatch => "email_code_recipient_mismatch", + Self::CodeNotFound => "email_code_not_found", + Self::CodeAmbiguous => "email_code_ambiguous", + } + } + + pub fn recovery(&self) -> &'static str { + match self { + Self::PolicyInvalid => "Correct email_codes in AgentTab's managed local policy, then restart the native host.", + Self::Disabled => "Enable email_codes with an exact grant in AgentTab's managed local policy, or enter the code manually.", + Self::InvalidRequest => "Provide one Gmail message ID, the exact recipient address, and purpose signup or email_sign_in.", + Self::GrantNotFound => "Add an exact managed grant for this verification page, recipient, and purpose, or enter the code manually.", + Self::ProviderUnavailable => "Verify the configured gws CLI is installed and already authenticated for the managed mailbox, and that the message ID exists.", + Self::ProviderTimeout => "Check the gws CLI and network connectivity, then retry.", + Self::MailboxMismatch => "Authenticate gws to the managed mailbox yourself, or correct email_codes.mailbox.", + Self::MessageUnavailable => "Use the message ID of the delivered verification email, or enter the code manually.", + Self::MessageIneligible => "Use a delivered inbox message; spam, drafts, sent, and trashed mail are refused.", + Self::MessageNotFresh => "Request a new verification email and use its message ID.", + Self::SenderUntrusted => "Use a verification email from a sender configured for this grant, or enter the code manually.", + Self::SenderUnauthenticated => "Gmail did not authenticate this sender with aligned DKIM and DMARC; enter the code manually.", + Self::RecipientMismatch => "Use the verification email sent to the requested recipient address.", + Self::CodeNotFound => "Check the grant's code_pattern against the verification email, or enter the code manually.", + Self::CodeAmbiguous => "Tighten the grant's code_pattern, or enter the code manually.", + } + } +} + +trait MailProvider: Send + Sync { + fn profile(&self, deadline: Instant) -> Result; + fn message(&self, message_id: &str, deadline: Instant) -> Result; +} + +/// Reads Gmail through the configured `gws` CLI. It never configures auth, +/// searches, or mutates the mailbox: only `users.getProfile` and one +/// `users.messages.get` for the exact requested ID. +struct GwsProvider { + executable: Option, +} + +impl GwsProvider { + fn new(policy: &EmailCodePolicy) -> Self { + Self { + executable: policy.executable.clone(), + } + } + + fn run(&self, arguments: Vec, deadline: Instant) -> Result { + let executable = self.executable.as_ref().ok_or(ProviderError::Unavailable)?; + let remaining = deadline.saturating_duration_since(Instant::now()); + if remaining.is_zero() { + return Err(ProviderError::Timeout); + } + let output = run_provider_process(executable, &arguments, &[], remaining)?; + let value: Value = + serde_json::from_slice(&output).map_err(|_| ProviderError::Unavailable)?; + if !value.is_object() || value.get("error").is_some() { + return Err(ProviderError::Unavailable); + } + Ok(value) + } +} + +impl MailProvider for GwsProvider { + fn profile(&self, deadline: Instant) -> Result { + self.run( + vec![ + "gmail".into(), + "users".into(), + "getProfile".into(), + "--params".into(), + json!({ "userId": "me" }).to_string(), + ], + deadline, + ) + } + + fn message(&self, message_id: &str, deadline: Instant) -> Result { + self.run( + vec![ + "gmail".into(), + "users".into(), + "messages".into(), + "get".into(), + "--params".into(), + json!({ "userId": "me", "id": message_id, "format": "full" }).to_string(), + ], + deadline, + ) + } +} + +struct CompiledGrant { + origin: String, + paths: Vec, + recipient: String, + purposes: Vec, + sender_domains: Vec, + relay_senders: Vec, + code_pattern: Regex, +} + +struct BrokerConfig { + enabled: bool, + mailbox_key: Option, + max_age_ms: u64, + timeout: Duration, + grants: Vec, +} + +impl BrokerConfig { + fn compile(policy: &EmailCodePolicy) -> Option { + policy.validate().ok()?; + let grants = policy + .grants + .iter() + .map(|grant| { + Some(CompiledGrant { + origin: grant.origin.clone(), + paths: grant.paths.clone(), + recipient: normalize_email(&grant.recipient)?, + purposes: grant.purposes.clone(), + sender_domains: grant.sender_domains.clone(), + relay_senders: grant.relay_senders.clone(), + code_pattern: compile_code_pattern(&grant.code_pattern).ok()?, + }) + }) + .collect::>>()?; + Some(Self { + enabled: policy.enabled, + mailbox_key: policy.mailbox.as_deref().and_then(mailbox_key), + max_age_ms: policy.max_age_ms, + timeout: Duration::from_millis(policy.timeout_ms), + grants, + }) + } + + fn grant_for(&self, request: &EmailCodeRequest<'_>, recipient: &str) -> Option<&CompiledGrant> { + self.grants.iter().find(|grant| { + grant.origin == request.origin + && grant.paths.iter().any(|path| path == request.path) + && grant.recipient == recipient + && grant + .purposes + .iter() + .any(|purpose| purpose == request.purpose) + }) + } +} + +pub struct EmailCodeBroker { + config: Option, + provider: Arc, + now_ms: fn() -> u64, +} + +impl std::fmt::Debug for EmailCodeBroker { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("EmailCodeBroker") + .field("policy_valid", &self.config.is_some()) + .field( + "enabled", + &self.config.as_ref().is_some_and(|config| config.enabled), + ) + .field( + "grant_count", + &self.config.as_ref().map_or(0, |config| config.grants.len()), + ) + .finish() + } +} + +impl EmailCodeBroker { + pub fn new(policy: EmailCodePolicy) -> Self { + let provider = Arc::new(GwsProvider::new(&policy)); + Self { + config: BrokerConfig::compile(&policy), + provider, + now_ms: system_now_ms, + } + } + + #[cfg(test)] + fn with_parts( + policy: EmailCodePolicy, + provider: Arc, + now_ms: fn() -> u64, + ) -> Self { + Self { + config: BrokerConfig::compile(&policy), + provider, + now_ms, + } + } + + /// Resolves the verification code for one exact grant and message. The + /// returned code must stay in host memory. + pub fn resolve(&self, request: EmailCodeRequest<'_>) -> Result { + let config = self.config.as_ref().ok_or(EmailCodeError::PolicyInvalid)?; + if !config.enabled { + return Err(EmailCodeError::Disabled); + } + if !is_message_id(request.message_id) || !EMAIL_CODE_PURPOSES.contains(&request.purpose) { + return Err(EmailCodeError::InvalidRequest); + } + let recipient = normalize_email(request.recipient).ok_or(EmailCodeError::InvalidRequest)?; + let grant = config + .grant_for(&request, &recipient) + .ok_or(EmailCodeError::GrantNotFound)?; + let expected_mailbox = config + .mailbox_key + .as_deref() + .ok_or(EmailCodeError::PolicyInvalid)?; + + let deadline = Instant::now() + config.timeout; + let profile = self.provider.profile(deadline).map_err(provider_error)?; + let authenticated = profile + .get("emailAddress") + .and_then(Value::as_str) + .and_then(mailbox_key) + .ok_or(EmailCodeError::MailboxMismatch)?; + if authenticated != expected_mailbox { + return Err(EmailCodeError::MailboxMismatch); + } + + let message = self + .provider + .message(request.message_id, deadline) + .map_err(provider_error)?; + verify_and_extract( + &message, + request.message_id, + grant, + config.max_age_ms, + (self.now_ms)(), + ) + } +} + +fn provider_error(error: ProviderError) -> EmailCodeError { + match error { + ProviderError::Timeout => EmailCodeError::ProviderTimeout, + ProviderError::Unavailable => EmailCodeError::ProviderUnavailable, + } +} + +fn system_now_ms() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_millis() + .try_into() + .unwrap_or(u64::MAX) +} + +fn is_message_id(value: &str) -> bool { + (1..=MAX_MESSAGE_ID_CHARS).contains(&value.len()) + && value.bytes().all(|byte| byte.is_ascii_hexdigit()) +} + +enum SenderRoute { + Direct, + Relay, +} + +fn verify_and_extract( + message: &Value, + message_id: &str, + grant: &CompiledGrant, + max_age_ms: u64, + now_ms: u64, +) -> Result { + let returned_id = message + .get("id") + .and_then(Value::as_str) + .ok_or(EmailCodeError::MessageUnavailable)?; + if !returned_id.eq_ignore_ascii_case(message_id) { + return Err(EmailCodeError::MessageUnavailable); + } + if let Some(labels) = message.get("labelIds") { + let labels = labels + .as_array() + .ok_or(EmailCodeError::MessageUnavailable)?; + if labels.iter().any(|label| { + label + .as_str() + .is_some_and(|label| INELIGIBLE_LABELS.contains(&label)) + }) { + return Err(EmailCodeError::MessageIneligible); + } + } + + let internal_date = match message.get("internalDate") { + Some(Value::String(value)) => value.parse::().ok(), + Some(Value::Number(value)) => value.as_u64(), + _ => None, + } + .ok_or(EmailCodeError::MessageUnavailable)?; + if internal_date > now_ms.saturating_add(MAX_FUTURE_SKEW_MS) + || now_ms.saturating_sub(internal_date) > max_age_ms + { + return Err(EmailCodeError::MessageNotFresh); + } + + let payload = message + .get("payload") + .filter(|payload| payload.is_object()) + .ok_or(EmailCodeError::MessageUnavailable)?; + let headers = part_headers(payload)?; + + let from = single_header(&headers, "from")? + .and_then(parse_single_address) + .ok_or(EmailCodeError::MessageUnavailable)?; + let from_domain = address_domain(&from); + + let route = if grant.relay_senders.iter().any(|sender| *sender == from) { + let original_sender = single_header(&headers, "reply-to") + .ok() + .flatten() + .and_then(parse_single_address) + .ok_or(EmailCodeError::SenderUntrusted)?; + if !grant + .sender_domains + .iter() + .any(|domain| domain == address_domain(&original_sender)) + { + return Err(EmailCodeError::SenderUntrusted); + } + SenderRoute::Relay + } else if grant + .sender_domains + .iter() + .any(|domain| domain == from_domain) + { + SenderRoute::Direct + } else { + return Err(EmailCodeError::SenderUntrusted); + }; + + if !gmail_authenticates_sender(&headers, from_domain) { + return Err(EmailCodeError::SenderUnauthenticated); + } + + let subject = match single_header(&headers, "subject")? { + Some(subject) => normalize_text( + &decode_encoded_words(subject).ok_or(EmailCodeError::MessageUnavailable)?, + ), + None => String::new(), + }; + let mut texts = MessageTexts::default(); + collect_text_parts(payload, 0, &mut texts)?; + + let addressed_in_headers = headers + .iter() + .filter(|(name, _)| RECIPIENT_HEADERS.contains(&name.as_str())) + .filter_map(|(_, value)| parse_address_list(value)) + .flatten() + .any(|address| address == grant.recipient); + let addressed = match route { + SenderRoute::Direct => addressed_in_headers, + SenderRoute::Relay => { + addressed_in_headers + || contains_address_token(&subject, &grant.recipient) + || texts + .plain + .iter() + .chain(texts.html.iter()) + .any(|text| contains_address_token(text, &grant.recipient)) + } + }; + if !addressed { + return Err(EmailCodeError::RecipientMismatch); + } + + extract_code(&grant.code_pattern, &subject, &texts) +} + +/// Prefers the Subject and text/plain parts; visible HTML text is consulted +/// only when neither yields a candidate. Raw HTML markup is never scanned. +fn extract_code( + pattern: &Regex, + subject: &str, + texts: &MessageTexts, +) -> Result { + let mut codes = Vec::new(); + collect_codes(pattern, subject, false, &mut codes)?; + for text in &texts.plain { + collect_codes(pattern, text, false, &mut codes)?; + } + if codes.is_empty() { + for text in &texts.html { + collect_codes(pattern, text, true, &mut codes)?; + } + } + match codes.len() { + 0 => Err(EmailCodeError::CodeNotFound), + 1 => Ok(codes.remove(0)), + _ => Err(EmailCodeError::CodeAmbiguous), + } +} + +fn collect_codes( + pattern: &Regex, + text: &str, + from_html: bool, + codes: &mut Vec, +) -> Result<(), EmailCodeError> { + for captures in pattern.captures_iter(text) { + let capture = captures.get(1).ok_or(EmailCodeError::CodeNotFound)?; + // A '#'-prefixed token in HTML-derived text is a color or anchor + // reference, never a verification code. + if from_html && text[..capture.start()].ends_with('#') { + continue; + } + let code = capture.as_str(); + if !(MIN_CODE_CHARS..=MAX_CODE_CHARS).contains(&code.len()) + || !code.bytes().all(|byte| byte.is_ascii_alphanumeric()) + { + return Err(EmailCodeError::CodeNotFound); + } + if !codes.iter().any(|existing| existing == code) { + codes.push(code.to_string()); + } + } + Ok(()) +} + +#[derive(Default)] +struct MessageTexts { + plain: Vec, + html: Vec, + decoded_bytes: usize, +} + +fn part_headers(part: &Value) -> Result, EmailCodeError> { + let Some(headers) = part.get("headers") else { + return Ok(Vec::new()); + }; + headers + .as_array() + .ok_or(EmailCodeError::MessageUnavailable)? + .iter() + .map(|header| { + let name = header.get("name").and_then(Value::as_str); + let value = header.get("value").and_then(Value::as_str); + match (name, value) { + (Some(name), Some(value)) => Ok((name.to_ascii_lowercase(), value.to_string())), + _ => Err(EmailCodeError::MessageUnavailable), + } + }) + .collect() +} + +/// Returns the only header with `name`, `None` when absent, and an error when +/// the header is duplicated (a classic spoofing vector). +fn single_header<'a>( + headers: &'a [(String, String)], + name: &str, +) -> Result, EmailCodeError> { + let mut matches = headers.iter().filter(|(header, _)| header == name); + let first = matches.next().map(|(_, value)| value.as_str()); + if matches.next().is_some() { + return Err(EmailCodeError::MessageUnavailable); + } + Ok(first) +} + +fn collect_text_parts( + part: &Value, + depth: usize, + texts: &mut MessageTexts, +) -> Result<(), EmailCodeError> { + if depth > MAX_MIME_DEPTH { + return Err(EmailCodeError::MessageUnavailable); + } + let mut part_count = 0; + collect_text_parts_inner(part, depth, texts, &mut part_count) +} + +fn collect_text_parts_inner( + part: &Value, + depth: usize, + texts: &mut MessageTexts, + part_count: &mut usize, +) -> Result<(), EmailCodeError> { + *part_count += 1; + if depth > MAX_MIME_DEPTH || *part_count > MAX_MIME_PARTS { + return Err(EmailCodeError::MessageUnavailable); + } + if let Some(parts) = part.get("parts") { + for child in parts.as_array().ok_or(EmailCodeError::MessageUnavailable)? { + collect_text_parts_inner(child, depth + 1, texts, part_count)?; + } + return Ok(()); + } + let mime_type = part + .get("mimeType") + .and_then(Value::as_str) + .unwrap_or_default() + .to_ascii_lowercase(); + let is_plain = mime_type == "text/plain"; + let is_html = mime_type == "text/html"; + if !is_plain && !is_html { + return Ok(()); + } + let headers = part_headers(part)?; + let is_attachment = part + .get("filename") + .and_then(Value::as_str) + .is_some_and(|filename| !filename.is_empty()) + || headers.iter().any(|(name, value)| { + name == "content-disposition" + && value + .trim_start() + .get(..10) + .is_some_and(|prefix| prefix.eq_ignore_ascii_case("attachment")) + }); + if is_attachment { + return Ok(()); + } + let body = part.get("body"); + let Some(data) = body + .and_then(|body| body.get("data")) + .and_then(Value::as_str) + else { + // Inline text that Gmail moved out of line cannot be inspected, and + // skipping it could hide a conflicting candidate: fail closed. + if body.and_then(|body| body.get("attachmentId")).is_some() { + return Err(EmailCodeError::MessageUnavailable); + } + return Ok(()); + }; + let bytes = GMAIL_BASE64 + .decode(data) + .map_err(|_| EmailCodeError::MessageUnavailable)?; + texts.decoded_bytes += bytes.len(); + if texts.decoded_bytes > MAX_DECODED_TEXT_BYTES { + return Err(EmailCodeError::MessageUnavailable); + } + let charset = headers + .iter() + .find(|(name, _)| name == "content-type") + .and_then(|(_, value)| content_type_charset(value)); + let text = + decode_charset(&bytes, charset.as_deref()).ok_or(EmailCodeError::MessageUnavailable)?; + if is_plain { + texts.plain.push(normalize_text(&text)); + } else { + texts.html.push(normalize_text(&html_to_text(&text))); + } + Ok(()) +} + +fn content_type_charset(value: &str) -> Option { + for parameter in split_top_level(value, ';').into_iter().skip(1) { + let Some((name, raw)) = parameter.split_once('=') else { + continue; + }; + if name.trim().eq_ignore_ascii_case("charset") { + return Some(raw.trim().trim_matches('"').to_ascii_lowercase()); + } + } + None +} + +/// Strict charset decoding. Unknown charsets fail closed rather than +/// exposing undecoded bytes to the code pattern. +fn decode_charset(bytes: &[u8], charset: Option<&str>) -> Option { + match charset.unwrap_or("utf-8") { + "utf-8" | "utf8" | "us-ascii" | "ascii" => String::from_utf8(bytes.to_vec()).ok(), + "iso-8859-1" | "iso8859-1" | "latin1" | "latin-1" | "l1" | "windows-1252" | "cp1252" => { + Some(bytes.iter().map(|byte| char::from(*byte)).collect()) + } + _ => None, + } +} + +/// Removes invisible characters that only split visible tokens and maps +/// non-breaking spaces to spaces. +fn normalize_text(text: &str) -> String { + text.chars() + .filter(|character| { + !matches!( + character, + '\u{00AD}' | '\u{200B}' | '\u{200C}' | '\u{200D}' | '\u{2060}' | '\u{FEFF}' + ) + }) + .map(|character| { + if character == '\u{00A0}' { + ' ' + } else { + character + } + }) + .collect() +} + +/// Decodes RFC 2047 encoded words in an unstructured header. Returns `None` +/// for an encoded word in an unsupported charset or with invalid encoding. +fn decode_encoded_words(value: &str) -> Option { + static ENCODED_WORD: LazyLock = LazyLock::new(|| { + Regex::new(r"=\?([^?\s]+)\?([BbQq])\?([^?\s]*)\?=").expect("static encoded-word pattern") + }); + let pattern = &*ENCODED_WORD; + let mut decoded = String::with_capacity(value.len()); + let mut cursor = 0; + let mut previous_was_word = false; + for captures in pattern.captures_iter(value) { + let whole = captures.get(0)?; + let gap = &value[cursor..whole.start()]; + if !(previous_was_word && gap.chars().all(char::is_whitespace)) { + decoded.push_str(gap); + } + let charset = captures[1].split('*').next()?.to_ascii_lowercase(); + let text = &captures[3]; + let bytes = if captures[2].eq_ignore_ascii_case("b") { + MIME_BASE64.decode(text).ok()? + } else { + decode_q_encoding(text)? + }; + decoded.push_str(&decode_charset(&bytes, Some(charset.as_str()))?); + cursor = whole.end(); + previous_was_word = true; + } + decoded.push_str(&value[cursor..]); + Some(decoded) +} + +fn decode_q_encoding(text: &str) -> Option> { + let bytes = text.as_bytes(); + let mut decoded = Vec::with_capacity(bytes.len()); + let mut index = 0; + while index < bytes.len() { + match bytes[index] { + b'_' => { + decoded.push(b' '); + index += 1; + } + b'=' => { + let high = hex_value(*bytes.get(index + 1)?)?; + let low = hex_value(*bytes.get(index + 2)?)?; + decoded.push((high << 4) | low); + index += 3; + } + byte => { + decoded.push(byte); + index += 1; + } + } + } + Some(decoded) +} + +/// Converts HTML to its visible text without executing or fetching anything: +/// comments, declarations, and the contents of non-rendered elements are +/// dropped, every tag (and so every attribute or inline style) is removed, +/// block boundaries become newlines, and character references are decoded. +fn html_to_text(html: &str) -> String { + const SKIPPED_ELEMENTS: &[&str] = &[ + "script", "style", "head", "title", "noscript", "template", "svg", "math", "object", + "iframe", "xml", + ]; + const BLOCK_ELEMENTS: &[&str] = &[ + "address", + "article", + "aside", + "blockquote", + "br", + "center", + "dd", + "div", + "dl", + "dt", + "footer", + "form", + "h1", + "h2", + "h3", + "h4", + "h5", + "h6", + "header", + "hr", + "li", + "main", + "nav", + "ol", + "p", + "pre", + "section", + "table", + "tbody", + "td", + "tfoot", + "th", + "thead", + "tr", + "ul", + "caption", + "img", + ]; + let lower = html.to_ascii_lowercase(); + let bytes = html.as_bytes(); + let mut text = String::with_capacity(html.len().min(MAX_DECODED_TEXT_BYTES)); + let mut index = 0; + let mut text_start = 0; + while index < bytes.len() { + if bytes[index] != b'<' { + index += 1; + continue; + } + let rest = &lower[index..]; + let name_offset = usize::from(rest.as_bytes().get(1) == Some(&b'/')) + 1; + let name_bytes = &rest.as_bytes()[name_offset..]; + let name_length = if name_bytes.first().is_some_and(u8::is_ascii_alphabetic) { + name_bytes + .iter() + .take_while(|byte| byte.is_ascii_alphanumeric()) + .count() + } else { + 0 + }; + let is_markup = rest.starts_with(" 0; + if !is_markup { + index += 1; + continue; + } + text.push_str(&decode_html_entities(&html[text_start..index])); + if rest.starts_with("") + .map_or(bytes.len(), |end| index + 4 + end + 3); + } else if name_length == 0 { + index = rest.find('>').map_or(bytes.len(), |end| index + end + 1); + } else { + let closing = name_offset == 2; + let name = &rest[name_offset..name_offset + name_length]; + let tag_end = find_tag_end(&bytes[index..]).map_or(bytes.len(), |end| index + end + 1); + let self_closing = lower[index..tag_end].trim_end_matches('>').ends_with('/'); + index = tag_end; + if !closing && !self_closing && SKIPPED_ELEMENTS.contains(&name) { + let closing_tag = format!("') + .map_or(bytes.len(), |end| after + end + 1) + }); + } else if BLOCK_ELEMENTS.contains(&name) { + text.push('\n'); + } + } + text_start = index; + } + if text_start < bytes.len() { + text.push_str(&decode_html_entities(&html[text_start..])); + } + text +} + +/// Finds the closing `>` of a tag, ignoring `>` inside quoted attributes. +fn find_tag_end(bytes: &[u8]) -> Option { + let mut quote = None; + for (offset, byte) in bytes.iter().enumerate().skip(1) { + match (quote, byte) { + (Some(open), _) if *byte == open => quote = None, + (Some(_), _) => {} + (None, b'"' | b'\'') => quote = Some(*byte), + (None, b'>') => return Some(offset), + _ => {} + } + } + None +} + +fn decode_html_entities(text: &str) -> String { + let mut decoded = String::with_capacity(text.len()); + let mut rest = text; + while let Some(start) = rest.find('&') { + decoded.push_str(&rest[..start]); + let candidate = &rest[start + 1..]; + let end = candidate + .char_indices() + .take(32) + .find(|(_, character)| *character == ';') + .map(|(offset, _)| offset); + let replacement = end.and_then(|end| { + let entity = &candidate[..end]; + let character = if let Some(number) = entity.strip_prefix('#') { + let value = match number.strip_prefix(['x', 'X']) { + Some(hex) => u32::from_str_radix(hex, 16).ok(), + None => number.parse::().ok(), + }; + value.and_then(char::from_u32) + } else { + match entity { + "amp" => Some('&'), + "lt" => Some('<'), + "gt" => Some('>'), + "quot" => Some('"'), + "apos" => Some('\''), + "nbsp" => Some('\u{00A0}'), + "zwnj" => Some('\u{200C}'), + "zwj" => Some('\u{200D}'), + "shy" => Some('\u{00AD}'), + _ => None, + } + }; + character.map(|character| (character, end)) + }); + match replacement { + Some((character, end)) => { + decoded.push(character); + rest = &candidate[end + 1..]; + } + None => { + decoded.push('&'); + rest = candidate; + } + } + } + decoded.push_str(rest); + decoded +} + +/// Requires Gmail's own, topmost `Authentication-Results` (authserv-id +/// `mx.google.com`) to report a DKIM pass aligned with the From domain and a +/// DMARC pass for exactly that From domain. Lower `Authentication-Results` +/// headers were supplied by upstream hops or the sender and are ignored. +fn gmail_authenticates_sender(headers: &[(String, String)], from_domain: &str) -> bool { + let Some(value) = headers + .iter() + .find(|(name, _)| name == "authentication-results") + .map(|(_, value)| value) + else { + return false; + }; + let Some(cleaned) = strip_comments(value) else { + return false; + }; + let mut segments = split_top_level(&cleaned, ';').into_iter(); + let authserv_id = segments.next().and_then(|segment| { + segment + .split_whitespace() + .next() + .map(str::to_ascii_lowercase) + }); + if authserv_id.as_deref() != Some(GMAIL_AUTHSERV_ID) { + return false; + } + let mut dkim_aligned = false; + let mut dmarc_seen = false; + let mut dmarc_pass = true; + for segment in segments { + let mut tokens = segment.split_whitespace(); + let Some((method, result)) = tokens.next().and_then(|token| token.split_once('=')) else { + continue; + }; + let method = method + .split('/') + .next() + .unwrap_or_default() + .to_ascii_lowercase(); + let result = result.to_ascii_lowercase(); + let property = |key: &str| { + segment + .split_whitespace() + .skip(1) + .filter_map(|token| token.split_once('=')) + .find(|(name, _)| name.eq_ignore_ascii_case(key)) + .map(|(_, value)| value.trim_matches('"').to_ascii_lowercase()) + }; + match method.as_str() { + "dkim" if result == "pass" => { + let signing_domain = property("header.d").or_else(|| { + property("header.i").and_then(|identity| { + identity + .rsplit_once('@') + .map(|(_, domain)| domain.to_string()) + }) + }); + if signing_domain.is_some_and(|domain| dkim_domain_aligned(&domain, from_domain)) { + dkim_aligned = true; + } + } + "dmarc" => { + dmarc_seen = true; + if result != "pass" || property("header.from").as_deref() != Some(from_domain) { + dmarc_pass = false; + } + } + _ => {} + } + } + dkim_aligned && dmarc_seen && dmarc_pass +} + +fn dkim_domain_aligned(signing_domain: &str, from_domain: &str) -> bool { + signing_domain == from_domain + || (signing_domain.contains('.') + && from_domain + .strip_suffix(signing_domain) + .is_some_and(|prefix| prefix.ends_with('.'))) +} + +/// Removes RFC 5322 comments outside quoted strings. Returns `None` for +/// unbalanced comments or quotes. +fn strip_comments(value: &str) -> Option { + let mut cleaned = String::with_capacity(value.len()); + let mut depth = 0_usize; + let mut in_quote = false; + let mut escaped = false; + for character in value.chars() { + if escaped { + if depth == 0 { + cleaned.push(character); + } + escaped = false; + continue; + } + match character { + '\\' if in_quote || depth > 0 => { + escaped = true; + if depth == 0 { + cleaned.push(character); + } + } + '"' if depth == 0 => { + in_quote = !in_quote; + cleaned.push(character); + } + '(' if !in_quote => depth += 1, + ')' if !in_quote => { + depth = depth.checked_sub(1)?; + if depth == 0 { + cleaned.push(' '); + } + } + _ if depth == 0 => cleaned.push(character), + _ => {} + } + } + (depth == 0 && !in_quote).then_some(cleaned) +} + +/// Splits on `separator` outside quoted strings and angle brackets. +fn split_top_level(value: &str, separator: char) -> Vec<&str> { + let mut parts = Vec::new(); + let mut start = 0; + let mut in_quote = false; + let mut escaped = false; + let mut angle = 0_usize; + for (offset, character) in value.char_indices() { + if escaped { + escaped = false; + continue; + } + match character { + '\\' if in_quote => escaped = true, + '"' => in_quote = !in_quote, + '<' if !in_quote => angle += 1, + '>' if !in_quote => angle = angle.saturating_sub(1), + _ if character == separator && !in_quote && angle == 0 => { + parts.push(&value[start..offset]); + start = offset + character.len_utf8(); + } + _ => {} + } + } + parts.push(&value[start..]); + parts +} + +/// Parses an RFC 5322 address list into normalized (lowercase) addresses. +/// Returns `None` when any member is malformed. +fn parse_address_list(value: &str) -> Option> { + let cleaned = strip_comments(value)?; + let mut addresses = Vec::new(); + for member in split_top_level(&cleaned, ',') { + let member = match split_top_level(member, ':').as_slice() { + [_, after] => *after, + [only] => *only, + _ => return None, + }; + let member = member.trim().trim_end_matches(';').trim(); + if member.is_empty() { + continue; + } + let address = match outside_quotes_find(member, '<') { + Some(open) => { + let inner = &member[open + 1..]; + let close = inner.find('>')?; + if !inner[close + 1..].trim().is_empty() { + return None; + } + &inner[..close] + } + None => member, + }; + addresses.push(normalize_email(address.trim())?); + } + Some(addresses) +} + +fn outside_quotes_find(value: &str, needle: char) -> Option { + let mut in_quote = false; + let mut escaped = false; + for (offset, character) in value.char_indices() { + if escaped { + escaped = false; + continue; + } + match character { + '\\' if in_quote => escaped = true, + '"' => in_quote = !in_quote, + _ if character == needle && !in_quote => return Some(offset), + _ => {} + } + } + None +} + +fn parse_single_address(value: &str) -> Option { + match parse_address_list(value)?.as_slice() { + [address] => Some(address.clone()), + _ => None, + } +} + +fn address_domain(address: &str) -> &str { + address.rsplit_once('@').map_or("", |(_, domain)| domain) +} + +/// Validates one bounded dot-atom email address and returns it lowercased. +fn normalize_email(value: &str) -> Option { + if !(MIN_EMAIL_CHARS..=MAX_EMAIL_CHARS).contains(&value.len()) { + return None; + } + let (local, domain) = value.split_once('@')?; + if local.is_empty() + || local.len() > MAX_LOCAL_PART_CHARS + || local.starts_with('.') + || local.ends_with('.') + || local.contains("..") + || !local + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || b".!#$%&'*+/=?^_`{|}~-".contains(&byte)) + || !is_valid_domain(&domain.to_ascii_lowercase()) + { + return None; + } + Some(value.to_ascii_lowercase()) +} + +fn is_valid_domain(domain: &str) -> bool { + let labels = domain.split('.').collect::>(); + !domain.is_empty() + && domain.len() <= MAX_DOMAIN_CHARS + && labels.len() >= 2 + && labels.iter().all(|label| { + !label.is_empty() + && label.len() <= 63 + && !label.starts_with('-') + && !label.ends_with('-') + && label + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || byte == b'-') + }) +} + +/// Mailbox identity key: lowercase, with Gmail's dot-insensitive local part +/// collapsed only for gmail.com and googlemail.com. +fn mailbox_key(value: &str) -> Option { + let address = normalize_email(value)?; + let (local, domain) = address.split_once('@')?; + if matches!(domain, "gmail.com" | "googlemail.com") { + Some(format!("{}@{domain}", local.replace('.', ""))) + } else { + Some(address) + } +} + +/// True when `address` (lowercase) appears in `text` as a whole address, not +/// as part of a longer local part or domain. +fn contains_address_token(text: &str, address: &str) -> bool { + let haystack = text.to_ascii_lowercase(); + haystack.match_indices(address).any(|(start, _)| { + let before_ok = haystack[..start] + .chars() + .next_back() + .is_none_or(|character| { + !(character.is_ascii_alphanumeric() || ".!#$%&'*+/=?^_`{|}~-".contains(character)) + }); + let mut after = haystack[start + address.len()..].chars(); + let after_ok = match after.next() { + None => true, + Some('.') => after + .next() + .is_none_or(|character| !(character.is_ascii_alphanumeric() || character == '-')), + Some(character) => { + !(character.is_ascii_alphanumeric() || matches!(character, '-' | '_' | '@')) + } + }; + before_ok && after_ok + }) +} + +#[cfg(test)] +pub(crate) mod tests { + use super::*; + use base64::engine::general_purpose::URL_SAFE_NO_PAD; + use parking_lot::Mutex; + + const NOW_MS: u64 = 1_800_000_000_000; + pub(crate) const MESSAGE_ID: &str = "18c0ffee0000abcd"; + pub(crate) const ORIGIN: &str = "https://id.service.example"; + pub(crate) const PATH: &str = "/signup/verify-email/otp"; + pub(crate) const MAILBOX: &str = "synthetic.owner@gmail.com"; + pub(crate) const DIRECT_RECIPIENT: &str = "synthetic.owner+svc@gmail.com"; + const RELAY_RECIPIENT: &str = "member-alias@relay.example"; + const RELAY_SENDER: &str = "forwarder@relay.example"; + const CODE_PATTERN: &str = r"(?i:code)(?: is)?:?\s+([A-Z0-9]{6})\b"; + const AUTH_PASS_SERVICE: &str = "mx.google.com; dkim=pass header.i=@service.example header.s=s1 header.b=AbCdEf; spf=pass (google.com: domain of bounce@service.example designates 192.0.2.1 as permitted sender; extra) smtp.mailfrom=bounce@service.example; dmarc=pass (p=REJECT sp=REJECT dis=NONE) header.from=service.example"; + const AUTH_PASS_RELAY: &str = "mx.google.com; dkim=pass header.i=@relay.example header.s=s1 header.b=XyZ; spf=pass smtp.mailfrom=bounce@relay.example; dmarc=pass (p=QUARANTINE) header.from=relay.example"; + + fn fixed_now() -> u64 { + NOW_MS + } + + #[derive(Default)] + pub(crate) struct FakeProvider { + profile: Option, + message: Option, + pub(crate) calls: Mutex>, + } + + impl MailProvider for FakeProvider { + fn profile(&self, _deadline: Instant) -> Result { + self.calls.lock().push("profile".into()); + self.profile.clone().ok_or(ProviderError::Unavailable) + } + + fn message(&self, message_id: &str, _deadline: Instant) -> Result { + self.calls.lock().push(format!("message:{message_id}")); + self.message.clone().ok_or(ProviderError::Unavailable) + } + } + + pub(crate) fn grant() -> EmailCodeGrant { + EmailCodeGrant { + origin: ORIGIN.into(), + paths: vec![PATH.into()], + recipient: DIRECT_RECIPIENT.into(), + purposes: vec!["signup".into()], + sender_domains: vec!["service.example".into()], + relay_senders: Vec::new(), + code_pattern: CODE_PATTERN.into(), + } + } + + fn relay_grant() -> EmailCodeGrant { + EmailCodeGrant { + recipient: RELAY_RECIPIENT.into(), + relay_senders: vec![RELAY_SENDER.into()], + ..grant() + } + } + + pub(crate) fn policy(grants: Vec) -> EmailCodePolicy { + EmailCodePolicy { + enabled: true, + executable: Some(PathBuf::from("/opt/fixture/bin/gws")), + mailbox: Some(MAILBOX.into()), + grants, + ..EmailCodePolicy::default() + } + } + + fn request(recipient: &str) -> EmailCodeRequest<'_> { + EmailCodeRequest { + origin: ORIGIN, + path: PATH, + recipient, + purpose: "signup", + message_id: MESSAGE_ID, + } + } + + fn encode(text: &str) -> String { + URL_SAFE_NO_PAD.encode(text) + } + + fn text_part(mime_type: &str, body: &str) -> Value { + json!({ + "mimeType": mime_type, + "filename": "", + "headers": [{ "name": "Content-Type", "value": format!("{mime_type}; charset=\"UTF-8\"") }], + "body": { "size": body.len(), "data": encode(body) } + }) + } + + fn message_with(headers: &[(&str, &str)], parts: Vec, internal_date: u64) -> Value { + let headers = headers + .iter() + .map(|(name, value)| json!({ "name": name, "value": value })) + .collect::>(); + json!({ + "id": MESSAGE_ID, + "threadId": MESSAGE_ID, + "labelIds": ["INBOX", "UNREAD"], + "internalDate": internal_date.to_string(), + "payload": { + "mimeType": "multipart/alternative", + "filename": "", + "headers": headers, + "body": { "size": 0 }, + "parts": parts + } + }) + } + + fn direct_headers<'a>(subject: &'a str, to: &'a str) -> Vec<(&'a str, &'a str)> { + vec![ + ("Delivered-To", MAILBOX), + ("Authentication-Results", AUTH_PASS_SERVICE), + ("From", "\"Service Accounts\" "), + ("To", to), + ("Subject", subject), + ] + } + + pub(crate) fn direct_message(subject: &str, plain: &str) -> Value { + message_with( + &direct_headers(subject, DIRECT_RECIPIENT), + vec![ + text_part("text/plain", plain), + text_part( + "text/html", + "

Your code is QX7K2M

", + ), + ], + NOW_MS - 60_000, + ) + } + + fn relay_message(subject: &str, reply_to: &str, authentication: &str) -> Value { + message_with( + &[ + ("Delivered-To", MAILBOX), + ("Authentication-Results", authentication), + ("From", "\"Service via Relay\" "), + ("Reply-To", reply_to), + ("To", MAILBOX), + ("Subject", subject), + ], + vec![text_part("text/plain", "Your verification code is QX7K2M")], + NOW_MS - 60_000, + ) + } + + pub(crate) fn broker_with( + policy: EmailCodePolicy, + provider: Arc, + ) -> EmailCodeBroker { + EmailCodeBroker::with_parts(policy, provider, fixed_now) + } + + pub(crate) fn provider_for(message: Value) -> Arc { + Arc::new(FakeProvider { + profile: Some(json!({ "emailAddress": MAILBOX, "messagesTotal": 3 })), + message: Some(message), + calls: Mutex::new(Vec::new()), + }) + } + + fn resolve_direct(message: Value) -> Result { + broker_with(policy(vec![grant()]), provider_for(message)).resolve(request(DIRECT_RECIPIENT)) + } + + fn resolve_relay(message: Value) -> Result { + broker_with(policy(vec![relay_grant()]), provider_for(message)) + .resolve(request(RELAY_RECIPIENT)) + } + + #[test] + fn policy_is_disabled_by_default_and_enabling_requires_complete_configuration() { + let defaults = EmailCodePolicy::default(); + assert!(!defaults.enabled); + assert!(defaults.validate().is_ok()); + + assert!(policy(vec![grant()]).validate().is_ok()); + for broken in [ + EmailCodePolicy { + executable: None, + ..policy(vec![grant()]) + }, + EmailCodePolicy { + executable: Some(PathBuf::from("gws")), + ..policy(vec![grant()]) + }, + EmailCodePolicy { + mailbox: None, + ..policy(vec![grant()]) + }, + EmailCodePolicy { + mailbox: Some("not-an-address".into()), + ..policy(vec![grant()]) + }, + EmailCodePolicy { + max_age_ms: 29_999, + ..policy(vec![grant()]) + }, + EmailCodePolicy { + max_age_ms: 900_001, + ..policy(vec![grant()]) + }, + EmailCodePolicy { + timeout_ms: 4_999, + ..policy(vec![grant()]) + }, + EmailCodePolicy { + timeout_ms: 120_001, + ..policy(vec![grant()]) + }, + policy(Vec::new()), + ] { + assert!(broken.validate().is_err(), "{broken:?}"); + } + + let unknown = serde_json::from_value::( + json!({ "enabled": false, "query": "in:inbox" }), + ); + assert!(unknown.is_err()); + let unknown_grant = serde_json::from_value::(json!({ + "grants": [{ + "origin": ORIGIN, "paths": [PATH], "recipient": DIRECT_RECIPIENT, + "purposes": ["signup"], "sender_domains": ["service.example"], + "code_pattern": CODE_PATTERN, "path_prefix": "/" + }] + })); + assert!(unknown_grant.is_err()); + } + + #[test] + fn grant_validation_requires_exact_secure_origins_and_paths() { + let invalid_origins = [ + "https://*.service.example", + "https://id.service.example/", + "https://id.service.example/signup", + "https://user@id.service.example", + "https://ID.service.example", + "https://id.service.example:443", + "http://id.service.example", + "ftp://id.service.example", + "id.service.example", + ]; + for origin in invalid_origins { + let candidate = EmailCodeGrant { + origin: origin.into(), + ..grant() + }; + assert!(policy(vec![candidate]).validate().is_err(), "{origin}"); + } + for origin in [ + "http://127.0.0.1:8080", + "http://localhost:3000", + "http://[::1]:9000", + ] { + let candidate = EmailCodeGrant { + origin: origin.into(), + ..grant() + }; + assert!(policy(vec![candidate]).validate().is_ok(), "{origin}"); + } + + let invalid_paths = [ + "", + "signup/verify-email/otp", + "/signup/*", + "/signup/verify-email/otp?step=1", + "/signup/verify-email/otp#code", + "/signup/../verify-email/otp", + "//evil.example/otp", + "/signup/verify email", + ]; + for path in invalid_paths { + let candidate = EmailCodeGrant { + paths: vec![path.into()], + ..grant() + }; + assert!(policy(vec![candidate]).validate().is_err(), "{path:?}"); + } + let duplicate = EmailCodeGrant { + paths: vec![PATH.into(), PATH.into()], + ..grant() + }; + assert!(policy(vec![duplicate]).validate().is_err()); + } + + #[test] + fn grant_validation_refuses_second_factor_paths_but_allows_email_otp_pages() { + for path in [ + "/login/mfa/verify", + "/account/2fa", + "/two-factor/challenge", + "/security/two_factor", + "/auth/authenticator", + "/verify/totp", + "/login/2-step", + "/webauthn/assert", + "/security-key/verify", + "/login/%6Dfa", + ] { + let candidate = EmailCodeGrant { + paths: vec![path.into()], + ..grant() + }; + let error = policy(vec![candidate]).validate().unwrap_err(); + assert!(error.contains("MFA"), "{path}: {error}"); + } + for path in [PATH, "/login/email-code", "/verify/email"] { + let candidate = EmailCodeGrant { + paths: vec![path.into()], + ..grant() + }; + assert!(policy(vec![candidate]).validate().is_ok(), "{path}"); + } + } + + #[test] + fn grant_validation_bounds_purposes_senders_patterns_and_overlap() { + let cases = [ + EmailCodeGrant { + purposes: Vec::new(), + ..grant() + }, + EmailCodeGrant { + purposes: vec!["password_reset".into()], + ..grant() + }, + EmailCodeGrant { + purposes: vec!["signup".into(), "signup".into()], + ..grant() + }, + EmailCodeGrant { + sender_domains: Vec::new(), + ..grant() + }, + EmailCodeGrant { + sender_domains: vec!["Service.example".into()], + ..grant() + }, + EmailCodeGrant { + sender_domains: vec!["*.service.example".into()], + ..grant() + }, + EmailCodeGrant { + sender_domains: vec!["example".into()], + ..grant() + }, + EmailCodeGrant { + relay_senders: vec!["Forwarder@relay.example".into()], + ..grant() + }, + EmailCodeGrant { + relay_senders: vec!["relay.example".into()], + ..grant() + }, + EmailCodeGrant { + recipient: "no-at-sign".into(), + ..grant() + }, + EmailCodeGrant { + recipient: "a\r\nb@service.example".into(), + ..grant() + }, + EmailCodeGrant { + code_pattern: r"[A-Z0-9]{6}".into(), + ..grant() + }, + EmailCodeGrant { + code_pattern: r"([A-Z]{3})([0-9]{3})".into(), + ..grant() + }, + EmailCodeGrant { + code_pattern: r"([A-Z".into(), + ..grant() + }, + EmailCodeGrant { + code_pattern: "x".repeat(MAX_PATTERN_CHARS + 1), + ..grant() + }, + ]; + for candidate in cases { + assert!( + policy(vec![candidate.clone()]).validate().is_err(), + "{candidate:?}" + ); + } + let overlapping = EmailCodeGrant { + recipient: DIRECT_RECIPIENT.to_ascii_uppercase(), + purposes: vec!["email_sign_in".into(), "signup".into()], + ..grant() + }; + assert!(policy(vec![grant(), overlapping]).validate().is_err()); + let distinct_purpose = EmailCodeGrant { + purposes: vec!["email_sign_in".into()], + ..grant() + }; + assert!(policy(vec![grant(), distinct_purpose]).validate().is_ok()); + } + + #[test] + fn invalid_or_disabled_policy_never_invokes_the_provider() { + let provider = provider_for(direct_message("Your code is QX7K2M", "Your code is QX7K2M")); + let disabled = EmailCodePolicy { + enabled: false, + ..policy(vec![grant()]) + }; + assert_eq!( + broker_with(disabled, provider.clone()).resolve(request(DIRECT_RECIPIENT)), + Err(EmailCodeError::Disabled) + ); + let invalid = EmailCodePolicy { + timeout_ms: 1, + ..policy(vec![grant()]) + }; + assert_eq!( + broker_with(invalid, provider.clone()).resolve(request(DIRECT_RECIPIENT)), + Err(EmailCodeError::PolicyInvalid) + ); + assert!(provider.calls.lock().is_empty()); + } + + #[test] + fn only_exact_grants_resolve_before_any_mailbox_access() { + let provider = provider_for(direct_message("Your code is QX7K2M", "Your code is QX7K2M")); + let broker = broker_with(policy(vec![grant()]), provider.clone()); + let denied = [ + EmailCodeRequest { + origin: "https://service.example", + ..request(DIRECT_RECIPIENT) + }, + EmailCodeRequest { + origin: "https://id.service.example.evil", + ..request(DIRECT_RECIPIENT) + }, + EmailCodeRequest { + origin: "http://id.service.example", + ..request(DIRECT_RECIPIENT) + }, + EmailCodeRequest { + path: "/signup/verify-email", + ..request(DIRECT_RECIPIENT) + }, + EmailCodeRequest { + path: "/signup/verify-email/otp/", + ..request(DIRECT_RECIPIENT) + }, + EmailCodeRequest { + path: "/signup/verify-email/otp/extra", + ..request(DIRECT_RECIPIENT) + }, + EmailCodeRequest { + path: "/SIGNUP/verify-email/otp", + ..request(DIRECT_RECIPIENT) + }, + EmailCodeRequest { + purpose: "email_sign_in", + ..request(DIRECT_RECIPIENT) + }, + request("synthetic.owner+other@gmail.com"), + request("syntheticowner+svc@gmail.com"), + ]; + for candidate in denied { + assert_eq!( + broker.resolve(candidate), + Err(EmailCodeError::GrantNotFound) + ); + } + for invalid in [ + EmailCodeRequest { + message_id: "", + ..request(DIRECT_RECIPIENT) + }, + EmailCodeRequest { + message_id: "not-hex!", + ..request(DIRECT_RECIPIENT) + }, + EmailCodeRequest { + message_id: "in:inbox", + ..request(DIRECT_RECIPIENT) + }, + EmailCodeRequest { + purpose: "password_reset", + ..request(DIRECT_RECIPIENT) + }, + request("no-address"), + ] { + assert_eq!(broker.resolve(invalid), Err(EmailCodeError::InvalidRequest)); + } + let long_id = "a".repeat(MAX_MESSAGE_ID_CHARS + 1); + assert_eq!( + broker.resolve(EmailCodeRequest { + message_id: &long_id, + ..request(DIRECT_RECIPIENT) + }), + Err(EmailCodeError::InvalidRequest) + ); + assert!(provider.calls.lock().is_empty()); + + assert_eq!( + broker.resolve(request(&DIRECT_RECIPIENT.to_ascii_uppercase())), + Ok("QX7K2M".into()) + ); + assert_eq!( + *provider.calls.lock(), + vec!["profile".to_string(), format!("message:{MESSAGE_ID}")] + ); + } + + #[test] + fn authenticated_mailbox_must_match_before_the_message_is_read() { + let message = direct_message("Your code is QX7K2M", "Your code is QX7K2M"); + for (address, accepted) in [ + ("synthetic.owner@gmail.com", true), + ("syntheticowner@gmail.com", true), + ("Synthetic.Owner@Gmail.com", true), + ("synthetic.owner+x@gmail.com", false), + ("synthetic.owner@googlemail.com", false), + ("other.owner@gmail.com", false), + ] { + let provider = Arc::new(FakeProvider { + profile: Some(json!({ "emailAddress": address })), + message: Some(message.clone()), + calls: Mutex::new(Vec::new()), + }); + let result = broker_with(policy(vec![grant()]), provider.clone()) + .resolve(request(DIRECT_RECIPIENT)); + if accepted { + assert_eq!(result, Ok("QX7K2M".into()), "{address}"); + } else { + assert_eq!(result, Err(EmailCodeError::MailboxMismatch), "{address}"); + assert_eq!(*provider.calls.lock(), vec!["profile".to_string()]); + } + } + + let dotted_workspace = EmailCodePolicy { + mailbox: Some("first.last@workspace.example".into()), + ..policy(vec![grant()]) + }; + let provider = Arc::new(FakeProvider { + profile: Some(json!({ "emailAddress": "firstlast@workspace.example" })), + message: Some(message.clone()), + calls: Mutex::new(Vec::new()), + }); + assert_eq!( + broker_with(dotted_workspace, provider).resolve(request(DIRECT_RECIPIENT)), + Err(EmailCodeError::MailboxMismatch) + ); + + let missing_profile = Arc::new(FakeProvider { + profile: Some(json!({ "historyId": "1" })), + message: Some(message), + calls: Mutex::new(Vec::new()), + }); + assert_eq!( + broker_with(policy(vec![grant()]), missing_profile).resolve(request(DIRECT_RECIPIENT)), + Err(EmailCodeError::MailboxMismatch) + ); + let unavailable = Arc::new(FakeProvider::default()); + assert_eq!( + broker_with(policy(vec![grant()]), unavailable).resolve(request(DIRECT_RECIPIENT)), + Err(EmailCodeError::ProviderUnavailable) + ); + } + + #[test] + fn message_identity_labels_and_freshness_fail_closed() { + let mut other_id = direct_message("Your code is QX7K2M", "Your code is QX7K2M"); + other_id["id"] = json!("18c0ffee0000abce"); + assert_eq!( + resolve_direct(other_id), + Err(EmailCodeError::MessageUnavailable) + ); + + for (label, eligible) in [ + ("INBOX", true), + ("SPAM", true), + ("TRASH", false), + ("DRAFT", false), + ("SENT", false), + ] { + let mut message = direct_message("Your code is QX7K2M", "Your code is QX7K2M"); + message["labelIds"] = json!([label]); + let expected = if eligible { + Ok("QX7K2M".into()) + } else { + Err(EmailCodeError::MessageIneligible) + }; + assert_eq!(resolve_direct(message), expected, "{label}"); + } + + let max_age = DEFAULT_MAX_AGE_MS; + for (internal_date, fresh) in [ + (NOW_MS - max_age, true), + (NOW_MS - max_age - 1, false), + (NOW_MS + MAX_FUTURE_SKEW_MS, true), + (NOW_MS + MAX_FUTURE_SKEW_MS + 1, false), + ] { + let mut message = direct_message("Your code is QX7K2M", "Your code is QX7K2M"); + message["internalDate"] = json!(internal_date.to_string()); + let result = resolve_direct(message); + if fresh { + assert_eq!(result, Ok("QX7K2M".into())); + } else { + assert_eq!(result, Err(EmailCodeError::MessageNotFresh)); + } + } + let mut undated = direct_message("Your code is QX7K2M", "Your code is QX7K2M"); + undated["internalDate"] = json!("yesterday"); + assert_eq!( + resolve_direct(undated), + Err(EmailCodeError::MessageUnavailable) + ); + } + + #[test] + fn direct_sender_domain_must_match_exactly() { + for from in [ + "noreply@evil-service.example", + "noreply@service.example.evil", + "noreply@mail.service.example", + "\"noreply@service.example\" ", + ] { + let mut headers = direct_headers("Your code is QX7K2M", DIRECT_RECIPIENT); + headers[2] = ("From", from); + let message = message_with( + &headers, + vec![text_part("text/plain", "Your code is QX7K2M")], + NOW_MS - 1_000, + ); + assert_eq!( + resolve_direct(message), + Err(EmailCodeError::SenderUntrusted), + "{from}" + ); + } + let mut headers = direct_headers("Your code is QX7K2M", DIRECT_RECIPIENT); + headers.push(("From", "noreply@service.example")); + let duplicate_from = message_with( + &headers, + vec![text_part("text/plain", "Your code is QX7K2M")], + NOW_MS - 1_000, + ); + assert_eq!( + resolve_direct(duplicate_from), + Err(EmailCodeError::MessageUnavailable) + ); + let mut headers = direct_headers("Your code is QX7K2M", DIRECT_RECIPIENT); + headers[2] = ("From", "noreply@service.example, second@service.example"); + let two_authors = message_with(&headers, Vec::new(), NOW_MS - 1_000); + assert_eq!( + resolve_direct(two_authors), + Err(EmailCodeError::MessageUnavailable) + ); + } + + #[test] + fn only_gmails_topmost_authentication_results_are_trusted() { + let cases = [ + // Gmail's own result fails; a forged passing header lower down must not rescue it. + vec![ + ("Authentication-Results", "mx.google.com; dkim=fail header.i=@service.example; dmarc=fail header.from=service.example"), + ("Authentication-Results", AUTH_PASS_SERVICE), + ], + // A forged mx.google.com result is ignored when it is not topmost. + vec![ + ("Authentication-Results", "relay.other.example; dkim=pass header.d=service.example; dmarc=pass header.from=service.example"), + ("Authentication-Results", AUTH_PASS_SERVICE), + ], + vec![("Authentication-Results", "mx.google.com.evil.example; dkim=pass header.d=service.example; dmarc=pass header.from=service.example")], + vec![("Authentication-Results", "mx.google.com; dkim=pass header.i=@service.example; dmarc=fail header.from=service.example")], + vec![("Authentication-Results", "mx.google.com; dkim=pass header.i=@service.example; spf=pass smtp.mailfrom=service.example")], + vec![("Authentication-Results", "mx.google.com; dkim=pass header.i=@unrelated.example; dmarc=pass header.from=service.example")], + vec![("Authentication-Results", "mx.google.com; dkim=pass header.i=@service.example; dmarc=pass header.from=other.example")], + vec![("Authentication-Results", "mx.google.com; dkim=pass header.d=example; dmarc=pass header.from=service.example")], + vec![("Authentication-Results", "mx.google.com; dkim=neutral header.i=@service.example; dmarc=pass header.from=service.example")], + vec![("Authentication-Results", "mx.google.com (dkim=pass; dmarc=pass header.from=service.example); spf=pass smtp.mailfrom=service.example")], + vec![("ARC-Authentication-Results", AUTH_PASS_SERVICE)], + Vec::new(), + ]; + for authentication in cases { + let mut headers = authentication.clone(); + headers.extend([ + ("From", "noreply@service.example"), + ("To", DIRECT_RECIPIENT), + ("Subject", "Your code is QX7K2M"), + ]); + let message = message_with(&headers, Vec::new(), NOW_MS - 1_000); + assert_eq!( + resolve_direct(message), + Err(EmailCodeError::SenderUnauthenticated), + "{authentication:?}" + ); + } + let subdomain_signature = message_with( + &[ + ("Authentication-Results", "mx.google.com; dkim=pass header.i=@service.example; dmarc=pass header.from=service.example"), + ("From", "noreply@service.example"), + ("To", DIRECT_RECIPIENT), + ("Subject", "Your code is QX7K2M"), + ], + Vec::new(), + NOW_MS - 1_000, + ); + assert_eq!(resolve_direct(subdomain_signature), Ok("QX7K2M".into())); + } + + #[test] + fn direct_mail_must_be_addressed_to_the_exact_recipient_in_headers() { + for to in [ + "synthetic.owner+svc2@gmail.com", + "xsynthetic.owner+svc@gmail.com", + "synthetic.owner@gmail.com", + "undisclosed-recipients:;", + ] { + let message = message_with( + &direct_headers("Your code is QX7K2M", to), + vec![text_part( + "text/plain", + &format!("Sent to {DIRECT_RECIPIENT}. Your code is QX7K2M"), + )], + NOW_MS - 1_000, + ); + assert_eq!( + resolve_direct(message), + Err(EmailCodeError::RecipientMismatch), + "{to}" + ); + } + for to in [ + "Owner ", + "Team: other@service.example, synthetic.owner+svc@gmail.com;", + ] { + let message = message_with( + &direct_headers("Your code is QX7K2M", to), + Vec::new(), + NOW_MS - 1_000, + ); + assert_eq!(resolve_direct(message), Ok("QX7K2M".into()), "{to}"); + } + } + + #[test] + fn relay_requires_exact_relay_sender_original_domain_and_recipient() { + let subject = format!("[{RELAY_RECIPIENT}] Verify your email"); + assert_eq!( + resolve_relay(relay_message( + &subject, + "Service ", + AUTH_PASS_RELAY + )), + Ok("QX7K2M".into()) + ); + + for reply_to in [ + "noreply@service.example.evil", + "noreply@sub.service.example", + "noreply@service.example, other@service.example", + ] { + assert_eq!( + resolve_relay(relay_message(&subject, reply_to, AUTH_PASS_RELAY)), + Err(EmailCodeError::SenderUntrusted), + "{reply_to}" + ); + } + let mut missing_reply_to = + relay_message(&subject, "noreply@service.example", AUTH_PASS_RELAY); + missing_reply_to["payload"]["headers"] + .as_array_mut() + .unwrap() + .retain(|header| header["name"] != "Reply-To"); + assert_eq!( + resolve_relay(missing_reply_to), + Err(EmailCodeError::SenderUntrusted) + ); + + // Gmail authenticated the original domain, not the relay that is the actual From. + assert_eq!( + resolve_relay(relay_message( + &subject, + "noreply@service.example", + AUTH_PASS_SERVICE + )), + Err(EmailCodeError::SenderUnauthenticated) + ); + + for subject in [ + "[x-member-alias@relay.example] Verify your email".to_string(), + "[member-alias@relay.example.evil] Verify your email".to_string(), + "[member-alias@relay.examples] Verify your email".to_string(), + "Verify your email".to_string(), + ] { + assert_eq!( + resolve_relay(relay_message( + &subject, + "noreply@service.example", + AUTH_PASS_RELAY + )), + Err(EmailCodeError::RecipientMismatch), + "{subject}" + ); + } + assert_eq!( + resolve_relay(relay_message( + &format!("Verify {RELAY_RECIPIENT}."), + "noreply@service.example", + AUTH_PASS_RELAY + )), + Ok("QX7K2M".into()) + ); + + // A relay-shaped message from a sender not listed in relay_senders is + // judged as direct mail, and the relay domain is not a sender domain. + let other_relay = relay_message(&subject, "noreply@service.example", AUTH_PASS_RELAY); + assert_eq!( + resolve_direct(other_relay), + Err(EmailCodeError::SenderUntrusted) + ); + } + + #[test] + fn code_extraction_prefers_subject_and_plain_text_and_fails_on_ambiguity() { + assert_eq!( + resolve_direct(direct_message("Your code is QX7K2M", "Your code is QX7K2M")), + Ok("QX7K2M".into()) + ); + assert_eq!( + resolve_direct(direct_message("Your code is QX7K2M", "Your code is ZZ9P4B")), + Err(EmailCodeError::CodeAmbiguous) + ); + assert_eq!( + resolve_direct(direct_message( + "Verify your email", + "Code: AB12CD then code: AB12CE" + )), + Err(EmailCodeError::CodeAmbiguous) + ); + // Plain text wins; the HTML part's different code is not consulted. + assert_eq!( + resolve_direct(direct_message("Verify your email", "Your code is ZZ9P4B")), + Ok("ZZ9P4B".into()) + ); + // Without subject or plain candidates, visible HTML text is used. + assert_eq!( + resolve_direct(direct_message("Verify your email", "Welcome")), + Ok("QX7K2M".into()) + ); + let html_only = message_with( + &direct_headers("Verify your email", DIRECT_RECIPIENT), + vec![text_part( + "text/html", + "

Your code is QX7K2M

Code: #A1B2C3

", + )], + NOW_MS - 1_000, + ); + assert_eq!(resolve_direct(html_only), Ok("QX7K2M".into())); + let styles_only = message_with( + &direct_headers("Verify your email", DIRECT_RECIPIENT), + vec![text_part( + "text/html", + "

Code: #FFAA00

", + )], + NOW_MS - 1_000, + ); + assert_eq!( + resolve_direct(styles_only), + Err(EmailCodeError::CodeNotFound) + ); + assert_eq!( + resolve_direct(direct_message("Verify your email", "Welcome").pipe_without_html()), + Err(EmailCodeError::CodeNotFound) + ); + } + + trait WithoutHtml { + fn pipe_without_html(self) -> Value; + } + + impl WithoutHtml for Value { + fn pipe_without_html(mut self) -> Value { + self["payload"]["parts"] + .as_array_mut() + .unwrap() + .retain(|part| part["mimeType"] != "text/html"); + self + } + } + + #[test] + fn captured_codes_must_be_bounded_ascii_alphanumerics() { + let loose = EmailCodeGrant { + code_pattern: r"code is (\S+)".into(), + ..grant() + }; + for (plain, expected) in [ + ("Your code is AB1", Err(EmailCodeError::CodeNotFound)), + ( + "Your code is ABCDEFGHIJKLM", + Err(EmailCodeError::CodeNotFound), + ), + ("Your code is AB-123", Err(EmailCodeError::CodeNotFound)), + ("Your code is AB12", Ok("AB12".to_string())), + ("Your code is ABCDEFGHIJKL", Ok("ABCDEFGHIJKL".to_string())), + ] { + let message = direct_message("Verify your email", plain).pipe_without_html(); + let result = broker_with(policy(vec![loose.clone()]), provider_for(message)) + .resolve(request(DIRECT_RECIPIENT)); + assert_eq!(result, expected, "{plain}"); + } + let optional = EmailCodeGrant { + code_pattern: r"code is (?:([A-Z0-9]{6})|pending)".into(), + ..grant() + }; + let message = + direct_message("Verify your email", "Your code is pending").pipe_without_html(); + assert_eq!( + broker_with(policy(vec![optional]), provider_for(message)) + .resolve(request(DIRECT_RECIPIENT)), + Err(EmailCodeError::CodeNotFound) + ); + } + + #[test] + fn mime_decoding_handles_encoded_subjects_nesting_and_attachments() { + let message = message_with( + &direct_headers( + "=?UTF-8?B?WW91ciBjb2RlIGlz?= =?utf-8?Q?_QX7K2M?=", + DIRECT_RECIPIENT, + ), + vec![ + json!({ + "mimeType": "multipart/alternative", + "headers": [], + "body": { "size": 0 }, + "parts": [text_part("text/plain", "Thanks for signing up\u{00A0}")] + }), + json!({ + "mimeType": "text/plain", + "filename": "previous-code.txt", + "headers": [{ "name": "Content-Disposition", "value": "attachment; filename=previous-code.txt" }], + "body": { "size": 20, "data": encode("Your code is OLD999") } + }), + json!({ + "mimeType": "text/plain", + "headers": [{ "name": "Content-Disposition", "value": "ATTACHMENT" }], + "body": { "size": 20, "data": encode("Your code is OLD998") } + }), + ], + NOW_MS - 1_000, + ); + assert_eq!(resolve_direct(message), Ok("QX7K2M".into())); + + let latin1 = message_with( + &direct_headers("Verify your email", DIRECT_RECIPIENT), + vec![json!({ + "mimeType": "text/plain", + "headers": [{ "name": "Content-Type", "value": "text/plain; charset=ISO-8859-1" }], + "body": { "data": URL_SAFE_NO_PAD.encode(b"Votre code is QX7K2M \xe9") } + })], + NOW_MS - 1_000, + ); + assert_eq!(resolve_direct(latin1), Ok("QX7K2M".into())); + + for broken in [ + json!({ "mimeType": "text/plain", "headers": [], "body": { "data": "%%%not-base64%%%" } }), + json!({ "mimeType": "text/plain", "headers": [], "body": { "size": 90000, "attachmentId": "ANGjdJ" } }), + json!({ + "mimeType": "text/plain", + "headers": [{ "name": "Content-Type", "value": "text/plain; charset=koi8-r" }], + "body": { "data": encode("Your code is QX7K2M") } + }), + json!({ "mimeType": "text/plain", "headers": [], "body": { "data": URL_SAFE_NO_PAD.encode(b"code is QX7K2M \xff") } }), + ] { + let message = message_with( + &direct_headers("Verify your email", DIRECT_RECIPIENT), + vec![broken.clone()], + NOW_MS - 1_000, + ); + assert_eq!( + resolve_direct(message), + Err(EmailCodeError::MessageUnavailable), + "{broken}" + ); + } + let unsupported_subject = message_with( + &direct_headers("=?koi8-r?B?UVg3SzJN?=", DIRECT_RECIPIENT), + Vec::new(), + NOW_MS - 1_000, + ); + assert_eq!( + resolve_direct(unsupported_subject), + Err(EmailCodeError::MessageUnavailable) + ); + let mut headers = direct_headers("Your code is QX7K2M", DIRECT_RECIPIENT); + headers.push(("Subject", "Your code is ZZ9P4B")); + let duplicate_subject = message_with(&headers, Vec::new(), NOW_MS - 1_000); + assert_eq!( + resolve_direct(duplicate_subject), + Err(EmailCodeError::MessageUnavailable) + ); + + let mut nested = text_part("text/plain", "Your code is QX7K2M"); + for _ in 0..=MAX_MIME_DEPTH { + nested = json!({ "mimeType": "multipart/mixed", "headers": [], "parts": [nested] }); + } + let deep = message_with( + &direct_headers("Verify your email", DIRECT_RECIPIENT), + vec![nested], + NOW_MS - 1_000, + ); + assert_eq!( + resolve_direct(deep), + Err(EmailCodeError::MessageUnavailable) + ); + } + + #[test] + fn errors_and_debug_output_never_carry_mail_content_addresses_or_codes() { + let secret_message = direct_message("Your code is QX7K2M", "Your code is ZZ9P4B"); + let error = resolve_direct(secret_message).unwrap_err(); + let all_errors = [ + EmailCodeError::PolicyInvalid, + EmailCodeError::Disabled, + EmailCodeError::InvalidRequest, + EmailCodeError::GrantNotFound, + EmailCodeError::ProviderUnavailable, + EmailCodeError::ProviderTimeout, + EmailCodeError::MailboxMismatch, + EmailCodeError::MessageUnavailable, + EmailCodeError::MessageIneligible, + EmailCodeError::MessageNotFresh, + EmailCodeError::SenderUntrusted, + EmailCodeError::SenderUnauthenticated, + EmailCodeError::RecipientMismatch, + EmailCodeError::CodeNotFound, + EmailCodeError::CodeAmbiguous, + ]; + assert!(all_errors.contains(&error)); + let mut codes = all_errors + .iter() + .map(EmailCodeError::code) + .collect::>(); + codes.sort_unstable(); + codes.dedup(); + assert_eq!(codes.len(), all_errors.len()); + for error in all_errors { + let rendered = format!("{error} {error:?} {} {}", error.code(), error.recovery()); + for secret in [ + "QX7K2M", + "ZZ9P4B", + "synthetic", + "service.example", + MESSAGE_ID, + ] { + assert!(!rendered.contains(secret), "{rendered}"); + } + } + + let broker = broker_with( + policy(vec![relay_grant()]), + Arc::new(FakeProvider::default()), + ); + let rendered = format!("{broker:?} {:?}", policy(vec![relay_grant()])); + for private in [ + MAILBOX, + RELAY_RECIPIENT, + RELAY_SENDER, + "/opt/fixture", + CODE_PATTERN, + ] { + assert!(!rendered.contains(private), "{rendered}"); + } + assert!(rendered.contains("grant_count: 1")); + } + + #[test] + fn helper_boundaries_for_addresses_and_tokens() { + assert_eq!( + parse_address_list("\"Doe, Jane (x)\" (comment), b@c.example") + .unwrap(), + vec!["jane@example.com".to_string(), "b@c.example".to_string()] + ); + assert!(parse_address_list("Jane trailing").is_none()); + assert!(parse_address_list("not an address").is_none()); + assert!(parse_single_address("a@b.example, c@d.example").is_none()); + + let address = "alias@relay.example"; + assert!(contains_address_token("To: alias@relay.example", address)); + assert!(contains_address_token("", address)); + assert!(contains_address_token( + "sent to alias@relay.example.", + address + )); + assert!(!contains_address_token("my.alias@relay.example", address)); + assert!(!contains_address_token("x+alias@relay.example", address)); + assert!(!contains_address_token("alias@relay.example.org", address)); + assert!(!contains_address_token( + "alias@relay.example-two.org", + address + )); + assert!(!contains_address_token("alias@relay.examples", address)); + + assert_eq!( + mailbox_key("First.Last@GoogleMail.com").as_deref(), + Some("firstlast@googlemail.com") + ); + assert_eq!( + mailbox_key("first.last@example.com").as_deref(), + Some("first.last@example.com") + ); + assert!(normalize_email("a\nb@example.com").is_none()); + assert!(normalize_email("a@@example.com").is_none()); + assert!(normalize_email(".a@example.com").is_none()); + } + + #[cfg(unix)] + fn write_fixture_gws(directory: &std::path::Path, name: &str, body: &str) -> PathBuf { + use std::os::unix::fs::PermissionsExt; + let executable = directory.join(name); + std::fs::write(&executable, format!("#!/bin/sh\n{body}\n")).unwrap(); + std::fs::set_permissions(&executable, std::fs::Permissions::from_mode(0o700)).unwrap(); + executable + } + + #[cfg(unix)] + #[test] + fn gws_provider_reads_only_the_profile_and_exact_message_with_argument_vectors() { + let temp = tempfile::tempdir().unwrap(); + let log = temp.path().join("argv.log"); + let message_file = temp.path().join("message.json"); + std::fs::write( + &message_file, + serde_json::to_vec(&direct_message( + "Your code is QX7K2M", + "Your code is QX7K2M", + )) + .unwrap(), + ) + .unwrap(); + let executable = write_fixture_gws( + temp.path(), + "gws-reader", + &format!( + "printf '%s|' \"$@\" >> '{log}'\nprintf '\\n' >> '{log}'\ncase \"$3\" in\n getProfile) printf '%s' '{{\"emailAddress\":\"{MAILBOX}\"}}' ;;\n messages) cat '{message}' ;;\n *) exit 3 ;;\nesac", + log = log.display(), + message = message_file.display(), + ), + ); + let fixture_policy = EmailCodePolicy { + executable: Some(executable), + ..policy(vec![grant()]) + }; + let provider = Arc::new(GwsProvider::new(&fixture_policy)); + let broker = EmailCodeBroker::with_parts(fixture_policy, provider, fixed_now); + assert_eq!( + broker.resolve(request(DIRECT_RECIPIENT)), + Ok("QX7K2M".into()) + ); + let calls = std::fs::read_to_string(&log).unwrap(); + let calls = calls + .lines() + .map(|line| line.trim_end_matches('|').split('|').collect::>()) + .collect::>(); + assert_eq!(calls.len(), 2); + assert_eq!(calls[0][..4], ["gmail", "users", "getProfile", "--params"]); + assert_eq!( + serde_json::from_str::(calls[0][4]).unwrap(), + json!({ "userId": "me" }) + ); + assert_eq!(calls[0].len(), 5); + assert_eq!( + calls[1][..5], + ["gmail", "users", "messages", "get", "--params"] + ); + assert_eq!( + serde_json::from_str::(calls[1][5]).unwrap(), + json!({ "userId": "me", "id": MESSAGE_ID, "format": "full" }) + ); + assert_eq!(calls[1].len(), 6); + } + + #[cfg(unix)] + #[test] + fn gws_provider_failures_are_sanitized_and_bounded_by_the_deadline() { + let temp = tempfile::tempdir().unwrap(); + let failing = write_fixture_gws( + temp.path(), + "gws-failing", + "printf '%s' '{\"error\":{\"message\":\"token QX7K2M leaked\"}}'\nprintf 'stderr secret QX7K2M' >&2\nexit 1", + ); + let fixture_policy = EmailCodePolicy { + executable: Some(failing), + ..policy(vec![grant()]) + }; + let error = EmailCodeBroker::new(fixture_policy) + .resolve(request(DIRECT_RECIPIENT)) + .unwrap_err(); + assert_eq!(error, EmailCodeError::ProviderUnavailable); + assert!(!format!("{error} {error:?} {}", error.recovery()).contains("QX7K2M")); + + let error_body = write_fixture_gws( + temp.path(), + "gws-error-body", + "printf '%s' '{\"error\":{\"code\":404}}'", + ); + let provider = GwsProvider { + executable: Some(error_body), + }; + assert!(matches!( + provider.message(MESSAGE_ID, Instant::now() + Duration::from_secs(5)), + Err(ProviderError::Unavailable) + )); + + let slow = write_fixture_gws(temp.path(), "gws-slow", "exec sleep 5"); + let provider = GwsProvider { + executable: Some(slow), + }; + let started = Instant::now(); + assert!(matches!( + provider.profile(Instant::now() + Duration::from_millis(200)), + Err(ProviderError::Timeout) + )); + assert!(started.elapsed() < Duration::from_secs(4)); + assert!(matches!( + provider.profile(Instant::now()), + Err(ProviderError::Timeout) + )); + + let missing = GwsProvider { executable: None }; + assert!(matches!( + missing.profile(Instant::now() + Duration::from_secs(1)), + Err(ProviderError::Unavailable) + )); + } +} diff --git a/host-rs/crates/agenttab-host/src/guardrails.rs b/host-rs/crates/agenttab-host/src/guardrails.rs index fff2035..89f6c34 100644 --- a/host-rs/crates/agenttab-host/src/guardrails.rs +++ b/host-rs/crates/agenttab-host/src/guardrails.rs @@ -1,6 +1,7 @@ +use crate::email_codes::EmailCodePolicy; use agenttab_protocol::{ - BrowserAction, BrowserOpenParams, BrowserProfileParams, MethodParams, NativeOriginPolicy, - RpcError, RpcMethod, + BrowserAction, BrowserCredentialsParams, BrowserOpenParams, BrowserProfileParams, MethodParams, + NativeOriginPolicy, RpcError, RpcMethod, }; use regex::{Regex, RegexBuilder}; use serde::Deserialize; @@ -37,6 +38,8 @@ pub enum GuardrailLoadError { }, #[error("invalid 1Password policy: {0}")] InvalidOnePasswordPolicy(String), + #[error("invalid email code policy: {0}")] + InvalidEmailCodePolicy(String), } #[derive(Debug, Clone, Deserialize)] @@ -73,6 +76,7 @@ struct Policy { dlp_max_file_bytes: u64, redact_patterns: Vec, one_password: OnePasswordPolicy, + email_codes: EmailCodePolicy, } impl Default for Policy { @@ -85,6 +89,7 @@ impl Default for Policy { dlp_allowed_roots: Vec::new(), dlp_max_file_bytes: DEFAULT_DLP_MAX_FILE_BYTES, one_password: OnePasswordPolicy::default(), + email_codes: EmailCodePolicy::default(), redact_patterns: Vec::new(), } } @@ -150,6 +155,10 @@ impl Guardrails { "executable must be an absolute path".into(), )); } + policy + .email_codes + .validate() + .map_err(GuardrailLoadError::InvalidEmailCodePolicy)?; let mut patterns = vec![ r"(?i)\bbearer\s+[a-z0-9._~+/=-]{8,}\b".to_string(), r"\b\d{3}-\d{2}-\d{4}\b".to_string(), @@ -179,6 +188,10 @@ impl Guardrails { &self.policy.one_password } + pub fn email_code_policy(&self) -> &EmailCodePolicy { + &self.policy.email_codes + } + pub fn authorize(&self, method: RpcMethod, params: &MethodParams) -> Result<(), RpcError> { if method == RpcMethod::BrowserDeveloper && !self.policy.developer_enabled { return Err(RpcError::new( @@ -187,12 +200,27 @@ impl Guardrails { ) .with_recovery("Enable Developer mode in AgentTab's managed local policy.")); } - if method == RpcMethod::BrowserCredentials && !self.policy.one_password.enabled { - return Err(RpcError::new( - "credential_provider_disabled", - "1Password credential filling is disabled by AgentTab policy", - ) - .with_recovery("Enable one_password in AgentTab's managed local policy.")); + if method == RpcMethod::BrowserCredentials { + if matches!( + params, + MethodParams::Credentials(BrowserCredentialsParams::FillEmail { .. }) + ) { + if !self.policy.email_codes.enabled { + return Err(RpcError::new( + "email_code_provider_disabled", + "Email verification code filling is disabled by AgentTab policy", + ) + .with_recovery( + "Enable email_codes with an exact grant in AgentTab's managed local policy.", + )); + } + } else if !self.policy.one_password.enabled { + return Err(RpcError::new( + "credential_provider_disabled", + "1Password credential filling is disabled by AgentTab policy", + ) + .with_recovery("Enable one_password in AgentTab's managed local policy.")); + } } match params { @@ -743,6 +771,116 @@ mod tests { assert!(!disabled.one_password_policy().enabled); } + fn email_code_policy_json(enabled: bool) -> Value { + json!({ + "enabled": enabled, + "executable": "/opt/fixture/bin/gws", + "mailbox": "synthetic.owner@gmail.com", + "grants": [{ + "origin": "https://id.service.example", + "paths": ["/signup/verify-email/otp"], + "recipient": "member-alias@relay.example", + "purposes": ["signup"], + "sender_domains": ["service.example"], + "relay_senders": ["forwarder@relay.example"], + "code_pattern": "code is ([A-Z0-9]{6})" + }] + }) + } + + fn credentials_requests() -> (MethodParams, MethodParams) { + let prepare = MethodParams::Credentials(BrowserCredentialsParams::Prepare { + tab_id: 4, + expected_page_revision: 9, + }); + let fill_email = MethodParams::Credentials(BrowserCredentialsParams::FillEmail { + tab_id: 4, + expected_page_revision: 9, + message_id: "18c0ffee0000abcd".into(), + recipient: "member-alias@relay.example".into(), + purpose: "signup".into(), + otp_refs: vec!["e1".into()], + }); + (prepare, fill_email) + } + + #[test] + fn email_code_filling_is_gated_separately_from_one_password() { + let (prepare, fill_email) = credentials_requests(); + + let defaults = Guardrails::defaults(); + assert!(!defaults.email_code_policy().enabled); + assert!(defaults + .authorize(RpcMethod::BrowserCredentials, &prepare) + .is_ok()); + let refused = defaults + .authorize(RpcMethod::BrowserCredentials, &fill_email) + .unwrap_err(); + assert_eq!(refused.code, "email_code_provider_disabled"); + + let email_only: Policy = serde_json::from_value(json!({ + "one_password": { "enabled": false }, + "email_codes": email_code_policy_json(true) + })) + .unwrap(); + let email_only = Guardrails::from_policy(email_only).unwrap(); + assert!(email_only.email_code_policy().enabled); + assert_eq!(email_only.email_code_policy().grants.len(), 1); + assert!(email_only + .authorize(RpcMethod::BrowserCredentials, &fill_email) + .is_ok()); + assert_eq!( + email_only + .authorize(RpcMethod::BrowserCredentials, &prepare) + .unwrap_err() + .code, + "credential_provider_disabled" + ); + } + + #[test] + fn invalid_email_code_policy_fails_load_without_echoing_values() { + let temp = tempfile::tempdir().unwrap(); + let path = temp.path().join("policy.json"); + let mut email_codes = email_code_policy_json(true); + email_codes["grants"][0]["paths"] = json!(["/account/two-factor/verify"]); + fs::write(&path, json!({ "email_codes": email_codes }).to_string()).unwrap(); + let error = Guardrails::load(&path).unwrap_err(); + assert!(matches!( + error, + GuardrailLoadError::InvalidEmailCodePolicy(_) + )); + let rendered = error.to_string(); + assert!(!rendered.contains("two-factor")); + assert!(!rendered.contains("member-alias")); + + let mut email_codes = email_code_policy_json(false); + email_codes["grants"][0]["origin"] = json!("https://*.service.example"); + fs::write(&path, json!({ "email_codes": email_codes }).to_string()).unwrap(); + assert!(matches!( + Guardrails::load(&path).unwrap_err(), + GuardrailLoadError::InvalidEmailCodePolicy(_) + )); + + fs::write( + &path, + json!({ "email_codes": { "enabled": false, "search_query": "in:anywhere" } }) + .to_string(), + ) + .unwrap(); + assert!(matches!( + Guardrails::load(&path).unwrap_err(), + GuardrailLoadError::InvalidPolicy(_) + )); + + fs::write( + &path, + json!({ "email_codes": email_code_policy_json(true) }).to_string(), + ) + .unwrap(); + assert!(Guardrails::load(&path).unwrap().email_code_policy().enabled); + } + #[test] fn origins_and_redaction_are_enforced() { let guardrails = Guardrails::from_policy(Policy { diff --git a/host-rs/crates/agenttab-host/src/journal.rs b/host-rs/crates/agenttab-host/src/journal.rs index 29a61f8..dc5afa5 100644 --- a/host-rs/crates/agenttab-host/src/journal.rs +++ b/host-rs/crates/agenttab-host/src/journal.rs @@ -2132,6 +2132,73 @@ mod tests { )); } + #[test] + fn opens_a_state_store_with_a_retired_staged_commit_column() { + let temp = tempfile::tempdir().unwrap(); + let path = temp.path().join("state.sqlite3"); + let staged = |native_token: &str, task_id: Uuid| NativeStagedCommit { + native_token: native_token.into(), + task_id, + tab_id: 7, + page_revision: 9, + effect: "submit order".into(), + fingerprint: "d".repeat(64), + expires_at_ms: now_ms() + 60_000, + }; + // A store written by an earlier host version carries one more nullable + // column, populated on its pending stage. + let (task_id, earlier) = { + let journal = Journal::open(&path).unwrap(); + let task = journal.create_task(None).unwrap(); + journal + .reconcile_inventory(&[owned_tab(task.task_id, 9)]) + .unwrap(); + let handles = journal + .store_staged_commit(&staged("native-token-earlier-host", task.task_id), &[]) + .unwrap(); + journal + .connection + .lock() + .execute_batch( + "ALTER TABLE staged_commits ADD COLUMN retired_scope_json TEXT; + UPDATE staged_commits SET retired_scope_json = '{\"scope\":\"retired\"}';", + ) + .unwrap(); + (task.task_id, handles) + }; + + let reopened = Journal::open(&path).unwrap(); + reopened + .reconcile_inventory(&[owned_tab(task_id, 9)]) + .unwrap(); + let current = reopened + .store_staged_commit(&staged("native-token-current-host", task_id), &[]) + .unwrap(); + for (handles, native_token) in [ + (&earlier, "native-token-earlier-host"), + (¤t, "native-token-current-host"), + ] { + assert!(matches!( + reopened.consume_staged_commit(task_id, &handles.staged_token, Uuid::now_v7()), + Ok(StagedCommitConsumption::ApprovalRequired) + )); + assert!(matches!( + reopened.approve_popup_staged_commit(task_id, 7, &handles.review_handle), + Ok(StagedCommitApproval::Approved) + )); + let StagedCommitConsumption::Ready(consumed) = reopened + .consume_staged_commit(task_id, &handles.staged_token, Uuid::now_v7()) + .unwrap() + else { + panic!("an approved stage must be consumable despite a retired column"); + }; + assert_eq!(consumed.native_token, native_token); + assert_eq!(consumed.effect, "submit order"); + assert!(consumed.upload_paths.is_empty()); + reopened.finish_staged_commit(native_token).unwrap(); + } + } + #[test] fn abandoning_popup_review_returns_uploads_and_removes_host_record() { let temp = tempfile::tempdir().unwrap(); diff --git a/host-rs/crates/agenttab-host/src/lib.rs b/host-rs/crates/agenttab-host/src/lib.rs index 4e412a9..bef1096 100644 --- a/host-rs/crates/agenttab-host/src/lib.rs +++ b/host-rs/crates/agenttab-host/src/lib.rs @@ -1,5 +1,6 @@ pub mod audit; pub mod credentials; +pub mod email_codes; pub mod guardrails; pub mod journal; pub mod lifecycle; diff --git a/host-rs/crates/agenttab-host/src/runtime.rs b/host-rs/crates/agenttab-host/src/runtime.rs index 77a3a65..c2895da 100644 --- a/host-rs/crates/agenttab-host/src/runtime.rs +++ b/host-rs/crates/agenttab-host/src/runtime.rs @@ -2,6 +2,7 @@ use crate::audit::{canonicalize, now_ms, AuditEntry, AuditLog}; use crate::credentials::{ BrokerError, CredentialBroker, NeedsUserReason, PrepareResult, SelectResult, }; +use crate::email_codes::{EmailCodeBroker, EmailCodePolicy, EmailCodeRequest}; use crate::guardrails::{GuardrailLoadError, Guardrails, STALE_UPLOAD_AGE}; use crate::journal::{ BeginDecision, InventoryReconciliation, Journal, JournalError, StagedCommitApproval, @@ -32,6 +33,7 @@ use uuid::Uuid; const RESPONSE_TASK_BINDING_RESERVE: usize = 512; const CLOSE_TASK_TIMEOUT: Duration = Duration::from_secs(5); const CLOSE_TAB_STAGED_EFFECT: &str = "Close an AgentTab-owned browser tab"; +const EMAIL_CODE_NATIVE_TIMEOUT: Duration = Duration::from_secs(30); #[derive(Debug, Error)] pub enum RuntimeBuildError { @@ -157,6 +159,7 @@ pub struct Runtime { audit: Arc, native: Arc, credentials: Arc, + email_codes: Arc, task_locks: Mutex>>>, tab_urls: Arc>>, upload_staging_dir: PathBuf, @@ -176,6 +179,17 @@ impl Runtime { paths: &AgentTabPaths, lifecycle: Arc, native: Arc, + ) -> Result, RuntimeBuildError> { + Self::open_with(paths, lifecycle, native, |policy| { + EmailCodeBroker::new(policy.clone()) + }) + } + + fn open_with( + paths: &AgentTabPaths, + lifecycle: Arc, + native: Arc, + email_code_broker: impl FnOnce(&EmailCodePolicy) -> EmailCodeBroker, ) -> Result, RuntimeBuildError> { paths.prepare()?; // Delivered uploads outlive their deferred cleanup thread when the host @@ -197,6 +211,7 @@ impl Runtime { let credentials = Arc::new(CredentialBroker::new( guardrails.one_password_policy().clone(), )); + let email_codes = Arc::new(email_code_broker(guardrails.email_code_policy())); let runtime = Arc::new(Self { lifecycle, journal, @@ -204,6 +219,7 @@ impl Runtime { audit, native, credentials, + email_codes, task_locks: Mutex::new(HashMap::new()), tab_urls, upload_staging_dir: paths.upload_staging_dir.clone(), @@ -1402,6 +1418,11 @@ impl Runtime { tab_id, expected_page_revision, } + | BrowserCredentialsParams::FillEmail { + tab_id, + expected_page_revision, + .. + } | BrowserCredentialsParams::Fill { tab_id, expected_page_revision, @@ -1413,6 +1434,9 @@ impl Runtime { .. } => (*tab_id, *expected_page_revision), }; + if matches!(params, BrowserCredentialsParams::FillEmail { .. }) { + return self.dispatch_email_code(connection_id, task_id, request_id, params); + } let host = match self.credential_host(tab_id) { Ok(host) => host, Err(error) => { @@ -1457,7 +1481,8 @@ impl Runtime { otp_ref, .. } => (credential_token, username_ref, password_ref, otp_ref, true), - BrowserCredentialsParams::Prepare { .. } => unreachable!(), + BrowserCredentialsParams::Prepare { .. } + | BrowserCredentialsParams::FillEmail { .. } => unreachable!(), }; let selection = match self.credentials.select( credential_token, @@ -1555,7 +1580,123 @@ impl Runtime { } } + fn dispatch_email_code( + &self, + connection_id: Uuid, + task_id: Uuid, + request_id: &str, + params: &BrowserCredentialsParams, + ) -> RpcResponse { + let BrowserCredentialsParams::FillEmail { + tab_id, + expected_page_revision, + message_id, + recipient, + purpose, + otp_refs, + } = params + else { + unreachable!() + }; + let url = match self.credential_url(*tab_id) { + Ok(url) => url, + Err(error) => { + return RpcResponse::failure(request_id, Outcome::NotStarted, error); + } + }; + let origin = url.origin().ascii_serialization(); + // The broker checks the exact grant, mailbox, freshness, sender + // authentication, and recipient. Errors carry fixed broker text only. + let code = match self.email_codes.resolve(EmailCodeRequest { + origin: &origin, + path: url.path(), + recipient, + purpose, + message_id, + }) { + Ok(code) => code, + Err(error) => { + return RpcResponse::failure( + request_id, + Outcome::NotStarted, + RpcError::new(error.code(), error.to_string()).with_recovery(error.recovery()), + ); + } + }; + if !(4..=12).contains(&code.len()) || !code.bytes().all(|byte| byte.is_ascii_alphanumeric()) + { + return RpcResponse::failure( + request_id, + Outcome::NotStarted, + RpcError::new( + "email_code_material_invalid", + "Email verification requires a 4 to 12 character ASCII alphanumeric code", + ), + ); + } + if otp_refs.len() != 1 && otp_refs.len() != code.len() { + return RpcResponse::failure( + request_id, + Outcome::NotStarted, + RpcError::new( + "email_code_field_count_mismatch", + "Use one complete-code input or one verification input per code character", + ) + .with_recovery("Refresh the verification controls and provide their ordered refs."), + ); + } + let fields = if otp_refs.len() == 1 { + vec![json!({"kind": "otp", "ref": otp_refs[0], "value": code})] + } else { + otp_refs + .iter() + .zip(code.chars()) + .map(|(field_ref, character)| { + json!({"kind": "otp", "ref": field_ref, "value": character.to_string()}) + }) + .collect() + }; + let native_params = json!({ + "tab_id": tab_id, + "expected_page_revision": expected_page_revision, + "expected_origin": origin, + "expected_path": url.path(), + "recipient": recipient, + "purpose": purpose, + "fields": fields, + }); + match self.native.dispatch( + connection_id, + task_id, + "browser_email_code_fill", + native_params, + self.guardrails.native_origin_policy(*tab_id), + EMAIL_CODE_NATIVE_TIMEOUT, + ) { + Ok(native) => email_code_fill_response( + request_id, + native, + otp_refs.len(), + *expected_page_revision, + ), + Err(error) => RpcResponse::failure( + request_id, + Outcome::Unknown, + email_code_input_error(native_error_code(&error)), + ), + } + } + fn credential_host(&self, tab_id: u64) -> Result { + let url = self.credential_url(tab_id)?; + Ok(url + .host_str() + .expect("credential_url verifies the hostname") + .trim_end_matches('.') + .to_ascii_lowercase()) + } + + fn credential_url(&self, tab_id: u64) -> Result { let tab_url = self.tab_urls.read().get(&tab_id).cloned().ok_or_else(|| { RpcError::new( "tab_origin_unverified", @@ -1583,7 +1724,7 @@ impl Runtime { ) .with_recovery("Use HTTPS or sign in manually after verifying the site.")); } - Ok(host.trim_end_matches('.').to_ascii_lowercase()) + Ok(url) } fn attach_task_binding( @@ -1664,6 +1805,11 @@ fn requested_tab(params: &MethodParams) -> Option<(u64, Option)> { tab_id, expected_page_revision, } + | BrowserCredentialsParams::FillEmail { + tab_id, + expected_page_revision, + .. + } | BrowserCredentialsParams::Fill { tab_id, expected_page_revision, @@ -1825,6 +1971,117 @@ fn native_event_result(response: RpcResponse) -> NativeEventResult { } } +fn native_error_code(error: &NativeError) -> &str { + match error { + NativeError::Disconnected => "extension_disconnected", + NativeError::Timeout => "extension_timeout", + NativeError::Protocol(_) => "native_protocol_error", + NativeError::Transport(_) => "native_transport_error", + NativeError::Extension(refusal) => &refusal.code, + } +} + +fn email_code_fill_response( + request_id: &str, + native: NativeResponse, + filled_fields: usize, + expected_page_revision: u64, +) -> RpcResponse { + match (native.outcome, native.result, native.error) { + (Outcome::Completed, Some(result), None) + if result.get("filled_fields").and_then(Value::as_u64) + == Some(filled_fields as u64) => + { + RpcResponse::success( + request_id, + Outcome::Completed, + json!({ + "status": "filled", + "filled_email_code": true, + "filled_fields": filled_fields, + "page_revision": result + .get("page_revision") + .and_then(Value::as_u64) + .unwrap_or(expected_page_revision), + }), + ) + } + (outcome, None, Some(error)) => { + RpcResponse::failure(request_id, outcome, email_code_input_error(&error.code)) + } + _ => RpcResponse::failure( + request_id, + Outcome::Unknown, + email_code_input_error("email_code_fill_failed"), + ), + } +} + +// Only codes cross this boundary. Native messages, recovery text, and details may echo the code. +fn email_code_input_error(code: &str) -> RpcError { + let code = match code { + "invalid_request" + | "stale_revision" + | "stale_ref" + | "email_code_page_mismatch" + | "email_code_target_invalid" + | "email_code_context_unverified" + | "email_code_recipient_unverified" + | "email_code_context_forbidden" + | "email_code_fill_failed" + | "challenge_active" + | "cross_origin_credentials_forbidden" + | "browser_restricted_origin" + | "debugger_unavailable" + | "frame_unavailable" + | "frame_not_found" + | "debugger_detached" + | "extension_disconnected" + | "ownership_denied" + | "ownership_revoked" + | "permissions_required" + | "paused" + | "origin_denied" + | "origin_not_allowed" + | "origin_unavailable" + | "origin_policy_mismatch" + | "scheme_denied" + | "origin_policy_unverifiable" + | "extension_timeout" + | "native_protocol_error" + | "native_transport_error" => code, + _ => "email_code_fill_failed", + }; + let recovery = match code { + "email_code_context_forbidden" | "challenge_active" => { + "Do not retry this fill. Start browser_handoff for this tab and let the human complete verification." + } + "email_code_context_unverified" => { + "Confirm this exact page shows email verification for the intended recipient. Do not submit unconfirmed input." + } + "email_code_recipient_unverified" => { + "Use a verification page that identifies the full recipient or one compatible masked recipient, or let the human complete this step." + } + "extension_disconnected" | "extension_timeout" | "native_transport_error" => { + "Repair the AgentTab connection, then inspect this exact verification tab before retrying. Do not submit unconfirmed input." + } + "native_protocol_error" => { + "Update AgentTab so the host and extension protocol versions match, then inspect the verification tab." + } + _ => { + "Inspect this exact verification tab and refresh its refs before retrying. Do not submit unconfirmed input." + } + }; + let message = match code { + "email_code_context_unverified" => "The page does not show an email code verification", + "email_code_recipient_unverified" => { + "The verification page does not identify the exact intended email recipient" + } + _ => "Email verification input did not return a confirmed complete result", + }; + RpcError::new(code, message).with_recovery(recovery) +} + fn native_failure(request_id: &str, error: NativeError) -> RpcResponse { let (code, recovery) = match error { NativeError::Extension(refusal) => { @@ -1999,6 +2256,10 @@ fn enforce_response_limit(response: RpcResponse) -> RpcResponse { #[cfg(test)] mod tests { use super::*; + use crate::email_codes::tests::{ + broker_with, direct_message, grant, policy, provider_for, FakeProvider, DIRECT_RECIPIENT, + MESSAGE_ID, ORIGIN, PATH, + }; use crate::native::NativeTaskFinish; use agenttab_protocol::{ ConnectKind, FinishDisposition, NativeInventoryEvent, NativeOriginPolicy, NativeResponse, @@ -4485,6 +4746,334 @@ mod tests { } } + #[test] + fn email_code_input_errors_preserve_safe_diagnostics_without_secret_text() { + for code in [ + "email_code_context_unverified", + "email_code_recipient_unverified", + "email_code_target_invalid", + "stale_ref", + "debugger_unavailable", + "extension_timeout", + "native_protocol_error", + ] { + let error = email_code_input_error(code); + assert_eq!(error.code, code); + } + for code in ["verification code 739184", "email_code_739184", ""] { + let error = email_code_input_error(code); + assert_eq!(error.code, "email_code_fill_failed"); + assert!(!serde_json::to_string(&error).unwrap().contains("739184")); + } + for code in ["challenge_active", "email_code_context_forbidden"] { + let error = email_code_input_error(code); + assert_eq!(error.code, code); + assert!(error.recovery.unwrap().contains("browser_handoff")); + } + } + + /// One scripted answer from the extension's private email-code channel. + #[derive(Debug)] + enum EmailCodeReply { + Filled, + Refused(Outcome, &'static str), + Failed(NativeError), + } + + #[derive(Debug)] + struct EmailCodeNative { + calls: Mutex>, + reply: EmailCodeReply, + } + + impl EmailCodeNative { + fn new(reply: EmailCodeReply) -> Arc { + Arc::new(Self { + calls: Mutex::new(Vec::new()), + reply, + }) + } + + fn methods(&self) -> Vec { + self.calls + .lock() + .iter() + .map(|(method, _)| method.clone()) + .collect() + } + } + + impl NativeTransport for EmailCodeNative { + fn dispatch( + &self, + _connection_id: Uuid, + _task_id: Uuid, + method: &str, + params: Value, + _origin_policy: Option, + _timeout: Duration, + ) -> Result { + self.calls.lock().push((method.to_owned(), params.clone())); + let (outcome, result, error) = match (method, &self.reply) { + ("browser_email_code_fill", EmailCodeReply::Filled) => ( + Outcome::Completed, + Some(json!({ + "filled_fields": params["fields"].as_array().map_or(0, Vec::len), + "page_revision": 8, + })), + None, + ), + ("browser_email_code_fill", EmailCodeReply::Refused(outcome, code)) => ( + *outcome, + None, + Some( + RpcError::new(*code, "page echoed QX7K2M near the recipient") + .with_recovery("Type QX7K2M manually"), + ), + ), + ("browser_email_code_fill", EmailCodeReply::Failed(error)) => { + return Err(error.clone()) + } + _ => (Outcome::Completed, Some(json!({})), None), + }; + Ok(NativeResponse { + protocol: agenttab_protocol::NATIVE_PROTOCOL.into(), + version: PROTOCOL_VERSION, + kind: NativeResponseKind::Response, + request_id: Uuid::new_v4(), + outcome, + result, + error, + staged: None, + }) + } + } + + /// A runtime whose email-code broker reads one fresh synthetic message + /// carrying code QX7K2M, with the verification tab on the granted page. + fn email_code_runtime( + native: Arc, + ) -> ( + tempfile::TempDir, + Arc, + Arc, + Arc, + ) { + let provider = provider_for(direct_message("Your code is QX7K2M", "Your code is QX7K2M")); + let broker_provider = provider.clone(); + let temp = tempfile::tempdir().unwrap(); + let paths = AgentTabPaths::from_root(temp.path().join("agenttab")); + let configured_policy = policy(vec![grant()]); + let configured_grant = &configured_policy.grants[0]; + paths.prepare().unwrap(); + std::fs::write( + &paths.policy_file, + serde_json::to_vec(&json!({ + "email_codes": { + "enabled": configured_policy.enabled, + "executable": configured_policy.executable, + "mailbox": configured_policy.mailbox, + "grants": [{ + "origin": configured_grant.origin, + "paths": configured_grant.paths, + "recipient": configured_grant.recipient, + "purposes": configured_grant.purposes, + "sender_domains": configured_grant.sender_domains, + "code_pattern": configured_grant.code_pattern, + }], + }, + })) + .unwrap(), + ) + .unwrap(); + let lifecycle = Arc::new(Lifecycle::default()); + lifecycle.begin_reconciliation(); + lifecycle.complete_reconciliation(false); + let runtime = Runtime::open_with(&paths, lifecycle, native, move |policy| { + broker_with(policy.clone(), broker_provider) + }) + .unwrap(); + let (connection, _) = runtime + .connect(ConnectionInit { + protocol: RPC_PROTOCOL.into(), + version: PROTOCOL_VERSION, + kind: ConnectKind::Connect, + conversation_id: None, + resume_capability: None, + }) + .unwrap(); + own_tab(&runtime, &connection, 7); + runtime + .tab_urls + .write() + .insert(3, format!("{ORIGIN}{PATH}")); + (temp, runtime, connection, provider) + } + + fn fill_email_request(request_id: &str, key: Uuid) -> Value { + json!({ + "protocol": RPC_PROTOCOL, + "version": PROTOCOL_VERSION, + "request_id": request_id, + "idempotency_key": key, + "method": "browser_credentials", + "params": { + "action": "fill_email", + "tab_id": 3, + "expected_page_revision": 7, + "message_id": MESSAGE_ID, + "recipient": DIRECT_RECIPIENT, + "purpose": "signup", + "otp_refs": ["e1"], + } + }) + } + + fn staged_row_count(temp: &tempfile::TempDir) -> i64 { + rusqlite::Connection::open(temp.path().join("agenttab/state.sqlite3")) + .unwrap() + .query_row("SELECT COUNT(*) FROM staged_commits", [], |row| row.get(0)) + .unwrap() + } + + fn tree_contains(dir: &std::path::Path, needle: &[u8]) -> bool { + std::fs::read_dir(dir).unwrap().any(|entry| { + let path = entry.unwrap().path(); + if path.is_dir() { + tree_contains(&path, needle) + } else { + std::fs::read(&path) + .map(|bytes| bytes.windows(needle.len()).any(|window| window == needle)) + .unwrap_or(false) + } + }) + } + + #[test] + fn email_code_fill_writes_directly_without_a_staged_review() { + let native = EmailCodeNative::new(EmailCodeReply::Filled); + let (temp, runtime, connection, provider) = email_code_runtime(native.clone()); + + let response = runtime.handle( + &connection, + fill_email_request("fill-direct", Uuid::now_v7()), + ); + assert_eq!(response["outcome"], "completed", "{response}"); + assert_eq!(response["result"]["status"], "filled"); + assert_eq!(response["result"]["filled_email_code"], true); + assert_eq!(response["result"]["filled_fields"], 1); + assert_eq!(response["result"]["page_revision"], 8); + assert!(response["result"].get("staged_token").is_none()); + assert!(!response.to_string().contains("QX7K2M")); + + // One verified mailbox read and one private-channel write, never a stage. + assert_eq!( + *provider.calls.lock(), + vec!["profile".to_owned(), format!("message:{MESSAGE_ID}")] + ); + assert_eq!(native.methods(), vec!["browser_email_code_fill"]); + let fill = native.calls.lock()[0].1.clone(); + assert_eq!( + fill, + json!({ + "tab_id": 3, + "expected_page_revision": 7, + "expected_origin": ORIGIN, + "expected_path": PATH, + "recipient": DIRECT_RECIPIENT, + "purpose": "signup", + "fields": [{"kind": "otp", "ref": "e1", "value": "QX7K2M"}], + }) + ); + assert_eq!(staged_row_count(&temp), 0); + assert!(!tree_contains(temp.path(), b"QX7K2M")); + } + + #[test] + fn email_code_fill_outside_the_exact_grant_never_reads_mail_or_writes() { + let native = EmailCodeNative::new(EmailCodeReply::Filled); + let (temp, runtime, connection, provider) = email_code_runtime(native.clone()); + runtime + .tab_urls + .write() + .insert(3, format!("{ORIGIN}/account/security")); + + let response = runtime.handle( + &connection, + fill_email_request("fill-ungranted", Uuid::now_v7()), + ); + assert_eq!(response["outcome"], "not_started", "{response}"); + assert_eq!(response["error"]["code"], "email_code_grant_not_found"); + assert!(provider.calls.lock().is_empty()); + assert!(native.methods().is_empty()); + assert_eq!(staged_row_count(&temp), 0); + } + + #[test] + fn email_code_refusals_return_fixed_diagnostics_without_staging() { + for (reply, expected_outcome, expected_code) in [ + ( + EmailCodeReply::Refused(Outcome::NotStarted, "email_code_recipient_unverified"), + "not_started", + "email_code_recipient_unverified", + ), + ( + EmailCodeReply::Refused(Outcome::NotStarted, "email_code_context_forbidden"), + "not_started", + "email_code_context_forbidden", + ), + ( + EmailCodeReply::Refused(Outcome::NotStarted, "challenge_active"), + "not_started", + "challenge_active", + ), + ( + EmailCodeReply::Refused(Outcome::Unknown, "page said QX7K2M"), + "unknown", + "email_code_fill_failed", + ), + ( + EmailCodeReply::Failed(NativeError::Extension(RpcError::new( + "ownership_revoked", + "tab QX7K2M was released", + ))), + "unknown", + "ownership_revoked", + ), + ( + EmailCodeReply::Failed(NativeError::Timeout), + "unknown", + "extension_timeout", + ), + ] { + let native = EmailCodeNative::new(reply); + let (temp, runtime, connection, _provider) = email_code_runtime(native.clone()); + let response = runtime.handle( + &connection, + fill_email_request("fill-refused", Uuid::now_v7()), + ); + let expected = email_code_input_error(expected_code); + assert_eq!(response["outcome"], expected_outcome, "{expected_code}"); + assert_eq!(response["error"]["code"], expected_code, "{expected_code}"); + assert_eq!( + response["error"]["message"], expected.message, + "{expected_code}" + ); + assert_eq!( + response["error"]["recovery"], + expected.recovery.unwrap(), + "{expected_code}" + ); + assert!(!response.to_string().contains("QX7K2M"), "{expected_code}"); + assert_eq!( + native.methods(), + vec!["browser_email_code_fill"], + "{expected_code}" + ); + assert_eq!(staged_row_count(&temp), 0, "{expected_code}"); + } + } + fn current_time_ms() -> i64 { std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) diff --git a/host-rs/crates/agenttab-protocol/src/lib.rs b/host-rs/crates/agenttab-protocol/src/lib.rs index 91d752c..57f23d8 100644 --- a/host-rs/crates/agenttab-protocol/src/lib.rs +++ b/host-rs/crates/agenttab-protocol/src/lib.rs @@ -34,6 +34,13 @@ const MAX_DEVELOPER_PARAMS: usize = 16; const MAX_DEVELOPER_PARAM_KEY_CHARS: usize = 64; const MAX_DEVELOPER_VALUE_CHARS: usize = 512; const MAX_DEVELOPER_ARRAY_ITEMS: usize = 16; +const MAX_EMAIL_MESSAGE_ID_CHARS: usize = 128; +const MIN_EMAIL_ADDRESS_CHARS: usize = 3; +const MAX_EMAIL_ADDRESS_CHARS: usize = 320; +const MAX_EMAIL_LOCAL_PART_CHARS: usize = 64; +const MAX_EMAIL_DOMAIN_CHARS: usize = 255; +const MAX_EMAIL_CODE_REFS: usize = 12; +const EMAIL_CODE_PURPOSES: [&str; 2] = ["signup", "email_sign_in"]; pub const RPC_SCHEMA_ASSETS: &[(&str, &str)] = &[ ( @@ -84,6 +91,10 @@ pub const RPC_SCHEMA_ASSETS: &[(&str, &str)] = &[ "browser_commit", include_str!("../../../../schemas/rpc/v1/browser-commit.schema.json"), ), + ( + "browser_credentials", + include_str!("../../../../schemas/rpc/v1/browser-credentials.schema.json"), + ), ( "browser_profile", include_str!("../../../../schemas/rpc/v1/browser-profile.schema.json"), @@ -867,6 +878,16 @@ pub enum BrowserCredentialsParams { #[serde(default, skip_serializing_if = "Option::is_none")] otp_ref: Option, }, + /// Brokered email verification code. Carries only non-secret routing + /// metadata; the host resolves the code from the managed mailbox grant. + FillEmail { + tab_id: u64, + expected_page_revision: u64, + message_id: String, + recipient: String, + purpose: String, + otp_refs: Vec, + }, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -1385,6 +1406,46 @@ impl MethodParams { } } } + Self::Credentials(BrowserCredentialsParams::FillEmail { + message_id, + recipient, + purpose, + otp_refs, + .. + }) => { + require( + method, + is_email_message_id(message_id), + format!( + "message_id must contain 1 to {MAX_EMAIL_MESSAGE_ID_CHARS} hexadecimal characters" + ), + )?; + require( + method, + is_bounded_email_address(recipient), + format!( + "recipient must be one email address of {MIN_EMAIL_ADDRESS_CHARS} to {MAX_EMAIL_ADDRESS_CHARS} printable ASCII characters" + ), + )?; + require( + method, + EMAIL_CODE_PURPOSES.contains(&purpose.as_str()), + "purpose must be signup or email_sign_in", + )?; + require( + method, + (1..=MAX_EMAIL_CODE_REFS).contains(&otp_refs.len()), + format!("otp_refs must contain 1 to {MAX_EMAIL_CODE_REFS} items"), + )?; + for (index, value) in otp_refs.iter().enumerate() { + require_len(method, value, 1, MAX_REF_CHARS, "otp_refs item")?; + require( + method, + !otp_refs[..index].contains(value), + "otp_refs must not contain duplicates", + )?; + } + } Self::Profile(params) => params.validate(method)?, Self::Commit(params) => require_len( method, @@ -1690,6 +1751,27 @@ fn is_challenge_id(value: &str) -> bool { }) } +fn is_email_message_id(value: &str) -> bool { + (1..=MAX_EMAIL_MESSAGE_ID_CHARS).contains(&value.len()) + && value.bytes().all(|byte| byte.is_ascii_hexdigit()) +} + +/// One addr-spec of printable ASCII without whitespace, control characters, +/// or list/header delimiters. Mirrors the public `recipient` schema pattern. +fn is_bounded_email_address(value: &str) -> bool { + let is_address_byte = + |byte: u8| matches!(byte, b'!'..=b'~') && !matches!(byte, b'@' | b'<' | b'>' | b',' | b';'); + if !(MIN_EMAIL_ADDRESS_CHARS..=MAX_EMAIL_ADDRESS_CHARS).contains(&value.len()) { + return false; + } + value.split_once('@').is_some_and(|(local, domain)| { + (1..=MAX_EMAIL_LOCAL_PART_CHARS).contains(&local.len()) + && (1..=MAX_EMAIL_DOMAIN_CHARS).contains(&domain.len()) + && local.bytes().all(is_address_byte) + && domain.bytes().all(is_address_byte) + }) +} + fn validate_developer_params( method: RpcMethod, params: &Map, @@ -2635,6 +2717,7 @@ mod tests { "browser_tabs", "browser_handoff", "browser_commit", + "browser_credentials", "browser_profile", "browser_developer", ] { @@ -3228,6 +3311,275 @@ mod tests { } } + fn fill_email_params() -> Value { + json!({ + "action": "fill_email", + "tab_id": 4, + "expected_page_revision": 9, + "message_id": "18f2a9c4b7e1d03a", + "recipient": "member+signup@example.test", + "purpose": "signup", + "otp_refs": ["e1"], + }) + } + + #[test] + fn credential_fill_email_accepts_only_non_secret_routing_metadata() { + let (_, single) = + RpcRequest::parse(request("browser_credentials", fill_email_params(), true)).unwrap(); + assert!(matches!( + &single, + MethodParams::Credentials(BrowserCredentialsParams::FillEmail { + tab_id: 4, + expected_page_revision: 9, + message_id, + recipient, + purpose, + otp_refs, + }) if message_id == "18f2a9c4b7e1d03a" + && recipient == "member+signup@example.test" + && purpose == "signup" + && otp_refs == &["e1"] + )); + + let mut split = fill_email_params(); + split["purpose"] = json!("email_sign_in"); + split["message_id"] = json!("ABCDEF0123456789"); + split["otp_refs"] = json!(["e1", "e2", "e3", "e4", "e5", "e6"]); + let (_, split) = RpcRequest::parse(request("browser_credentials", split, true)).unwrap(); + assert!(matches!( + &split, + MethodParams::Credentials(BrowserCredentialsParams::FillEmail { otp_refs, .. }) + if otp_refs.len() == 6 + )); + + let mut maximum = fill_email_params(); + maximum["message_id"] = json!("f".repeat(MAX_EMAIL_MESSAGE_ID_CHARS)); + maximum["recipient"] = json!(format!("{}@{}", "a".repeat(64), "b".repeat(255))); + maximum["otp_refs"] = json!((0..MAX_EMAIL_CODE_REFS) + .map(|index| format!("{index:0>256}")) + .collect::>()); + assert!(RpcRequest::parse(request("browser_credentials", maximum, true)).is_ok()); + + let mut minimum = fill_email_params(); + minimum["recipient"] = json!("a@b"); + minimum["message_id"] = json!("0"); + assert!(RpcRequest::parse(request("browser_credentials", minimum, true)).is_ok()); + } + + #[test] + fn credential_fill_email_rejects_secrets_mixed_fields_and_unbounded_values() { + for (field, value) in [ + ("credential_token", json!("t".repeat(32))), + ("username_ref", json!("e1")), + ("password_ref", json!("e2")), + ("otp_ref", json!("e3")), + ("code", json!("123456")), + ("value", json!("123456")), + ("origin", json!("https://example.test")), + ("query", json!("from:example.test")), + ("submit_ref", json!("e9")), + ] { + let mut params = fill_email_params(); + params[field] = value; + assert!( + matches!( + RpcRequest::parse(request("browser_credentials", params, true)), + Err(ProtocolError::InvalidParams { .. }) + ), + "fill_email accepted forbidden field {field}" + ); + } + for field in [ + "tab_id", + "expected_page_revision", + "message_id", + "recipient", + "purpose", + "otp_refs", + ] { + let mut params = fill_email_params(); + params.as_object_mut().unwrap().remove(field); + assert!( + matches!( + RpcRequest::parse(request("browser_credentials", params, true)), + Err(ProtocolError::InvalidParams { .. }) + ), + "fill_email accepted a request without {field}" + ); + } + let oversized_refs = (0..=MAX_EMAIL_CODE_REFS) + .map(|index| format!("e{index}")) + .collect::>(); + for (field, value) in [ + ("message_id", json!("")), + ("message_id", json!("18f2a9c4b7e1d03g")), + ("message_id", json!("18f2-a9c4")), + ( + "message_id", + json!("f".repeat(MAX_EMAIL_MESSAGE_ID_CHARS + 1)), + ), + ("recipient", json!("ab")), + ("recipient", json!("member.example.test")), + ("recipient", json!("@example.test")), + ("recipient", json!("member@")), + ("recipient", json!("member@one@example.test")), + ( + "recipient", + json!("member@example.test\r\nBcc: other@example.test"), + ), + ("recipient", json!("member@example.test\n")), + ("recipient", json!("member\0@example.test")), + ("recipient", json!("member @example.test")), + ( + "recipient", + json!("member@example.test, other@example.test"), + ), + ("recipient", json!("Member ")), + ("recipient", json!("mémber@example.test")), + ( + "recipient", + json!(format!("{}@example.test", "a".repeat(65))), + ), + ("recipient", json!(format!("a@{}", "b".repeat(256)))), + ("purpose", json!("mfa")), + ("purpose", json!("two_factor")), + ("purpose", json!("")), + ("otp_refs", json!([])), + ("otp_refs", json!(oversized_refs)), + ("otp_refs", json!(["e1", "e1"])), + ("otp_refs", json!(["e1", "e2", "e1"])), + ("otp_refs", json!([""])), + ("otp_refs", json!(["r".repeat(MAX_REF_CHARS + 1)])), + ] { + let mut params = fill_email_params(); + params[field] = value; + assert!( + matches!( + RpcRequest::parse(request("browser_credentials", params, true)), + Err(ProtocolError::InvalidParams { .. } + | ProtocolError::InvalidParamConstraint { .. }) + ), + "fill_email accepted invalid {field}" + ); + } + for (field, value) in [ + ("purpose", json!(["signup"])), + ("otp_refs", json!("e1")), + ("otp_refs", json!([1, 2, 3, 4])), + ("message_id", json!(18)), + ] { + let mut params = fill_email_params(); + params[field] = value; + assert!(matches!( + RpcRequest::parse(request("browser_credentials", params, true)), + Err(ProtocolError::InvalidParams { .. }) + )); + } + } + + #[test] + fn credential_one_password_actions_reject_email_code_fields() { + let (_, fill) = RpcRequest::parse(request( + "browser_credentials", + json!({ + "action": "fill", + "tab_id": 4, + "expected_page_revision": 9, + "credential_token": "t".repeat(32), + "otp_ref": "e3", + }), + true, + )) + .unwrap(); + assert!(matches!( + &fill, + MethodParams::Credentials(BrowserCredentialsParams::Fill { otp_ref: Some(value), .. }) + if value == "e3" + )); + for (action, field, value) in [ + ("prepare", "otp_refs", json!(["e1"])), + ("prepare", "recipient", json!("member@example.test")), + ("fill", "otp_refs", json!(["e1"])), + ("fill", "message_id", json!("18f2a9c4b7e1d03a")), + ("next", "purpose", json!("signup")), + ] { + let mut params = json!({ + "action": action, + "tab_id": 4, + "expected_page_revision": 9, + }); + if action != "prepare" { + params["credential_token"] = json!("t".repeat(32)); + params["password_ref"] = json!("e2"); + } + params[field] = value; + assert!( + matches!( + RpcRequest::parse(request("browser_credentials", params, true)), + Err(ProtocolError::InvalidParams { .. }) + ), + "{action} accepted email-code field {field}" + ); + } + assert!(matches!( + RpcRequest::parse(request("browser_credentials", fill_email_params(), false)), + Err(ProtocolError::MissingIdempotencyKey( + RpcMethod::BrowserCredentials + )) + )); + } + + #[test] + fn credential_fill_email_schema_branch_matches_runtime_bounds() { + let schema: Value = serde_json::from_str( + RPC_SCHEMA_ASSETS + .iter() + .find_map(|(name, schema)| (*name == "browser_credentials").then_some(*schema)) + .unwrap(), + ) + .unwrap(); + let branch = schema.pointer("/$defs/email_code").unwrap(); + assert_eq!(branch["additionalProperties"], json!(false)); + assert_eq!( + branch["required"], + json!([ + "action", + "tab_id", + "expected_page_revision", + "message_id", + "recipient", + "purpose", + "otp_refs" + ]) + ); + let properties = branch["properties"].as_object().unwrap(); + assert_eq!(properties.len(), 7); + assert_eq!(properties["action"], json!({"const": "fill_email"})); + assert_eq!(properties["purpose"]["enum"], json!(EMAIL_CODE_PURPOSES)); + assert_eq!( + properties["message_id"]["maxLength"], + json!(MAX_EMAIL_MESSAGE_ID_CHARS) + ); + assert_eq!( + properties["recipient"]["minLength"], + json!(MIN_EMAIL_ADDRESS_CHARS) + ); + assert_eq!( + properties["recipient"]["maxLength"], + json!(MAX_EMAIL_ADDRESS_CHARS) + ); + assert_eq!( + properties["otp_refs"]["maxItems"], + json!(MAX_EMAIL_CODE_REFS) + ); + assert_eq!(properties["otp_refs"]["uniqueItems"], json!(true)); + assert!(schema["oneOf"] + .as_array() + .unwrap() + .contains(&json!({"$ref": "#/$defs/email_code"}))); + } + #[test] fn screenshot_encoding_constraints_match_the_public_schema() { let (_, valid) = RpcRequest::parse(request( diff --git a/packages/extension/src/background.ts b/packages/extension/src/background.ts index 30e3661..300eed1 100644 --- a/packages/extension/src/background.ts +++ b/packages/extension/src/background.ts @@ -1,6 +1,7 @@ import type { ChallengeEvent } from "./challenge"; import { clearChallengeTask } from "./challenge-store"; import { StandardBrowserRuntime } from "./browser"; +import { sanitizeEmailCodeFailure } from "./email-code-fill"; import { HandoffController, HANDOFF_ALARM } from "./handoff"; import { NativeBridge, RECONNECT_ALARM } from "./native"; import { OwnershipLedger } from "./ownership"; @@ -10,6 +11,7 @@ import { completed, failed, needsUser, + parseEmailCodeFillParams, PROFILE_STAGE_TAB_ID, type NativeDispatchCommand, type NativeOriginPolicy, @@ -436,7 +438,7 @@ async function dispatch(command: NativeDispatchCommand): Promise } } const params = command.params; - const mutating = command.method === "browser_open" || command.method === "browser_act" || command.method === "browser_commit" || command.method === "browser_handoff" || command.method === "browser_developer" || command.method === "browser_credentials_fill" || + const mutating = command.method === "browser_open" || command.method === "browser_act" || command.method === "browser_commit" || command.method === "browser_handoff" || command.method === "browser_developer" || command.method === "browser_credentials_fill" || command.method === "browser_email_code_fill" || (command.method === "browser_profile" && PROFILE_READ_OPERATIONS[String(params.operation)] !== true); try { if (command.method === "commit_review_bind") { @@ -575,6 +577,23 @@ async function dispatch(command: NativeDispatchCommand): Promise }); return completed(command.request_id, result); } + if (command.method === "browser_email_code_fill") { + // The code is a secret: every failure is rebuilt from fixed text, never page or Chrome messages. + let invoked = false; + try { + const request = parseEmailCodeFillParams(params); + const targetTabId = request.tab_id; + const result = await scheduler.enqueueTab(command.task_id, targetTabId, async () => { + await ownership.assertOwned(command.task_id, targetTabId); + await assertCurrentOrigin(targetTabId, command.origin_policy); + invoked = true; + return browser.fillEmailCode(command.task_id, targetTabId, request); + }); + return completed(command.request_id, result); + } catch (error) { + throw sanitizeEmailCodeFailure(error, invoked ? "unknown" : "not_started"); + } + } const targetTabId = tabId(params); if (command.method === "browser_snapshot" || command.method === "browser_wait") { const result = await scheduler.readAfterWrites(targetTabId, async () => { @@ -999,7 +1018,17 @@ chrome.alarms.onAlarm.addListener((alarm: { name: string }) => { }); }); -async function handlePopupMessage(message: Record): Promise> { +/** Trusted fields Chrome sets on a runtime message; never taken from the message body. */ +interface PopupMessageSender { + id?: string; + url?: string; + tab?: unknown; +} + +async function handlePopupMessage( + message: Record, + sender: PopupMessageSender, +): Promise> { if (message.kind === "wake") return { ready: true }; if (message.kind === "runtime_instance") return { runtime_instance: RUNTIME_INSTANCE_ID }; if (message.kind === "automation_revocation_state") { @@ -1093,6 +1122,14 @@ async function handlePopupMessage(message: Record): Promise): Promise) => void, ) => { if (sender.id !== chrome.runtime.id || !isRecord(message) || typeof message.kind !== "string") return undefined; void start() - .then(() => handlePopupMessage(message)) + .then(() => handlePopupMessage(message, sender)) .then( sendResponse, (error: unknown) => sendResponse({ diff --git a/packages/extension/src/browser.ts b/packages/extension/src/browser.ts index db96d87..35ea386 100644 --- a/packages/extension/src/browser.ts +++ b/packages/extension/src/browser.ts @@ -17,6 +17,7 @@ import { SCREENSHOT_MAX_BYTES, SNAPSHOT_TEXT_MAX_BYTES, randomToken, + type EmailCodeFillRequest, sha256Hex, type StagedCommit, type StagedDialog, @@ -56,6 +57,13 @@ import { ACTION_POINTER_CUES, AgentPointer, withoutPointerHost } from "./pointer import { mutateState, readState } from "./storage"; import { sensitiveFieldState } from "./sensitive-field"; import { removeTabs } from "./tab-removal"; +import { + assertEmailCodeFilled, + EMAIL_CODE_FILL_DECLARATION, + emailCodeError, + sanitizeEmailCodeFailure, +} from "./email-code-fill"; +import type { EmailCodePageExpectation } from "./email-code-page"; import { PAGE_AGENT_FILE, PAGE_AGENT_GLOBAL, @@ -80,6 +88,8 @@ const DEBUGGER_IDLE_MS = 30_000; const DEBUGGER_REFUSAL_RETRY_MS = 30_000; // Scripting-route refs always name the tab's top document. const SCRIPTING_FRAME_ID = "scripting:top"; +/** CDP object group holding the email code targets for one fill; released after the page call. */ +const EMAIL_CODE_OBJECT_GROUP = "agenttab-email-code"; /** Overall time a challenge scan may add to one result; an overrun omits `challenge`. */ const CHALLENGE_SCAN_BUDGET_MS = 250; /** Minimum spacing between the starts of two scans inside a `challenge_cleared` wait. */ @@ -1802,6 +1812,135 @@ export class StandardBrowserRuntime { }; } + /** + * Fills a host-resolved email verification code into the approved page's code + * inputs without submitting. The tab, page revision, every ref, the target + * frame, and the page's verification context are checked before the first + * character; the page function then checks the exact origin, path, and every + * target again around each write and takes the code back if any check fails. + * Every failure carries fixed text only. + */ + async fillEmailCode( + taskId: string, + tabId: number, + request: EmailCodeFillRequest, + ): Promise> { + let writeAttempted = false; + try { + await this.authorizeDebuggerUse(tabId); + const pageRevision = await this.revisions.assertExpected(tabId, request.expected_page_revision); + await this.requireFullAutomationRoute(tabId, "fill an email verification code"); + const expectation: EmailCodePageExpectation = { + origin: request.expected_origin, + path: request.expected_path, + recipient: request.recipient, + }; + await this.assertEmailCodePage(tabId, expectation); + if ((await this.guardEpisode(taskId, tabId, pageRevision, { first: true })) !== undefined) { + throw emailCodeError("challenge_active", "not_started"); + } + const targets: ResolvedTarget[] = []; + for (const field of request.fields) { + await this.revisions.assertExpected(tabId, pageRevision); + targets.push(await this.resolveTarget(tabId, { taskId, pageRevision, ref: field.ref })); + } + const [first] = targets; + if ( + targets.some((target) => + target.route !== first.route || target.documentId !== first.documentId || target.frameId !== first.frameId) || + new Set(targets.map((target) => target.backendNodeId)).size !== targets.length + ) { + throw emailCodeError("email_code_target_invalid", "not_started"); + } + // Scripting refs always name the top document; debugger refs may name a child frame. + if (first.route !== "scripting") { + const topFrame = await this.frameIdentity(tabId); + if (first.frameId !== topFrame.frameId) { + const targetFrame = await this.frameIdentity(tabId, first.frameId); + let frameOrigin: string | undefined; + try { + frameOrigin = targetFrame.url === undefined ? undefined : new URL(targetFrame.url).origin; + } catch { + frameOrigin = undefined; + } + if (frameOrigin !== expectation.origin) { + throw emailCodeError("cross_origin_credentials_forbidden", "not_started"); + } + } + } + const values = request.fields.map((field) => field.value); + await this.revisions.assertExpected(tabId, pageRevision); + await this.assertEmailCodePage(tabId, expectation); + if (first.route === "scripting") { + writeAttempted = true; + const { value } = await this.runPageAgent(tabId, { + op: "email_code_fill", + ids: targets.map((target) => target.backendNodeId), + values, + expectation, + }, first.documentId); + assertEmailCodeFilled(value, values.length); + } else { + try { + const objectIds: string[] = []; + for (const target of targets) { + let resolved: Record; + try { + resolved = await this.send(tabId, "DOM.resolveNode", { + backendNodeId: target.backendNodeId, + objectGroup: EMAIL_CODE_OBJECT_GROUP, + ...(target.contextId !== undefined ? { executionContextId: target.contextId } : {}), + }, false, first.sessionId); + } catch { + throw emailCodeError("stale_ref", "not_started"); + } + if (!isRecord(resolved.object) || typeof resolved.object.objectId !== "string") { + throw emailCodeError("stale_ref", "not_started"); + } + objectIds.push(resolved.object.objectId); + } + writeAttempted = true; + const invoked = await this.send(tabId, "Runtime.callFunctionOn", { + objectId: objectIds[0], + functionDeclaration: EMAIL_CODE_FILL_DECLARATION, + arguments: [{ value: expectation }, { value: values }, ...objectIds.map((objectId) => ({ objectId }))], + awaitPromise: false, + returnByValue: true, + userGesture: false, + }, false, first.sessionId); + if (isRecord(invoked.exceptionDetails)) throw emailCodeError("email_code_fill_failed", "unknown"); + assertEmailCodeFilled(isRecord(invoked.result) ? invoked.result.value : undefined, values.length); + } finally { + await this.send(tabId, "Runtime.releaseObjectGroup", { objectGroup: EMAIL_CODE_OBJECT_GROUP }, false, first.sessionId) + .catch(() => undefined); + } + } + return { + tab_id: tabId, + page_revision: await this.revisions.current(tabId), + filled_fields: values.length, + }; + } catch (error) { + const staleBeforeWrite = isRecord(error) && error.code === "stale_ref"; + throw sanitizeEmailCodeFailure(error, writeAttempted && !staleBeforeWrite ? "unknown" : "not_started"); + } + } + + /** The tab, including any navigation in flight, is on the exact approved origin and pathname. */ + private async assertEmailCodePage(tabId: number, expectation: EmailCodePageExpectation): Promise { + const tab = await chrome.tabs.get(tabId); + const rawUrl = tab.pendingUrl ?? tab.url; + let url: URL | null = null; + try { + url = typeof rawUrl === "string" ? new URL(rawUrl) : null; + } catch { + url = null; + } + if (url === null || url.origin !== expectation.origin || url.pathname !== expectation.path) { + throw emailCodeError("email_code_page_mismatch", "not_started"); + } + } + async bindReview(taskId: string, params: Record): Promise> { const nativeToken = params.native_token; const reviewHandle = params.review_handle; diff --git a/packages/extension/src/email-code-fill.ts b/packages/extension/src/email-code-fill.ts new file mode 100644 index 0000000..a86d359 --- /dev/null +++ b/packages/extension/src/email-code-fill.ts @@ -0,0 +1,137 @@ +import { fillEmailCodeInPage, type EmailCodePageRefusal } from "./email-code-page"; +import { NotStartedError } from "./scheduler"; +import { sensitiveFieldState } from "./sensitive-field"; +import { isRecord } from "./type-guards"; + +/** + * Debugger declaration for the email code fill. Called on the first target with + * the expectation, the values, then every target element as remote objects. + */ +export const EMAIL_CODE_FILL_DECLARATION = + `function(expectation,values,...elements){return (${fillEmailCodeInPage.toString()})(elements,values,expectation,${sensitiveFieldState.toString()})}`; + +const EMAIL_CODE_HANDOFF_RECOVERY = + "Do not retry the email code fill on this page. Start browser_handoff for this tab and let the human complete the verification."; +const EMAIL_CODE_SNAPSHOT_RECOVERY = + "Take a fresh browser_snapshot of the approved verification page, then retry with its current refs and page revision."; + +/** + * Fixed text for every failure the email code fill reports. A failure never + * carries the original message, which may come from the page and echo the code. + */ +const EMAIL_CODE_FAILURES: Readonly> = { + invalid_request: { message: "The email code fill request is invalid" }, + stale_revision: { message: "The page changed since the snapshot", recovery: EMAIL_CODE_SNAPSHOT_RECOVERY }, + stale_ref: { message: "A code field ref no longer resolves", recovery: EMAIL_CODE_SNAPSHOT_RECOVERY }, + email_code_page_mismatch: { + message: "The tab is not on the approved email verification page", + recovery: EMAIL_CODE_SNAPSHOT_RECOVERY, + }, + email_code_target_invalid: { + message: "The refs do not name the verification code inputs of one email code form", + recovery: EMAIL_CODE_SNAPSHOT_RECOVERY, + }, + email_code_context_unverified: { + message: "The page does not show an email code verification", + }, + email_code_recipient_unverified: { + message: "The verification page does not identify the exact intended email recipient", + recovery: "Use a verification page that identifies the full recipient or one compatible masked recipient, or let the human complete this step.", + }, + email_code_context_forbidden: { + message: "The page asks for multi-factor, passkey, or human verification", + recovery: EMAIL_CODE_HANDOFF_RECOVERY, + }, + email_code_fill_failed: { + message: "The page did not accept the email verification code", + recovery: EMAIL_CODE_SNAPSHOT_RECOVERY, + }, + challenge_active: { + message: "A human verification challenge is active on this tab", + recovery: EMAIL_CODE_HANDOFF_RECOVERY, + }, + cross_origin_credentials_forbidden: { + message: "Email codes cannot be filled in a frame from another origin", + }, + browser_restricted_origin: { message: "AgentTab cannot fill an email code on a browser-restricted origin" }, + debugger_unavailable: { message: "Chrome refused AgentTab's debugger for this tab" }, + frame_unavailable: { message: "The code field's frame is unavailable" }, + frame_not_found: { message: "The code field's frame is unavailable" }, + debugger_detached: { message: "AgentTab's debugger detached from the tab" }, + extension_disconnected: { message: "The AgentTab extension disconnected" }, + ownership_denied: { message: "Tab is not owned by this AgentTab task" }, + ownership_revoked: { message: "Tab ownership changed before the email code fill" }, + permissions_required: { + message: "AgentTab automation permissions have not been enabled", + recovery: "Open the AgentTab popup and choose Enable automation.", + }, + paused: { message: "AgentTab is paused" }, + origin_denied: { message: "AgentTab policy denies the tab's origin" }, + origin_not_allowed: { message: "AgentTab policy does not allow the tab's origin" }, + origin_unavailable: { message: "AgentTab cannot determine the target tab's current origin" }, + origin_policy_mismatch: { message: "origin_policy.tab_id does not match the command target" }, + scheme_denied: { message: "AgentTab Standard mode does not allow the tab's URL scheme" }, + origin_policy_unverifiable: { message: "AgentTab cannot verify the target tab's origin policy" }, +}; +const EMAIL_CODE_FAILURE_FALLBACK = "AgentTab could not fill the email verification code"; + +const REFUSAL_CODES: Readonly> = { + target_invalid: "email_code_target_invalid", + sensitive_target: "email_code_target_invalid", + otp_mismatch: "email_code_target_invalid", + password_present: "email_code_target_invalid", + url_mismatch: "email_code_page_mismatch", + cross_origin_frame: "cross_origin_credentials_forbidden", + context_unverified: "email_code_context_unverified", + recipient_unverified: "email_code_recipient_unverified", + mfa_context: "email_code_context_forbidden", + human_challenge: "email_code_context_forbidden", + write_failed: "email_code_fill_failed", +} satisfies Record; + +/** A coded email code failure with fixed text; `outcome` is set when it is known. */ +export function emailCodeError(code: string, outcome?: "not_started" | "unknown"): Error { + const known = EMAIL_CODE_FAILURES[code]; + return Object.assign(new Error(known?.message ?? EMAIL_CODE_FAILURE_FALLBACK), { + code, + ...(known?.recovery !== undefined ? { recovery: known.recovery } : {}), + ...(outcome !== undefined ? { outcome } : {}), + }); +} + +/** + * Interprets the page result. A refusal before any character reached the page did + * not start; a failed write was rolled back, but page listeners already ran. + */ +export function assertEmailCodeFilled(value: unknown, expectedFields: number): void { + const verdict = isRecord(value) && isRecord(value.agenttab_email_code) ? value.agenttab_email_code : null; + if (verdict === null) throw emailCodeError("email_code_fill_failed", "unknown"); + if (verdict.ok === true && verdict.filled === expectedFields) return; + const reason = typeof verdict.reason === "string" && Object.hasOwn(REFUSAL_CODES, verdict.reason) + ? REFUSAL_CODES[verdict.reason] + : "email_code_fill_failed"; + throw emailCodeError(reason, verdict.ok === false && verdict.wrote === false ? "not_started" : "unknown"); +} + +/** + * Rebuilds any failure of the email code operation from a known code alone, so no + * page, Chrome, or nested message or unrecognized code can carry the code into a + * response. + */ +export function sanitizeEmailCodeFailure(error: unknown, outcome?: "not_started" | "unknown"): Error { + const rawCode = isRecord(error) ? error.code : undefined; + const code = typeof rawCode === "string" && Object.hasOwn(EMAIL_CODE_FAILURES, rawCode) + ? rawCode + : "email_code_fill_failed"; + const rawOutcome = isRecord(error) ? error.outcome : undefined; + const knownOutcome = rawOutcome === "not_started" || rawOutcome === "unknown" + ? rawOutcome + : error instanceof NotStartedError + ? "not_started" + : outcome; + const sanitized = emailCodeError(code, knownOutcome); + if (isRecord(error) && typeof error.currentPageRevision === "number") { + return Object.assign(sanitized, { currentPageRevision: error.currentPageRevision }); + } + return sanitized; +} diff --git a/packages/extension/src/email-code-page.ts b/packages/extension/src/email-code-page.ts new file mode 100644 index 0000000..e9eb25b --- /dev/null +++ b/packages/extension/src/email-code-page.ts @@ -0,0 +1,405 @@ +/** + * Page side of the brokered email verification code fill. The host resolves the + * code from an approved message and the extension routes it here through either + * the debugger (`Runtime.callFunctionOn`) or the scripting page agent. + * + * Every target and the surrounding document are checked once before any value + * is written. Page listeners run inside each write, so the top location, the + * frame origin, and every target are checked again before each character and + * after the last; a failed check or a write the page does not accept stops the + * fill and takes back every character already written. + * + * The page must name the expected recipient in full, or by exactly one bounded + * ASCII `*` mask that fits it and that no other shown address also fits. + * + * Results carry fixed refusal reasons only. Nothing here reports a value, a + * label, page text, or a page exception message. + * + * Self-contained so it can run in either page route: serialized with + * `toString()` into a debugger declaration, or bundled into the page agent. It + * must not reference module scope. + */ + +export interface EmailCodePageExpectation { + /** Host-derived origin the target document and the top document must have. */ + origin: string; + /** Host-derived exact pathname of the top document. */ + path: string; + /** Mailbox address the page must name as the code's destination. */ + recipient: string; +} + +export type EmailCodePageRefusal = + | "target_invalid" + | "sensitive_target" + | "otp_mismatch" + | "url_mismatch" + | "cross_origin_frame" + | "password_present" + | "context_unverified" + | "recipient_unverified" + | "mfa_context" + | "human_challenge" + | "write_failed"; + +export interface EmailCodePageResult { + agenttab_email_code: + | { ok: true; filled: number } + | { ok: false; reason: EmailCodePageRefusal; wrote: boolean }; +} + +/** Label source shared with the sensitive-field guard (`sensitiveFieldState`). */ +export type EmailCodeLabelSource = (this: Element) => { associated: string[]; accessible: string[] }; + +export function fillEmailCodeInPage( + elements: unknown[], + values: unknown[], + expectation: EmailCodePageExpectation, + labelsOf: EmailCodeLabelSource, +): EmailCodePageResult { + const refuse = (reason: EmailCodePageRefusal, wrote = false): EmailCodePageResult => ({ + agenttab_email_code: { ok: false, reason, wrote }, + }); + try { + if ( + !Array.isArray(elements) || !Array.isArray(values) || elements.length === 0 || elements.length > 12 || + elements.length !== values.length || typeof expectation !== "object" || expectation === null || + typeof expectation.origin !== "string" || typeof expectation.path !== "string" || + typeof expectation.recipient !== "string" || expectation.recipient.length < 3 || + expectation.recipient.length > 320 + ) { + return refuse("target_invalid"); + } + const split = elements.length > 1; + const texts = values.map((value) => (typeof value === "string" ? value : "")); + if ( + split + ? elements.length < 4 || texts.some((text) => !/^[A-Za-z0-9]$/.test(text)) + : !/^[A-Za-z0-9]{4,12}$/.test(texts[0]) + ) { + return refuse("otp_mismatch"); + } + const inputs = elements as HTMLInputElement[]; + if (new Set(inputs).size !== inputs.length) return refuse("target_invalid"); + const first = inputs[0]; + const doc = first && typeof first === "object" ? first.ownerDocument : null; + if (!doc) return refuse("target_invalid"); + + // The top document must be the approved page; the target document must share its origin. + const locationRefusal = (): EmailCodePageRefusal | null => { + let topOrigin: string; + let topPath: string; + let frameOrigin: string; + try { + const view = doc.defaultView; + if (!view || !view.top) return "cross_origin_frame"; + topOrigin = String(view.top.location.origin); + topPath = String(view.top.location.pathname); + frameOrigin = String(doc.location.origin); + } catch { + return "cross_origin_frame"; + } + if (topOrigin !== expectation.origin || topPath !== expectation.path) return "url_mismatch"; + return frameOrigin === expectation.origin ? null : "cross_origin_frame"; + }; + const preflightLocation = locationRefusal(); + if (preflightLocation !== null) return refuse(preflightLocation); + + const rendered = (node: Element): boolean => + typeof node.checkVisibility === "function" + ? node.checkVisibility({ checkVisibilityCSS: true }) + : typeof node.getClientRects === "function" && node.getClientRects().length > 0; + const setterOf = (node: object): ((this: unknown, value: string) => void) | undefined => { + // Skips own-instance overrides, as frameworks track values there and must observe the change. + for (let proto = Object.getPrototypeOf(node); proto; proto = Object.getPrototypeOf(proto)) { + const descriptor = Object.getOwnPropertyDescriptor(proto, "value"); + if (descriptor && typeof descriptor.set === "function") return descriptor.set; + } + return undefined; + }; + + const form = first.form ?? null; + const setters: Array<(this: unknown, value: string) => void> = []; + let firstType = ""; + let firstMaxLength: string | null = null; + for (let index = 0; index < inputs.length; index += 1) { + const input = inputs[index]; + if ( + !input || typeof input !== "object" || String(input.tagName || "").toUpperCase() !== "INPUT" || + input.ownerDocument !== doc || (input.form ?? null) !== form || input.isConnected === false + ) { + return refuse("target_invalid"); + } + const attr = (name: string): string => String(input.getAttribute(name) || "").toLowerCase(); + const type = attr("type") || "text"; + if (type === "password") return refuse("sensitive_target"); + if (type !== "text" && type !== "tel" && type !== "number") return refuse("target_invalid"); + if (input.disabled === true || input.readOnly === true || attr("aria-disabled") === "true" || !rendered(input)) { + return refuse("target_invalid"); + } + const autocomplete = attr("autocomplete").split(/\s+/).filter(Boolean); + if (autocomplete.includes("webauthn")) return refuse("mfa_context"); + if ( + autocomplete.some((token) => + token === "current-password" || token === "new-password" || token === "username" || + token === "email" || token.startsWith("cc-")) + ) { + return refuse("sensitive_target"); + } + const { associated, accessible } = labelsOf.call(input); + const descriptor = [ + input.getAttribute("name"), + input.id, + input.getAttribute("aria-label"), + input.getAttribute("title"), + input.getAttribute("placeholder"), + ...associated, + ...accessible, + ] + .filter(Boolean) + .join(" ") + .replace(/([a-z])([A-Z])/g, "$1 $2") + .toLowerCase() + .replace(/[^a-z0-9]+/g, " ") + .trim(); + const compact = descriptor.replace(/\s+/g, ""); + if ( + /\b(password|passphrase|passcode|pin|user ?name|login id|card|cvv|cvc|cvn|iban|routing|bank|account number|expir\w*|ssn|social security)\b/ + .test(descriptor) || + /password|username|cardnumber|creditcard|securitycode/.test(compact) + ) { + return refuse("sensitive_target"); + } + if ( + /\b(totp|authenticator|2fa|mfa|two ?factor|multi ?factor|2 ?step|two ?step|backup|recovery|sms|phone|passkeys?|security keys?)\b/ + .test(descriptor) + ) { + return refuse("mfa_context"); + } + const codeField = autocomplete.includes("one-time-code") || + /\b(otp|code|one ?time|verification|verify|digit|character|char)\b/.test(descriptor) || + /onetimecode|verificationcode|otp/.test(compact); + if (!codeField) return refuse("otp_mismatch"); + const maxLength = input.getAttribute("maxlength"); + if (maxLength !== null) { + const limit = Number(maxLength); + if (!Number.isInteger(limit) || limit < texts[index].length) return refuse("otp_mismatch"); + } + const inputMode = attr("inputmode"); + if ( + (type === "number" || type === "tel" || inputMode === "numeric" || inputMode === "decimal" || inputMode === "tel") && + !/^[0-9]+$/.test(texts[index]) + ) { + return refuse("otp_mismatch"); + } + if (index === 0) { + firstType = type; + firstMaxLength = maxLength; + } else { + // Split boxes are one widget: same kind of input, in code-character order. + if (type !== firstType || maxLength !== firstMaxLength) return refuse("otp_mismatch"); + const previous = inputs[index - 1]; + if ( + typeof previous.compareDocumentPosition === "function" && + typeof previous.getRootNode === "function" && previous.getRootNode() === input.getRootNode() && + (previous.compareDocumentPosition(input) & 4) === 0 + ) { + return refuse("otp_mismatch"); + } + } + const setter = setterOf(input); + if (!setter) return refuse("target_invalid"); + setters.push(setter); + } + + // The form, or the document when the code boxes have none, must not also collect a password. + const scope: ParentNode = form ?? doc; + if (Array.from(scope.querySelectorAll('input[type="password" i]')).some(rendered)) { + return refuse("password_present"); + } + if (Array.from(doc.querySelectorAll('input[autocomplete~="webauthn" i]')).some(rendered)) { + return refuse("mfa_context"); + } + + const body = doc.body; + const bodyText = body ? String(body.innerText ?? body.textContent ?? "") : ""; + const corpus = `${String(doc.title || "")}\n${bodyText}` + .slice(0, 200_000) + .replace(/\s+/g, " ") + .toLowerCase(); + if ( + /\b(two[- ]?factor|2fa|mfa|multi[- ]?factor|2[- ]?step|two[- ]step|authenticator|authentication app|totp|passkeys?|security keys?|webauthn|backup codes?|recovery codes?|hardware key|yubikey|text message|sms)\b/ + .test(corpus) + ) { + return refuse("mfa_context"); + } + // reCAPTCHA's required disclosure is not a challenge; the challenge guard watches its widget. + const humanCorpus = corpus.replace(/this (?:site|page|form) is protected by recaptcha[^.]*\.?/g, " "); + if ( + /\b(captcha|recaptcha|hcaptcha|not a robot|are you (?:a )?human|verify (?:that )?you(?:'re| are) (?:a )?human|prove you(?:'re| are) (?:a )?human|human verification)\b/ + .test(humanCorpus) + ) { + return refuse("human_challenge"); + } + if (!/\b(e-?mail(?:ed)?|inbox)\b/.test(corpus) || !/\b(code|otp|one[- ]time|verification|verify)\b/.test(corpus)) { + return refuse("context_unverified"); + } + + const recipient = expectation.recipient.toLowerCase(); + const namesRecipient = (text: string): boolean => { + for (let from = 0; ;) { + const at = text.indexOf(recipient, from); + if (at < 0) return false; + const before = at > 0 ? text.charAt(at - 1) : ""; + const after = text.charAt(at + recipient.length); + const following = text.charAt(at + recipient.length + 1); + if ( + !/[a-z0-9._%+\-]/.test(before) && + !/[a-z0-9\-]/.test(after) && + !(after === "." && /[a-z0-9]/.test(following)) + ) { + return true; + } + from = at + 1; + } + }; + const displayed = Array.from(doc.querySelectorAll("input")) + .slice(0, 64) + .filter((node) => { + const type = String(node.getAttribute("type") || "text").toLowerCase(); + return type === "email" || ((type === "text" || type === "hidden") && (node.readOnly === true || node.disabled === true)); + }) + .map((node) => String(node.value || "").toLowerCase()); + // A masked recipient (`a***t@e***m`) counts as evidence only when every ASCII `*` mask + // on the page is the same bounded mask, it fits the expected recipient, and no full + // address it could also stand for is shown. Tokens are cut around each `@` with + // bounded scans and matched without regular expressions built from page text. + const recipientAt = recipient.lastIndexOf("@"); + const recipientLocal = recipient.slice(0, recipientAt); + const recipientDomain = recipient.slice(recipientAt + 1); + const tokensOf = (text: string): string[] | null => { + const found: string[] = []; + let scanned = 0; + for (let at = text.indexOf("@"); at >= 0; at = text.indexOf("@", at + 1)) { + scanned += 1; + if (found.length >= 64 || scanned > 1_024) return null; + let start = at; + while (start > 0 && at - start <= 64 && /^[a-z0-9._%+\-*]$/.test(text.charAt(start - 1))) start -= 1; + let end = at + 1; + while (end < text.length && end - at - 1 <= 255 && /^[a-z0-9.\-*]$/.test(text.charAt(end))) end += 1; + while (end > at + 1 && text.charAt(end - 1) === ".") end -= 1; + if (start < at && end > at + 1) found.push(text.slice(start, end)); + } + return found; + }; + // Each run of `*` hides one or more characters; literal segments match in order. + const globMatch = (pattern: string, text: string): boolean => { + const parts = pattern.split(/\*+/); + if (parts.length === 1) return pattern === text; + const head = parts[0]; + const tail = parts[parts.length - 1]; + if (!text.startsWith(head)) return false; + let position = head.length; + for (let index = 1; index < parts.length - 1; index += 1) { + const found = text.indexOf(parts[index], position + 1); + if (found < 0) return false; + position = found + parts[index].length; + } + return text.length - tail.length >= position + 1 && text.endsWith(tail); + }; + const maskFits = (mask: string, address: string): boolean => { + const maskAt = mask.indexOf("@"); + const addressAt = address.lastIndexOf("@"); + return globMatch(mask.slice(0, maskAt), address.slice(0, addressAt)) && + globMatch(mask.slice(maskAt + 1), address.slice(addressAt + 1)); + }; + const maskEvidence = (): boolean => { + const masks = new Set(); + const addresses = new Set(); + for (const source of [corpus, ...displayed]) { + const tokens = tokensOf(source); + if (tokens === null) return false; + for (const token of tokens) { + if (!token.includes("*")) { + addresses.add(token); + continue; + } + const at = token.indexOf("@"); + const local = token.slice(0, at); + const domain = token.slice(at + 1); + if ( + local.length > 64 || domain.length > 255 || + !/[a-z0-9]/.test(local) || !/[a-z0-9]/.test(domain) || + !globMatch(local, recipientLocal) || !globMatch(domain, recipientDomain) + ) { + return false; + } + masks.add(token); + } + } + if (masks.size !== 1) return false; + const [mask] = Array.from(masks); + for (const address of addresses) { + if (address !== recipient && maskFits(mask, address)) return false; + } + return true; + }; + if (!namesRecipient(corpus) && !displayed.some(namesRecipient) && !maskEvidence()) { + return refuse("recipient_unverified"); + } + + // The target document's own constructor, so main-world and isolated-world listeners both see a native event. + const viewEvent: unknown = doc.defaultView ? Reflect.get(doc.defaultView, "Event") : undefined; + const EventType: typeof Event = typeof viewEvent === "function" ? viewEvent as typeof Event : Event; + const notify = (input: HTMLInputElement, type: string): void => { + input.dispatchEvent(new EventType(type, { bubbles: true, composed: true })); + }; + // Page listeners run synchronously inside every write and can navigate the history, + // retype, move, or disable a box, so the location and every target are checked again + // before each character and after the last one. + const targetIntact = (input: HTMLInputElement): boolean => + String(input.tagName || "").toUpperCase() === "INPUT" && input.ownerDocument === doc && + input.isConnected !== false && (input.form ?? null) === form && + String(input.getAttribute("type") || "text").toLowerCase() === firstType && + input.disabled !== true && input.readOnly !== true; + const writeRefusal = (): EmailCodePageRefusal | null => + locationRefusal() ?? (inputs.every(targetIntact) ? null : "target_invalid"); + const previousValues = inputs.map((input) => String(input.value ?? "")); + let written = 0; + let failure: EmailCodePageRefusal | null = null; + try { + for (let index = 0; index < inputs.length; index += 1) { + failure = writeRefusal(); + if (failure !== null) break; + const input = inputs[index]; + written = index + 1; + setters[index].call(input, texts[index]); + notify(input, "input"); + notify(input, "change"); + } + if (failure === null) failure = writeRefusal(); + if ( + failure === null && + !inputs.every((input, index) => String(input.value ?? "").toLowerCase() === texts[index].toLowerCase()) + ) { + failure = "write_failed"; + } + } catch { + failure = "write_failed"; + } + if (failure === null) return { agenttab_email_code: { ok: true, filled: inputs.length } }; + // Takes the partial code back out of the page, even one that moved away from the approved path. + for (let index = written - 1; index >= 0; index -= 1) { + try { + setters[index].call(inputs[index], previousValues[index]); + notify(inputs[index], "input"); + notify(inputs[index], "change"); + } catch { + // Best effort: the page refused the restore as well. + } + } + return refuse(failure, written > 0); + } catch { + return refuse("target_invalid"); + } +} diff --git a/packages/extension/src/page-agent-protocol.ts b/packages/extension/src/page-agent-protocol.ts index b895bde..63a8faf 100644 --- a/packages/extension/src/page-agent-protocol.ts +++ b/packages/extension/src/page-agent-protocol.ts @@ -1,4 +1,5 @@ import type { AccessibilityQuery } from "./accessibility-query"; +import type { EmailCodePageExpectation } from "./email-code-page"; /** * Contract between the extension worker and the scripting page agent, the @@ -8,7 +9,7 @@ import type { AccessibilityQuery } from "./accessibility-query"; */ /** Bump whenever a request or response shape changes, so a stale agent reinstalls. */ -export const PAGE_AGENT_VERSION = 3; +export const PAGE_AGENT_VERSION = 5; /** Bundled agent script, injected with `chrome.scripting.executeScript({ files })`. */ export const PAGE_AGENT_FILE = "page-agent.js"; @@ -26,6 +27,7 @@ export type PageAgentRequest = | { op: "inspect_selector"; selector: string } | { op: "geometry"; selector?: string } | { op: "fill_credential"; id: number; value: string; kind: string } + | { op: "email_code_fill"; ids: number[]; values: string[]; expectation: EmailCodePageExpectation } | { op: "surface"; id: number; selectors: string[] }; export interface PageAgentError { diff --git a/packages/extension/src/page-agent.ts b/packages/extension/src/page-agent.ts index 286bc8e..6f8d1be 100644 --- a/packages/extension/src/page-agent.ts +++ b/packages/extension/src/page-agent.ts @@ -16,6 +16,7 @@ import { } from "./page-agent-protocol"; import { sensitiveFieldState } from "./sensitive-field"; import { accessibilityMatcher, type AccessibilityQuery } from "./accessibility-query"; +import { fillEmailCodeInPage } from "./email-code-page"; // Work bound so a snapshot of a pathological document stays responsive. const SNAPSHOT_INSPECT_LIMIT = 25_000; @@ -460,6 +461,16 @@ async function run(registry: Registry, request: PageAgentRequest): Promise = { browser_developer: true, "agenttab.close": true, browser_credentials_fill: true, + browser_email_code_fill: true, commit_review_bind: true, commit_review_abandon: true, }; @@ -618,6 +620,95 @@ function assertCredentialFillParams(value: unknown): Record { return params; } +export type EmailCodeFillRequest = { + tab_id: number; + expected_page_revision: number; + expected_origin: string; + expected_path: string; + recipient: string; + purpose: "signup" | "email_sign_in"; + fields: Array<{ kind: "otp"; ref: string; value: string }>; +}; + +const EMAIL_CODE_MAX_FIELDS = 12; +const EMAIL_CODE_LOOPBACK_HOSTS: Readonly> = { localhost: true, "127.0.0.1": true, "[::1]": true }; +// Mirrors the host's bounded addr-spec: printable ASCII without whitespace or list/header delimiters. +const EMAIL_CODE_RECIPIENT_PATTERN = + /^[\x21-\x2b\x2d-\x3a\x3d\x3f\x41-\x7e]{1,64}@[\x21-\x2b\x2d-\x3a\x3d\x3f\x41-\x7e]{1,255}$/; +const EMAIL_CODE_PATH_PATTERN = /^\/[^\s?#\u0000-\u001f\u007f]*$/; + +/** + * Validates the internal email code fill. Values are checked for shape only and + * no failure message names a value: one field takes the whole 4..12 character + * code, or 4..12 fields take one character each. + */ +export function parseEmailCodeFillParams(value: unknown): EmailCodeFillRequest { + const params = assertExactObject( + value, + ["tab_id", "expected_page_revision", "expected_origin", "expected_path", "recipient", "purpose", "fields"], + [], + "browser_email_code_fill parameters", + ); + const tabId = assertTabId(params.tab_id); + const revision = assertRevision(params.expected_page_revision); + const origin = assertBoundedString(params.expected_origin, "expected_origin", 8, 2_048); + let parsedOrigin: URL | null = null; + try { + parsedOrigin = new URL(origin); + } catch { + parsedOrigin = null; + } + if ( + parsedOrigin === null || + parsedOrigin.origin !== origin || + !(parsedOrigin.protocol === "https:" || + (parsedOrigin.protocol === "http:" && EMAIL_CODE_LOOPBACK_HOSTS[parsedOrigin.hostname] === true)) + ) { + commandError("expected_origin must be an HTTPS or loopback HTTP origin"); + } + const path = assertBoundedString(params.expected_path, "expected_path", 1, 2_048); + if (!EMAIL_CODE_PATH_PATTERN.test(path) || new URL(path, origin).pathname !== path) { + commandError("expected_path must be an exact URL pathname"); + } + const recipient = assertBoundedString(params.recipient, "recipient", 3, 320); + if (!EMAIL_CODE_RECIPIENT_PATTERN.test(recipient)) commandError("recipient must be one bounded email address"); + const purpose = params.purpose; + if (purpose !== "signup" && purpose !== "email_sign_in") commandError("purpose must be signup or email_sign_in"); + if (!Array.isArray(params.fields) || params.fields.length === 0 || params.fields.length > EMAIL_CODE_MAX_FIELDS) { + commandError(`email code fields must contain between 1 and ${EMAIL_CODE_MAX_FIELDS} entries`); + } + const refs = new Set(); + const fields: EmailCodeFillRequest["fields"] = []; + for (const field of params.fields) { + const parsed = assertExactObject(field, ["kind", "ref", "value"], [], "email code field"); + if (parsed.kind !== "otp") commandError("email code field kind must be otp"); + const ref = assertBoundedString(parsed.ref, "email code field ref", 1, 256); + if (refs.has(ref)) commandError("email code field refs must be unique"); + refs.add(ref); + if (typeof parsed.value !== "string") commandError("email code field value must be a string"); + fields.push({ kind: "otp", ref, value: parsed.value }); + } + const single = fields.length === 1; + if ( + single + ? !/^[A-Za-z0-9]{4,12}$/.test(fields[0].value) + : fields.length < 4 || fields.some((field) => !/^[A-Za-z0-9]$/.test(field.value)) + ) { + commandError( + "email code fields must be one field with a 4 to 12 character alphanumeric code, or 4 to 12 fields with one alphanumeric character each", + ); + } + return { + tab_id: tabId, + expected_page_revision: revision, + expected_origin: origin, + expected_path: path, + recipient, + purpose, + fields, + }; +} + const BOOKMARK_ID_PATTERN = /^[0-9]{1,20}$/; const BOOKMARK_URL_PATTERN = /^(?:https?|file|chrome):\/\/\S+$/i; @@ -784,6 +875,8 @@ function validateParams(method: NativeMethod, value: unknown): Record): FakeInput { + const input = new FakeInput(this, this.inputs.length, attributes); + this.inputs.push(input); + return input; + } + + querySelectorAll(selector: string): FakeInput[] { + if (selector === "input") return [...this.inputs]; + if (selector === 'input[type="password" i]') { + return this.inputs.filter((input) => input.getAttribute("type")?.toLowerCase() === "password"); + } + if (selector === 'input[autocomplete~="webauthn" i]') { + return this.inputs.filter((input) => + String(input.getAttribute("autocomplete") ?? "").toLowerCase().split(/\s+/).includes("webauthn")); + } + throw new Error(`FakeDocument does not support selector ${selector}`); + } + + getElementById(id: string): FakeInput | null { + return this.inputs.find((input) => input.id === id) ?? null; + } +} + +export class FakeInput extends EventTarget { + readonly tagName = "INPUT"; + readonly attributes: Map; + readonly events: string[] = []; + id: string; + form: object | null = null; + disabled = false; + readOnly = false; + isConnected = true; + visible = true; + labels: Array<{ textContent: string }> = []; + stored = ""; + /** Page-owned setter behavior; defaults to storing the value. */ + pageSetter: (value: string) => string = (value) => value; + + constructor(readonly ownerDocument: FakeDocument, readonly order: number, attributes: Record) { + super(); + this.attributes = new Map(Object.entries(attributes)); + this.id = attributes.id ?? ""; + } + + get value(): string { + return this.stored; + } + + set value(value: string) { + this.stored = this.pageSetter(value); + } + + getAttribute(name: string): string | null { + return this.attributes.get(name) ?? null; + } + + checkVisibility(): boolean { + return this.visible; + } + + getRootNode(): FakeDocument { + return this.ownerDocument; + } + + compareDocumentPosition(other: FakeInput): number { + // DOCUMENT_POSITION_FOLLOWING (4) or DOCUMENT_POSITION_PRECEDING (2). + return other.order > this.order ? 4 : 2; + } + + override dispatchEvent(event: Event): boolean { + this.events.push(event.type); + return super.dispatchEvent(event); + } +} + +/** Atlassian's split signup code widget: six one-character boxes. */ +export function atlassianSplitPage(options: FakePageOptions = {}): { doc: FakeDocument; inputs: FakeInput[] } { + const doc = new FakeDocument(options); + const inputs = Array.from({ length: 6 }, (_unused, index) => + doc.input({ type: "text", maxlength: "1", "aria-label": `Please enter OTP character ${index + 1}` })); + return { doc, inputs }; +} + +/** One `autocomplete="one-time-code"` box taking the whole code. */ +export function singleCodePage(options: FakePageOptions = {}): { doc: FakeDocument; input: FakeInput } { + const doc = new FakeDocument(options); + const input = doc.input({ + type: "text", + maxlength: "6", + autocomplete: "one-time-code", + name: "verificationCode", + "aria-label": "Verification code", + }); + return { doc, input }; +} diff --git a/packages/extension/test/email-code-fill.test.ts b/packages/extension/test/email-code-fill.test.ts new file mode 100644 index 0000000..09a88c8 --- /dev/null +++ b/packages/extension/test/email-code-fill.test.ts @@ -0,0 +1,417 @@ +import { describe, expect, test } from "bun:test"; +import { assertEmailCodeFilled, emailCodeError, sanitizeEmailCodeFailure } from "../src/email-code-fill"; +import { fillEmailCodeInPage, type EmailCodePageExpectation, type EmailCodePageRefusal, type EmailCodePageResult } from "../src/email-code-page"; +import { NATIVE_PROTOCOL, parseCommand, parseEmailCodeFillParams, PROTOCOL_VERSION } from "../src/protocol"; +import { NotStartedError } from "../src/scheduler"; +import { sensitiveFieldState } from "../src/sensitive-field"; +import { ATLASSIAN, atlassianSplitPage, FakeDocument, singleCodePage, type FakeInput } from "./email-code-dom"; + +const ID = "018f47b8-2f80-7c20-9c77-f8a38c9e621e"; +const CODE = "7K3Q9Z"; +const EXPECTATION: EmailCodePageExpectation = { ...ATLASSIAN }; +const MASK = "a***t@e***m"; +const MASKED_TEXT = `To complete your account setup, enter the code we've sent to: ${MASK}`; + +function fill( + elements: unknown[], + values: string[], + expectation: EmailCodePageExpectation = EXPECTATION, +): EmailCodePageResult { + return fillEmailCodeInPage(elements, values, expectation, sensitiveFieldState); +} + +function refusal(reason: EmailCodePageRefusal, wrote = false): EmailCodePageResult { + return { agenttab_email_code: { ok: false, reason, wrote } }; +} + +function untouched(inputs: FakeInput[]): void { + for (const input of inputs) { + expect(input.stored).toBe(""); + expect(input.events).toEqual([]); + } +} + +describe("email code page function", () => { + test("fills Atlassian's six split OTP boxes in order with native events", () => { + const { inputs } = atlassianSplitPage(); + expect(fill(inputs, [...CODE])).toEqual({ agenttab_email_code: { ok: true, filled: 6 } }); + expect(inputs.map((input) => input.stored).join("")).toBe(CODE); + for (const input of inputs) { + expect(input.events).toEqual(["input", "change"]); + } + }); + + test("fills one one-time-code box with the whole code", () => { + const { input } = singleCodePage(); + expect(fill([input], ["482913"])).toEqual({ agenttab_email_code: { ok: true, filled: 1 } }); + expect(input.stored).toBe("482913"); + }); + + test("refuses another page path, a foreign frame, and an unreadable parent before writing", () => { + const moved = atlassianSplitPage({ path: "/login" }); + expect(fill(moved.inputs, [...CODE])).toEqual(refusal("url_mismatch")); + untouched(moved.inputs); + + const framed = atlassianSplitPage({ + origin: "https://evil.example", + top: { origin: ATLASSIAN.origin, pathname: ATLASSIAN.path }, + }); + expect(fill(framed.inputs, [...CODE])).toEqual(refusal("cross_origin_frame")); + untouched(framed.inputs); + + const blocked = atlassianSplitPage({ top: "blocked" }); + expect(fill(blocked.inputs, [...CODE])).toEqual(refusal("cross_origin_frame")); + untouched(blocked.inputs); + }); + + test("refuses password, MFA, and human challenge contexts", () => { + const password = atlassianSplitPage(); + password.doc.input({ type: "password", name: "password" }); + expect(fill(password.inputs, [...CODE])).toEqual(refusal("password_present")); + untouched(password.inputs); + + const mfa = atlassianSplitPage({ text: "Enter the code from your authenticator app sent for agent@example.com email" }); + expect(fill(mfa.inputs, [...CODE])).toEqual(refusal("mfa_context")); + untouched(mfa.inputs); + + const captcha = atlassianSplitPage({ text: "Please verify you are human. We emailed a code to agent@example.com" }); + expect(fill(captcha.inputs, [...CODE])).toEqual(refusal("human_challenge")); + untouched(captcha.inputs); + + const disclosure = atlassianSplitPage({ + text: "We emailed a code to agent@example.com. This site is protected by reCAPTCHA and the Google Privacy Policy applies.", + }); + expect(fill(disclosure.inputs, [...CODE])).toEqual({ agenttab_email_code: { ok: true, filled: 6 } }); + }); + + test("requires email verification wording and the exact recipient", () => { + const noEmail = atlassianSplitPage({ text: "Enter the code we sent to agent@example.com", title: "Confirm" }); + // "agent@example.com" alone is not email wording; the corpus needs email or inbox. + expect(fill(noEmail.inputs, [...CODE])).toEqual(refusal("context_unverified")); + + for (const text of [ + "We emailed a code to someone@example.com", + "We emailed a code to xagent@example.com", + "We emailed a code to agent@example.com.evil.test", + ]) { + const page = atlassianSplitPage({ text }); + expect(fill(page.inputs, [...CODE])).toEqual(refusal("recipient_unverified")); + untouched(page.inputs); + } + + const shown = atlassianSplitPage({ text: "We emailed you a verification code." }); + const echo = shown.doc.input({ type: "email" }); + echo.stored = "Agent@Example.com"; + expect(fill(shown.inputs, [...CODE])).toEqual({ agenttab_email_code: { ok: true, filled: 6 } }); + }); + + test("fills directly when the page names the recipient by one compatible mask", () => { + const page = atlassianSplitPage({ title: "We've emailed you a code", text: `${MASKED_TEXT}. Support: help@atlassian.com.` }); + expect(fill(page.inputs, [...CODE])).toEqual({ agenttab_email_code: { ok: true, filled: 6 } }); + expect(page.inputs.map((input) => input.stored).join("")).toBe(CODE); + + const single = singleCodePage({ title: "Check your email", text: `We emailed a verification code to ${MASK}.` }); + expect(fill([single.input], ["482913"])).toEqual({ agenttab_email_code: { ok: true, filled: 1 } }); + expect(single.input.stored).toBe("482913"); + + // A displayed email field may carry the mask instead of the page text. + const displayed = atlassianSplitPage({ title: "Check your email", text: "Enter the verification code we emailed you." }); + const echo = displayed.doc.input({ type: "email" }); + echo.stored = "A***T@E***M"; + expect(fill(displayed.inputs, [...CODE])).toEqual({ agenttab_email_code: { ok: true, filled: 6 } }); + + // The same mask shown twice is still one mask. + const repeated = atlassianSplitPage({ title: "Check your email", text: `${MASKED_TEXT}. Not ${MASK}? Resend email.` }); + expect(fill(repeated.inputs, [...CODE])).toEqual({ agenttab_email_code: { ok: true, filled: 6 } }); + expect(repeated.inputs.map((input) => input.stored).join("")).toBe(CODE); + + // A full recipient keeps filling even beside a mask that would not count on its own. + const full = atlassianSplitPage({ text: "We emailed a code to agent@example.com, not b***@example.com." }); + expect(fill(full.inputs, [...CODE])).toEqual({ agenttab_email_code: { ok: true, filled: 6 } }); + }); + + test("refuses a missing, incompatible, ambiguous, or contradicted mask without writing", () => { + for (const text of [ + "We've emailed you a verification code to b***t@e***m", + "We've emailed you a verification code to a***t@x***m", + // Without any visible literal, a mask names no one. + "We've emailed you a verification code to ***@***", + // Two different compatible masks are ambiguous. + `We've emailed you a verification code to ${MASK} or ag***@example.com`, + // One incompatible mask contradicts a compatible one. + `We've emailed you a verification code to ${MASK} and b***@example.com`, + // A full address the mask also fits, other than the recipient, contradicts it. + `We've emailed you a verification code to ${MASK} (ant@exam.com)`, + // Unicode masking is not ASCII `*` evidence. + "We've emailed you a verification code to a•••t@e•••m", + `We've emailed you a verification code to ${"a".repeat(70)}***t@e***m`, + "We've emailed you a verification code.", + ]) { + const page = atlassianSplitPage({ title: "Check your email", text }); + expect(fill(page.inputs, [...CODE])).toEqual(refusal("recipient_unverified")); + untouched(page.inputs); + } + }); + + test("never lets a compatible mask override password, MFA, or human challenge guards", () => { + const password = atlassianSplitPage({ title: "Check your email", text: MASKED_TEXT }); + password.doc.input({ type: "password", name: "password" }); + expect(fill(password.inputs, [...CODE])).toEqual(refusal("password_present")); + untouched(password.inputs); + + const mfa = atlassianSplitPage({ title: "Check your email", text: `${MASKED_TEXT}. Or use your authenticator app.` }); + expect(fill(mfa.inputs, [...CODE])).toEqual(refusal("mfa_context")); + untouched(mfa.inputs); + + const captcha = atlassianSplitPage({ title: "Check your email", text: `${MASKED_TEXT}. Please verify you are human.` }); + expect(fill(captcha.inputs, [...CODE])).toEqual(refusal("human_challenge")); + untouched(captcha.inputs); + + const backup = new FakeDocument({ title: "Check your email", text: MASKED_TEXT }); + const box = backup.input({ type: "text", "aria-label": "Backup code" }); + expect(fill([box], ["482913"])).toEqual(refusal("mfa_context")); + expect(box.stored).toBe(""); + + const moved = atlassianSplitPage({ title: "Check your email", text: MASKED_TEXT }); + moved.inputs[2].addEventListener("input", () => { + moved.doc.location.pathname = "/signup/complete"; + }); + expect(fill(moved.inputs, [...CODE])).toEqual(refusal("url_mismatch", true)); + expect(moved.inputs.map((input) => input.stored)).toEqual(["", "", "", "", "", ""]); + }); + + test("refuses targets that are not one ordered code widget before writing", () => { + const disabled = atlassianSplitPage(); + disabled.inputs[3].disabled = true; + expect(fill(disabled.inputs, [...CODE])).toEqual(refusal("target_invalid")); + untouched(disabled.inputs); + + const reversed = atlassianSplitPage(); + expect(fill([...reversed.inputs].reverse(), [...CODE])).toEqual(refusal("otp_mismatch")); + untouched(reversed.inputs); + + const repeated = atlassianSplitPage(); + expect(fill([...repeated.inputs.slice(0, 5), repeated.inputs[0]], [...CODE])).toEqual(refusal("target_invalid")); + + const short = atlassianSplitPage(); + expect(fill(short.inputs.slice(0, 3), [..."7K3"])).toEqual(refusal("otp_mismatch")); + + const numeric = singleCodePage(); + numeric.input.attributes.set("inputmode", "numeric"); + expect(fill([numeric.input], [CODE])).toEqual(refusal("otp_mismatch")); + untouched([numeric.input]); + + const tooLong = singleCodePage(); + expect(fill([tooLong.input], ["12345678"])).toEqual(refusal("otp_mismatch")); + + const username = new FakeDocument(); + const login = username.input({ type: "text", name: "username", "aria-label": "Username" }); + expect(fill([login], ["482913"])).toEqual(refusal("sensitive_target")); + + const recovery = new FakeDocument(); + const backup = recovery.input({ type: "text", "aria-label": "Backup code" }); + expect(fill([backup], ["482913"])).toEqual(refusal("mfa_context")); + }); + + test("rolls back every written box when the page rejects a character", () => { + const { inputs } = atlassianSplitPage(); + for (const input of inputs) input.stored = "x"; + // The page drops the third character but accepts the restore. + inputs[2].pageSetter = (value) => (value === CODE[2] ? "" : value); + expect(fill(inputs, [...CODE])).toEqual(refusal("write_failed", true)); + expect(inputs.map((input) => input.stored)).toEqual(["x", "x", "x", "x", "x", "x"]); + }); + + test("stops and takes the code back when a listener moves the page or a box mid-fill", () => { + const moved = atlassianSplitPage(); + moved.inputs[1].addEventListener("input", () => { + moved.doc.location.pathname = "/signup/complete"; + }); + expect(fill(moved.inputs, [...CODE])).toEqual(refusal("url_mismatch", true)); + expect(moved.inputs.map((input) => input.stored)).toEqual(["", "", "", "", "", ""]); + + const retyped = atlassianSplitPage(); + retyped.inputs[2].addEventListener("change", () => { + retyped.inputs[3].attributes.set("type", "password"); + }); + expect(fill(retyped.inputs, [...CODE])).toEqual(refusal("target_invalid", true)); + expect(retyped.inputs.map((input) => input.stored)).toEqual(["", "", "", "", "", ""]); + + const detached = atlassianSplitPage(); + detached.inputs[0].addEventListener("input", () => { + detached.inputs[4].isConnected = false; + }); + expect(fill(detached.inputs, [...CODE])).toEqual(refusal("target_invalid", true)); + expect(detached.inputs.map((input) => input.stored)).toEqual(["", "", "", "", "", ""]); + + const navigatedLast = atlassianSplitPage(); + navigatedLast.inputs[5].addEventListener("change", () => { + navigatedLast.doc.location.pathname = "/signup/welcome"; + }); + expect(fill(navigatedLast.inputs, [...CODE])).toEqual(refusal("url_mismatch", true)); + expect(navigatedLast.inputs.map((input) => input.stored)).toEqual(["", "", "", "", "", ""]); + }); + + test("never reports a value a throwing page setter echoes", () => { + const { inputs } = atlassianSplitPage(); + inputs[4].pageSetter = (value) => { + throw new Error(`Rejected character ${value} of ${CODE}`); + }; + const result = fill(inputs, [...CODE]); + expect(result).toEqual(refusal("write_failed", true)); + expect(JSON.stringify(result)).not.toContain(CODE[4]); + expect(inputs.slice(0, 4).map((input) => input.stored)).toEqual(["", "", "", ""]); + }); +}); + +describe("email code result and failure text", () => { + test("maps page refusals to fixed coded failures with known outcomes", () => { + const failure = (value: unknown, fields = 6): unknown => { + try { + assertEmailCodeFilled(value, fields); + } catch (error) { + return error; + } + return undefined; + }; + expect(failure({ agenttab_email_code: { ok: true, filled: 6 } })).toBeUndefined(); + expect(failure({ agenttab_email_code: { ok: true, filled: 5 } })).toMatchObject({ + code: "email_code_fill_failed", + outcome: "unknown", + }); + expect(failure(refusal("url_mismatch"))).toMatchObject({ code: "email_code_page_mismatch", outcome: "not_started" }); + expect(failure(refusal("cross_origin_frame"))).toMatchObject({ + code: "cross_origin_credentials_forbidden", + outcome: "not_started", + }); + expect(failure(refusal("mfa_context"))).toMatchObject({ code: "email_code_context_forbidden", outcome: "not_started" }); + expect(failure(refusal("recipient_unverified"))).toMatchObject({ + code: "email_code_recipient_unverified", + outcome: "not_started", + }); + expect(failure(refusal("context_unverified"))).toMatchObject({ + code: "email_code_context_unverified", + outcome: "not_started", + }); + expect(failure(refusal("write_failed", true))).toMatchObject({ code: "email_code_fill_failed", outcome: "unknown" }); + expect(failure({ result: CODE })).toMatchObject({ code: "email_code_fill_failed", outcome: "unknown" }); + }); + + test("rebuilds failures from known codes only", () => { + const echoed = sanitizeEmailCodeFailure( + Object.assign(new Error(`Uncaught: invalid code ${CODE}`), { code: "action_failed", recovery: CODE, cause: CODE }), + "unknown", + ); + expect(echoed).toMatchObject({ code: "email_code_fill_failed", outcome: "unknown" }); + expect(JSON.stringify({ ...echoed, message: echoed.message })).not.toContain(CODE); + + expect(sanitizeEmailCodeFailure(Object.assign(new Error(CODE), { code: "qx7k2m" }))).toMatchObject({ + code: "email_code_fill_failed", + }); + + const stale = sanitizeEmailCodeFailure( + Object.assign(new Error(`stale ${CODE}`), { code: "stale_revision", currentPageRevision: 9 }), + "not_started", + ); + expect(stale).toMatchObject({ code: "stale_revision", currentPageRevision: 9, outcome: "not_started" }); + expect(stale.message).not.toContain(CODE); + + expect(sanitizeEmailCodeFailure(new NotStartedError("paused", `paused ${CODE}`), "unknown")).toMatchObject({ + code: "paused", + outcome: "not_started", + message: "AgentTab is paused", + }); + expect(sanitizeEmailCodeFailure(emailCodeError("stale_ref", "not_started"), "unknown")).toMatchObject({ + code: "stale_ref", + outcome: "not_started", + }); + }); +}); + +describe("browser_email_code_fill parameters", () => { + const valid = { + tab_id: 7, + expected_page_revision: 3, + expected_origin: ATLASSIAN.origin, + expected_path: ATLASSIAN.path, + recipient: ATLASSIAN.recipient, + purpose: "signup", + fields: [...CODE].map((value, index) => ({ kind: "otp", ref: `rt1.code-${index}`, value })), + }; + + function command(params: unknown): unknown { + return { + protocol: NATIVE_PROTOCOL, + version: PROTOCOL_VERSION, + kind: "command", + request_id: ID, + connection_id: ID, + task_id: ID, + method: "browser_email_code_fill", + params, + }; + } + + test("accepts split and single code fields", () => { + expect(() => parseCommand(command(valid))).not.toThrow(); + expect(parseEmailCodeFillParams({ + ...valid, + purpose: "email_sign_in", + expected_origin: "http://127.0.0.1:8080", + expected_path: "/verify", + fields: [{ kind: "otp", ref: "rt1.one", value: "482913" }], + })).toMatchObject({ purpose: "email_sign_in", fields: [{ kind: "otp", ref: "rt1.one", value: "482913" }] }); + }); + + test("rejects malformed requests without naming the code", () => { + for (const params of [ + { ...valid, extra: true }, + { ...valid, purpose: "password_reset" }, + { ...valid, expected_origin: "http://id.atlassian.com" }, + { ...valid, expected_origin: "https://id.atlassian.com/" }, + { ...valid, expected_path: "signup" }, + { ...valid, expected_path: "/signup?step=otp" }, + { ...valid, recipient: "agent@example.com, other@example.com" }, + { ...valid, recipient: "@example.com" }, + { ...valid, fields: [] }, + { ...valid, fields: valid.fields.slice(0, 3) }, + { ...valid, fields: valid.fields.map((field) => ({ ...field, kind: "password" })) }, + { ...valid, fields: valid.fields.map((field) => ({ ...field, ref: "rt1.same" })) }, + { ...valid, fields: valid.fields.map((field, index) => (index === 0 ? { ...field, value: "7K" } : field)) }, + { ...valid, fields: [{ kind: "otp", ref: "rt1.one", value: "12 34 56" }] }, + { ...valid, fields: [{ kind: "otp", ref: "rt1.one", value: "123" }] }, + ]) { + let message = ""; + try { + parseEmailCodeFillParams(params); + } catch (error) { + message = error instanceof Error ? error.message : String(error); + } + expect(message).not.toBe(""); + expect(message).not.toContain(CODE); + expect(message).not.toContain("12 34 56"); + } + }); + + test("keeps browser_credentials_fill at three unique kinds", () => { + const credentials = (fields: unknown[]): unknown => ({ + ...(command({ tab_id: 7, expected_page_revision: 3, fields }) as Record), + method: "browser_credentials_fill", + }); + expect(() => parseCommand(credentials([ + { kind: "username", ref: "a", value: "u" }, + { kind: "password", ref: "b", value: "p" }, + { kind: "otp", ref: "c", value: "1" }, + ]))).not.toThrow(); + expect(() => parseCommand(credentials([ + { kind: "otp", ref: "a", value: "1" }, + { kind: "otp", ref: "b", value: "2" }, + ]))).toThrow("unique"); + expect(() => parseCommand(credentials([ + { kind: "username", ref: "a", value: "u" }, + { kind: "password", ref: "b", value: "p" }, + { kind: "otp", ref: "c", value: "1" }, + { kind: "otp", ref: "d", value: "2" }, + ]))).toThrow("between 1 and 3"); + }); +}); diff --git a/packages/extension/test/extension.test.ts b/packages/extension/test/extension.test.ts index b408112..35dad2a 100644 --- a/packages/extension/test/extension.test.ts +++ b/packages/extension/test/extension.test.ts @@ -5,7 +5,12 @@ import { NativeBridge, RECONNECT_ALARM } from "../src/native"; import { BoundedChain } from "../src/bounded"; import { OWNERSHIP_OPERATION_HOLD_MS, OwnershipLedger } from "../src/ownership"; import { TAB_REMOVAL_WAIT_MS } from "../src/tab-removal"; -import { parseCommand, parseInboundNativeMessage, type NativeOriginPolicy } from "../src/protocol"; +import { + parseCommand, + parseInboundNativeMessage, + type EmailCodeFillRequest, + type NativeOriginPolicy, +} from "../src/protocol"; import { RevisionTracker } from "../src/revisions"; import { automationRoute, normalizeRestrictedOriginError } from "../src/routes"; import { MutationScheduler } from "../src/scheduler"; @@ -13,6 +18,8 @@ import { IdempotentStartup, STARTUP_OPERATION_TIMEOUT_MS, StartupOperationQueue import { mutateState, readState, resetStateForTest, STATE_KEY } from "../src/storage"; import { isRecord } from "../src/type-guards"; import { targetRefs } from "../src/target-resolution"; +import { fillEmailCodeInPage } from "../src/email-code-page"; +import { ATLASSIAN, atlassianSplitPage, type FakeDocument, type FakeInput } from "./email-code-dom"; import { PAGE_AGENT_GLOBAL, PAGE_AGENT_VERSION, @@ -124,9 +131,10 @@ interface DebuggerCommand { params: Record; } +type PopupMessageSender = { id?: string; url?: string; tab?: unknown }; type PopupMessageListener = ( message: unknown, - sender: { id?: string }, + sender: PopupMessageSender, sendResponse: (response: Record) => void, ) => boolean | undefined; type PermissionRemovedListener = (permissions: { permissions?: string[] }) => void; @@ -143,6 +151,8 @@ type TabGroupRemovedListener = (group: { id?: number }) => void; type AlarmListener = (alarm: { name: string }) => void; const EXTENSION_ID = "agenttab-test-extension"; +/** The AgentTab popup page as Chrome reports it in `sender.url`. */ +const POPUP_SENDER: PopupMessageSender = { id: EXTENSION_ID, url: `chrome-extension://${EXTENSION_ID}/popup.html` }; let debuggerCommands: DebuggerCommand[]; let automationPermission: boolean; @@ -733,6 +743,7 @@ function installChromeMock(): void { runtime: { id: EXTENSION_ID, getManifest() { return { version: "2.0.0" }; }, + getURL(path: string) { return `chrome-extension://${EXTENSION_ID}/${path}`; }, connectNative() { if (!nativePort) throw new Error("native host unavailable"); return nativePort; @@ -853,12 +864,12 @@ async function sendNativeCloseTask( return response as Record; } -async function sendPopupMessage(message: unknown): Promise { +async function sendPopupMessage(message: unknown, sender: PopupMessageSender = POPUP_SENDER): Promise { const listener = popupMessageListeners.at(-1); if (!listener) throw new Error("popup message listener is unavailable"); return new Promise((resolve) => { let settled = false; - const keepChannelOpen = listener(message, { id: EXTENSION_ID }, (response) => { + const keepChannelOpen = listener(message, sender, (response) => { settled = true; resolve(response); }); @@ -6057,6 +6068,32 @@ describe("extension entrypoint admission boundaries", () => { tab_id: 100, }], }); + // Only the popup page itself decides a review: never a tab, another extension page, or another extension. + const eventsBeforeForgedDecisions = port.posted.length; + for (const sender of [ + { id: EXTENSION_ID, url: POPUP_SENDER.url, tab: { id: 100 } }, + { id: EXTENSION_ID, url: `chrome-extension://${EXTENSION_ID}/wake.html` }, + { id: EXTENSION_ID, url: `chrome-extension://${EXTENSION_ID}/popup.html?forged=1` }, + { id: EXTENSION_ID, url: "https://example.test/workspace", tab: { id: 100 } }, + { id: EXTENSION_ID }, + ]) { + for (const kind of ["approve_popup_commit", "abandon_popup_commit"]) { + expect(await sendPopupMessage({ kind, review_handle: reviewHandle }, sender)).toEqual({ + error: "Commit reviews can only be decided in the AgentTab popup", + }); + } + } + expect( + popupListener( + { kind: "approve_popup_commit", review_handle: reviewHandle }, + { id: "other-extension", url: "chrome-extension://other-extension/popup.html" }, + () => undefined, + ), + ).toBeUndefined(); + await flushPromiseQueue(); + expect(port.posted.slice(eventsBeforeForgedDecisions)).toEqual([]); + expect((await readState()).stagedCommits[stagedToken]).toMatchObject({ review_handle: reviewHandle }); + expect((await readState()).stagedCommits[stagedToken]?.approved).not.toBe(true); const approval = sendPopupMessage({ kind: "approve_popup_commit", review_handle: reviewHandle, @@ -6868,3 +6905,258 @@ describe("context clicks", () => { expect(expired.rightClick()).toBe(false); }); }); + +describe("email code fill runtime", () => { + const CODE = "7K3Q9Z"; + const ATLASSIAN_URL = `${ATLASSIAN.origin}${ATLASSIAN.path}`; + + function request(tabId: number, pageRevision: number, refs: string[]): EmailCodeFillRequest { + return { + tab_id: tabId, + expected_page_revision: pageRevision, + expected_origin: ATLASSIAN.origin, + expected_path: ATLASSIAN.path, + recipient: ATLASSIAN.recipient, + purpose: "signup", + fields: refs.map((ref, index) => ({ kind: "otp", ref, value: CODE.charAt(index) })), + }; + } + + /** Runs the debugger declaration against the fake inputs the way Chrome would. */ + function emulateDebuggerPage(inputs: FakeInput[], firstBackendNodeId: number): void { + debuggerCommandOverride = (method, params) => { + if (method !== "Runtime.callFunctionOn" || !String(params.functionDeclaration).includes("agenttab_email_code")) { + return undefined; + } + const elementFor = (objectId: unknown): FakeInput => { + const element = inputs[Number(String(objectId).replace("node-", "")) - firstBackendNodeId]; + if (element === undefined) throw new Error("unknown remote object"); + return element; + }; + const declaration: unknown = Function(`return (${String(params.functionDeclaration)})`)(); + if (typeof declaration !== "function") throw new Error("email code declaration is not a function"); + const args = (Array.isArray(params.arguments) ? params.arguments : []).map((argument: unknown) => { + if (!isRecord(argument)) return undefined; + return "objectId" in argument ? elementFor(argument.objectId) : argument.value; + }); + return { result: { value: Reflect.apply(declaration, elementFor(params.objectId), args) } }; + }; + } + + test("fills Atlassian's split code boxes through the debugger without submitting", async () => { + tabStore.set(70, { id: 70, windowId: 1, groupId: -1, active: true, url: ATLASSIAN_URL }); + const { inputs } = atlassianSplitPage(); + emulateDebuggerPage(inputs, 31); + const revisions = new RevisionTracker(); + const runtime = new StandardBrowserRuntime(revisions, async () => undefined, () => undefined, async () => undefined); + const revision = await revisions.ensure(70); + const refs = inputs.map((_input, index) => mintedRef(TASK_A, 70, revision, 31 + index)); + + const result = await runtime.fillEmailCode(TASK_A, 70, request(70, revision, refs)); + + expect(result).toEqual({ tab_id: 70, page_revision: revision, filled_fields: 6 }); + expect(inputs.map((input) => input.stored).join("")).toBe(CODE); + const calls = debuggerCommands.filter((command) => + command.method === "Runtime.callFunctionOn" && + String(command.params.functionDeclaration).includes("agenttab_email_code")); + expect(calls).toHaveLength(1); + expect(calls[0]?.params).toMatchObject({ objectId: "node-31", returnByValue: true, userGesture: false }); + expect(debuggerCommands.filter((command) => command.method === "DOM.resolveNode" && + command.params.objectGroup === "agenttab-email-code")).toHaveLength(6); + expect(debuggerCommands).toContainEqual({ + method: "Runtime.releaseObjectGroup", + params: { objectGroup: "agenttab-email-code" }, + }); + }); + + test("refuses a redirected tab and a stale ref before touching the page", async () => { + const { inputs } = atlassianSplitPage(); + emulateDebuggerPage(inputs, 31); + const revisions = new RevisionTracker(); + const runtime = new StandardBrowserRuntime(revisions, async () => undefined, () => undefined, async () => undefined); + + tabStore.set(71, { id: 71, windowId: 1, groupId: -1, active: true, url: `${ATLASSIAN.origin}/login` }); + const redirectedRevision = await revisions.ensure(71); + const redirectedRefs = inputs.map((_input, index) => mintedRef(TASK_A, 71, redirectedRevision, 31 + index)); + await expect(runtime.fillEmailCode(TASK_A, 71, request(71, redirectedRevision, redirectedRefs))) + .rejects.toMatchObject({ code: "email_code_page_mismatch", outcome: "not_started" }); + + tabStore.set(72, { id: 72, windowId: 1, groupId: -1, active: true, url: ATLASSIAN_URL }); + const revision = await revisions.ensure(72); + const staleRefs = inputs.map((_input, index) => mintedRef(TASK_A, 72, revision + 1, 31 + index)); + await expect(runtime.fillEmailCode(TASK_A, 72, request(72, revision, staleRefs))) + .rejects.toMatchObject({ code: "stale_ref", outcome: "not_started", message: "A code field ref no longer resolves" }); + + expect(debuggerCommands.some((command) => + String(command.params.functionDeclaration ?? "").includes("agenttab_email_code"))).toBe(false); + untouchedInputs(inputs); + }); + + test("replaces a page exception that echoes the code with fixed text", async () => { + tabStore.set(73, { id: 73, windowId: 1, groupId: -1, active: true, url: ATLASSIAN_URL }); + debuggerCommandOverride = (method, params) => + method === "Runtime.callFunctionOn" && String(params.functionDeclaration).includes("agenttab_email_code") + ? { exceptionDetails: { text: `Uncaught Error: rejected ${CODE}`, exception: { description: CODE } } } + : undefined; + const revisions = new RevisionTracker(); + const runtime = new StandardBrowserRuntime(revisions, async () => undefined, () => undefined, async () => undefined); + const revision = await revisions.ensure(73); + const refs = [...CODE].map((_character, index) => mintedRef(TASK_A, 73, revision, 31 + index)); + + const failure: unknown = await runtime.fillEmailCode(TASK_A, 73, request(73, revision, refs)).catch((error) => error); + + expect(failure).toMatchObject({ code: "email_code_fill_failed", outcome: "unknown" }); + expect(failure instanceof Error ? failure.message : "").not.toContain(CODE); + expect(JSON.stringify(failure)).not.toContain(CODE); + }); + + test("fills through the page agent when Chrome refuses the debugger", async () => { + debuggerAttachError = "Cannot access a chrome-extension:// URL of different extension"; + tabStore.set(74, { id: 74, windowId: 1, groupId: -1, active: true, url: ATLASSIAN_URL }); + const { inputs } = atlassianSplitPage(); + pageAgentReply = (agentRequest) => { + if (agentRequest.op !== "email_code_fill") return {}; + return fillEmailCodeInPage( + agentRequest.ids.map((id) => inputs[id - 1]), + agentRequest.values, + agentRequest.expectation, + sensitiveFieldState, + ); + }; + const revisions = new RevisionTracker(); + const runtime = new StandardBrowserRuntime(revisions, async () => undefined, () => undefined, async () => undefined); + const revision = await revisions.ensure(74); + const refs = inputs.map((_input, index) => targetRefs.mint({ + backendNodeId: index + 1, + documentId: PAGE_AGENT_DOCUMENT, + frameId: "scripting:top", + pageRevision: revision, + taskId: TASK_A, + tabId: 74, + route: "scripting", + })); + + const result = await runtime.fillEmailCode(TASK_A, 74, request(74, revision, refs)); + + expect(result).toEqual({ tab_id: 74, page_revision: revision, filled_fields: 6 }); + expect(inputs.map((input) => input.stored).join("")).toBe(CODE); + expect(pageAgentRequests.filter((agentRequest) => agentRequest.op === "email_code_fill")).toEqual([{ + op: "email_code_fill", + ids: [1, 2, 3, 4, 5, 6], + values: [...CODE], + expectation: { ...ATLASSIAN }, + }]); + expect(debuggerCalls).not.toContain("Runtime.callFunctionOn"); + }); + + /** A verification page that names its recipient only by a compatible mask. */ + const MASKED_TEXT = "To complete your account setup, enter the code we've sent to: a***t@e***m"; + + test("fills a compatible masked recipient directly through the debugger", async () => { + tabStore.set(75, { id: 75, windowId: 1, groupId: -1, active: true, url: ATLASSIAN_URL }); + const { inputs } = atlassianSplitPage({ title: "We've emailed you a code", text: MASKED_TEXT }); + emulateDebuggerPage(inputs, 31); + const events: string[] = []; + const revisions = new RevisionTracker(); + const runtime = new StandardBrowserRuntime( + revisions, + async () => undefined, + (event: string) => { + events.push(event); + }, + async () => undefined, + ); + const revision = await revisions.ensure(75); + const refs = inputs.map((_input, index) => mintedRef(TASK_A, 75, revision, 31 + index)); + + const result = await runtime.fillEmailCode(TASK_A, 75, request(75, revision, refs)); + + expect(result).toEqual({ tab_id: 75, page_revision: revision, filled_fields: 6 }); + expect(inputs.map((input) => input.stored).join("")).toBe(CODE); + const calls = debuggerCommands.filter((command) => + command.method === "Runtime.callFunctionOn" && + String(command.params.functionDeclaration).includes("agenttab_email_code")); + expect(calls).toHaveLength(1); + // The page receives only the exact scope: no approval, mask, or stage rides along. + expect(Array.isArray(calls[0]?.params.arguments) ? calls[0]?.params.arguments[0] : undefined) + .toEqual({ value: { ...ATLASSIAN } }); + expect((await readState()).stagedCommits).toEqual({}); + expect(JSON.stringify(await readState())).not.toContain(CODE); + expect(events).toEqual([]); + }); + + test("fills a compatible masked recipient directly through the page agent", async () => { + debuggerAttachError = "Cannot access a chrome-extension:// URL of different extension"; + tabStore.set(76, { id: 76, windowId: 1, groupId: -1, active: true, url: ATLASSIAN_URL }); + const { inputs } = atlassianSplitPage({ title: "We've emailed you a code", text: MASKED_TEXT }); + pageAgentReply = (agentRequest) => { + if (agentRequest.op !== "email_code_fill") return {}; + return fillEmailCodeInPage( + agentRequest.ids.map((id) => inputs[id - 1]), + agentRequest.values, + agentRequest.expectation, + sensitiveFieldState, + ); + }; + const revisions = new RevisionTracker(); + const runtime = new StandardBrowserRuntime(revisions, async () => undefined, () => undefined, async () => undefined); + const revision = await revisions.ensure(76); + const refs = inputs.map((_input, index) => targetRefs.mint({ + backendNodeId: index + 1, + documentId: PAGE_AGENT_DOCUMENT, + frameId: "scripting:top", + pageRevision: revision, + taskId: TASK_A, + tabId: 76, + route: "scripting", + })); + + const result = await runtime.fillEmailCode(TASK_A, 76, request(76, revision, refs)); + + expect(result).toEqual({ tab_id: 76, page_revision: revision, filled_fields: 6 }); + expect(inputs.map((input) => input.stored).join("")).toBe(CODE); + expect(pageAgentRequests.filter((agentRequest) => agentRequest.op.startsWith("email_code"))).toEqual([{ + op: "email_code_fill", + ids: [1, 2, 3, 4, 5, 6], + values: [...CODE], + expectation: { ...ATLASSIAN }, + }]); + expect(debuggerCalls).not.toContain("Runtime.callFunctionOn"); + }); + + test("refuses an incompatible or changed mask and keeps every page guard without writing", async () => { + const cases: Array<[string, (page: { doc: FakeDocument; inputs: FakeInput[] }) => void, string]> = [ + ["incompatible", (page) => { page.doc.body.innerText = "We've emailed you a verification code to b***t@e***m"; }, "email_code_recipient_unverified"], + ["changed", (page) => { page.doc.body.innerText = "Enter the code we've emailed to: ag***@example.com or a***t@e***m"; }, "email_code_recipient_unverified"], + ["contradicted", (page) => { page.doc.body.innerText = `${MASKED_TEXT} (ant@exam.com)`; }, "email_code_recipient_unverified"], + ["absent", (page) => { page.doc.body.innerText = "Enter the verification code we emailed you."; }, "email_code_recipient_unverified"], + ["password", (page) => { page.doc.input({ type: "password", name: "password" }); }, "email_code_target_invalid"], + ["mfa", (page) => { page.doc.body.innerText = `${MASKED_TEXT}. Or use your authenticator app.`; }, "email_code_context_forbidden"], + ["human", (page) => { page.doc.body.innerText = `${MASKED_TEXT}. Please verify you are human.`; }, "email_code_context_forbidden"], + ]; + let tabId = 80; + for (const [_label, mutate, code] of cases) { + tabId += 1; + tabStore.set(tabId, { id: tabId, windowId: 1, groupId: -1, active: true, url: ATLASSIAN_URL }); + const page = atlassianSplitPage({ title: "Check your email", text: MASKED_TEXT }); + mutate(page); + emulateDebuggerPage(page.inputs, 31); + const revisions = new RevisionTracker(); + const runtime = new StandardBrowserRuntime(revisions, async () => undefined, () => undefined, async () => undefined); + const revision = await revisions.ensure(tabId); + const refs = page.inputs.map((_input, index) => mintedRef(TASK_A, tabId, revision, 31 + index)); + const failure: unknown = await runtime.fillEmailCode(TASK_A, tabId, request(tabId, revision, refs)) + .catch((error) => error); + expect(failure).toMatchObject({ code, outcome: "not_started" }); + expect(JSON.stringify(failure)).not.toContain(CODE); + untouchedInputs(page.inputs); + } + }); + + function untouchedInputs(inputs: FakeInput[]): void { + for (const input of inputs) { + expect(input.stored).toBe(""); + expect(input.events).toEqual([]); + } + } +}); diff --git a/packages/extension/test/profile.test.ts b/packages/extension/test/profile.test.ts index f96914f..33c7f3e 100644 --- a/packages/extension/test/profile.test.ts +++ b/packages/extension/test/profile.test.ts @@ -506,6 +506,7 @@ function installChrome(): void { runtime: { id: EXTENSION_ID, getManifest() { return { version: "2.0.0" }; }, + getURL(path: string) { return `chrome-extension://${EXTENSION_ID}/${path}`; }, connectNative() { if (!nativePort) throw new Error("native host unavailable"); return nativePort; @@ -1147,7 +1148,8 @@ describe("browser_profile through the extension entrypoint", () => { const listener = listeners.get("runtime.onMessage")?.at(-1); if (!listener) throw new Error("popup listener is unavailable"); const { promise, resolve } = Promise.withResolvers(); - listener(message, { id: EXTENSION_ID }, resolve); + // The popup page's sender: this extension, its popup URL, and no tab. + listener(message, { id: EXTENSION_ID, url: `chrome-extension://${EXTENSION_ID}/popup.html` }, resolve); return promise; } diff --git a/packages/mcp/src/server.ts b/packages/mcp/src/server.ts index 7eaa2c9..340f9d8 100644 --- a/packages/mcp/src/server.ts +++ b/packages/mcp/src/server.ts @@ -189,7 +189,7 @@ export const STANDARD_TOOLS: readonly Tool[] = [ }, { name: "browser_credentials", - description: "Prefer this before human handoff: use an origin-matching 1Password Login without exposing values; more than three matches require the user.", + description: "Prefer this before human handoff. 1Password: prepare, then fill or next with an origin-matching Login without exposing values; more than three matches require the user. Email verification: use fill_email for a policy-approved signup or email sign-in message, never MFA. A compatible masked recipient is accepted when it fits the exact granted recipient and no conflicting address is shown. Use browser_act for explicit submission; you may complete verification on entering the final character.", inputSchema: schema(credentialsSchema), }, { @@ -199,7 +199,7 @@ export const STANDARD_TOOLS: readonly Tool[] = [ }, { name: "browser_commit", - description: "Execute one previously staged consequential action after semantic review.", + description: "Execute one staged action after human popup approval, including bookmark removal.", inputSchema: schema(commitSchema), }, { diff --git a/packages/mcp/test/server.test.ts b/packages/mcp/test/server.test.ts index 5761a2c..514b69e 100644 --- a/packages/mcp/test/server.test.ts +++ b/packages/mcp/test/server.test.ts @@ -5,6 +5,10 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { AgentTabTransportError, + EMAIL_CODE_MAX_REFS, + EMAIL_CODE_MESSAGE_ID_PATTERN, + EMAIL_CODE_PURPOSES, + EMAIL_CODE_RECIPIENT_PATTERN, FrameDecoder, encodeFrame, type AgentTabClient, @@ -75,6 +79,50 @@ describe("AgentTab MCP surface", () => { expect(JSON.stringify(credentialTool.inputSchema)).not.toContain("submit_ref"); }); + test("browser_credentials advertises fill_email as a strict non-secret branch", () => { + const credentialTool = STANDARD_TOOLS.find((tool) => tool.name === "browser_credentials")!; + const definitions = credentialTool.inputSchema.$defs as Record; + expect(credentialTool.inputSchema.oneOf).toContainEqual({ $ref: "#/$defs/email_code" }); + const branch = definitions.email_code; + expect(branch.additionalProperties).toBe(false); + expect(Object.keys(branch.properties).sort()).toEqual( + ["action", "expected_page_revision", "message_id", "otp_refs", "purpose", "recipient", "tab_id"], + ); + expect([...branch.required].sort()).toEqual(Object.keys(branch.properties).sort()); + expect(branch.properties.action).toEqual({ const: "fill_email" }); + expect(branch.properties.purpose.enum).toEqual([...EMAIL_CODE_PURPOSES]); + expect(branch.properties.otp_refs).toMatchObject({ minItems: 1, maxItems: EMAIL_CODE_MAX_REFS, uniqueItems: true }); + expect(branch.properties.message_id.pattern).toBe(EMAIL_CODE_MESSAGE_ID_PATTERN); + expect(branch.properties.recipient.pattern).toBe(EMAIL_CODE_RECIPIENT_PATTERN); + expect(definitions.target.properties.action.enum).toEqual(["fill", "next"]); + expect(definitions.target.properties.otp_refs).toBeUndefined(); + + // JSON Schema validators compile `pattern` as ECMA-262 regular expressions. + const recipient = new RegExp(branch.properties.recipient.pattern, "u"); + for (const value of ["a@b", "member+signup@example.test", `${"a".repeat(64)}@${"b".repeat(255)}`]) { + expect({ value, valid: recipient.test(value) }).toEqual({ value, valid: true }); + } + for (const value of [ + "member.example.test", + "@example.test", + "member@", + "member@one@example.test", + "member@example.test\r\nBcc: other@example.test", + "member\u0000@example.test", + "member @example.test", + "member@example.test, other@example.test", + "Member ", + "mémber@example.test", + `${"a".repeat(65)}@example.test`, + ]) { + expect({ value, valid: recipient.test(value) }).toEqual({ value, valid: false }); + } + const messageId = new RegExp(branch.properties.message_id.pattern, "u"); + expect(messageId.test("18f2a9c4b7e1d03A")).toBe(true); + expect(messageId.test("18f2a9c4b7e1d03g")).toBe(false); + expect(messageId.test("f".repeat(129))).toBe(false); + }); + test("browser_profile advertises the profile params schema with its definitions", async () => { const profileTool = STANDARD_TOOLS.find((tool) => tool.name === "browser_profile")!; expect(profileTool.inputSchema.$schema).toBeUndefined(); diff --git a/packages/omp/src/index.ts b/packages/omp/src/index.ts index affd196..9d063b7 100644 --- a/packages/omp/src/index.ts +++ b/packages/omp/src/index.ts @@ -5,6 +5,12 @@ import { AgentTabClient, AgentTabError, AgentTabTransportError, + EMAIL_CODE_MAX_REFS, + EMAIL_CODE_MESSAGE_ID_PATTERN, + EMAIL_CODE_PURPOSES, + EMAIL_CODE_RECIPIENT_MAX_CHARS, + EMAIL_CODE_RECIPIENT_MIN_CHARS, + EMAIL_CODE_RECIPIENT_PATTERN, SCREENSHOT_MAX_BYTES, SCREENSHOT_MAX_DIMENSION, CHALLENGE_GUIDANCE, @@ -312,17 +318,31 @@ const DEFINITIONS: ReadonlyArray<{ { name: "browser_credentials", label: "Browser Credentials", - description: "Prefer this before human handoff: use a URL-matching 1Password Login without exposing its values. Prepare first; more than three matches require the user. Fill only, then submit through Browser Act.", + description: "Prefer this before human handoff. 1Password: prepare, then fill or next with a URL-matching Login without exposing its values; more than three matches require the user. Email verification: use fill_email for a policy-approved signup or email sign-in message, never MFA. A compatible masked recipient is accepted when it fits the exact granted recipient and no conflicting address is shown. Use Browser Act for explicit submission; you may complete verification on entering the final character.", approval: "write", - // One flat object covers every action; Core enforces credential_token for fill and next. + // One flat object covers every action: OpenAI-compatible providers reject a root union. + // Core enforces each action's exact fields: credential_token for fill and next, and only + // message_id, recipient, purpose, and unique otp_refs for fill_email. schema: (z) => z.object({ - action: z.enum(["prepare", "fill", "next"]), + action: z.enum(["prepare", "fill", "next", "fill_email"]), tab_id: z.number().int().min(0), expected_page_revision: z.number().int().min(0), credential_token: z.string().min(32).max(256).optional(), username_ref: z.string().min(1).max(256).optional(), password_ref: z.string().min(1).max(256).optional(), otp_ref: z.string().min(1).max(256).optional(), + message_id: z.string().regex(new RegExp(EMAIL_CODE_MESSAGE_ID_PATTERN)).optional(), + recipient: z.string() + .min(EMAIL_CODE_RECIPIENT_MIN_CHARS) + .max(EMAIL_CODE_RECIPIENT_MAX_CHARS) + .regex(new RegExp(EMAIL_CODE_RECIPIENT_PATTERN)) + .optional(), + purpose: z.enum(EMAIL_CODE_PURPOSES).optional(), + otp_refs: z.array(z.string().min(1).max(256)) + .min(1) + .max(EMAIL_CODE_MAX_REFS) + .refine((values) => new Set(values).size === values.length, "otp_refs must be unique") + .optional(), }).strict(), }, { @@ -374,7 +394,7 @@ const DEFINITIONS: ReadonlyArray<{ { name: "browser_commit", label: "Browser Commit", - description: "Execute one previously staged consequential action after semantic review.", + description: "Execute one staged action after human popup approval, including bookmark removal.", approval: "write", schema: (z) => z.object({ staged_token: z.string().min(32).max(256) }).strict(), }, diff --git a/packages/omp/src/pi-schema.ts b/packages/omp/src/pi-schema.ts index e4b1518..55990b5 100644 --- a/packages/omp/src/pi-schema.ts +++ b/packages/omp/src/pi-schema.ts @@ -1,6 +1,12 @@ import { Type } from "typebox"; import { ACT_INTENT_MAX_CHARS, + EMAIL_CODE_MAX_REFS, + EMAIL_CODE_MESSAGE_ID_PATTERN, + EMAIL_CODE_PURPOSES, + EMAIL_CODE_RECIPIENT_MAX_CHARS, + EMAIL_CODE_RECIPIENT_MIN_CHARS, + EMAIL_CODE_RECIPIENT_PATTERN, SCREENSHOT_MAX_BYTES, SCREENSHOT_MAX_DIMENSION, SNAPSHOT_TEXT_MAX_BYTES, @@ -126,6 +132,19 @@ const schemas: Record = { password_ref: Type.Optional(ref()), otp_ref: Type.Optional(ref()), }), + object({ + action: Type.Literal("fill_email"), + tab_id: Type.Integer({ minimum: 0 }), + expected_page_revision: Type.Integer({ minimum: 0 }), + message_id: Type.String({ minLength: 1, maxLength: 128, pattern: EMAIL_CODE_MESSAGE_ID_PATTERN }), + recipient: Type.String({ + minLength: EMAIL_CODE_RECIPIENT_MIN_CHARS, + maxLength: EMAIL_CODE_RECIPIENT_MAX_CHARS, + pattern: EMAIL_CODE_RECIPIENT_PATTERN, + }), + purpose: stringEnum(EMAIL_CODE_PURPOSES), + otp_refs: Type.Array(ref(), { minItems: 1, maxItems: EMAIL_CODE_MAX_REFS, uniqueItems: true }), + }), ]), browser_profile: Type.Union([ object({ diff --git a/packages/omp/src/render.ts b/packages/omp/src/render.ts index f558fcd..a6a5015 100644 --- a/packages/omp/src/render.ts +++ b/packages/omp/src/render.ts @@ -269,6 +269,9 @@ function describeCall(method: ToolMethod, args: Record): { titl } case "browser_credentials": { const action = fieldString(args, "action") ?? "prepare"; + if (action === "fill_email") { + return { title: "Fill email verification code", meta: "code stays hidden" }; + } return { title: action === "prepare" ? "Prepare 1Password credentials" @@ -519,7 +522,7 @@ function summarizeResult( const count = fieldNumber(result, "candidate_count") ?? 0; return `${count} matching login${count === 1 ? "" : "s"} ready`; } - if (credentialStatus === "filled") return "Credentials filled"; + if (credentialStatus === "filled") return result.filled_email_code === true ? "Email code filled" : "Credentials filled"; return credentialStatus ? humanize(credentialStatus) : "Credential action completed"; } case "browser_commit": diff --git a/packages/omp/test/extension.test.ts b/packages/omp/test/extension.test.ts index f3bfe5f..c5a62c9 100644 --- a/packages/omp/test/extension.test.ts +++ b/packages/omp/test/extension.test.ts @@ -892,6 +892,68 @@ test("Pi browser_profile schema accepts each operation and rejects unknown field expect(Value.Check(schema, { operation: "bookmarks_wipe" })).toBe(false); }); +const FILL_EMAIL_PARAMS = { + action: "fill_email", + tab_id: 4, + expected_page_revision: 9, + message_id: "18f2a9c4b7e1d03a", + recipient: "member+signup@example.test", + purpose: "signup", + otp_refs: ["e1"], +} as const; + +test("OMP browser_credentials stays one strict flat object that admits fill_email", () => { + const credentials = register(false).tools.find((tool) => tool.name === "browser_credentials"); + expect(credentials).toMatchObject({ approval: "write", parameters: { kind: "object" } }); + const state = stubState(credentials?.parameters); + expect(state.strict).toBe(true); + for (const action of ["prepare", "fill", "next", "fill_email"]) expect(state.vocabulary.values).toContain(action); + for (const field of Object.keys(FILL_EMAIL_PARAMS)) expect(state.vocabulary.names).toContain(field); + expect(state.vocabulary.values).toContain("email_sign_in"); + expect(state.vocabulary.limits.get("otp_refs")).toBe(12); + expect(state.vocabulary.unique).toContain("otp_refs"); + expect(state.vocabulary.names).not.toContain("code"); + expect(state.vocabulary.names).not.toContain("query"); + expect(state.vocabulary.names).not.toContain("origin"); +}); + +test("Pi browser_credentials schema accepts fill_email routing metadata and rejects secrets or mixed fields", () => { + // piSchema returns the TypeBox schema typed as unknown so the Pi registration map stays uniform. + const schema = piSchema("browser_credentials") as Parameters[0]; + const accepted: ReadonlyArray> = [ + FILL_EMAIL_PARAMS, + { ...FILL_EMAIL_PARAMS, purpose: "email_sign_in", otp_refs: ["e1", "e2", "e3", "e4", "e5", "e6"] }, + { ...FILL_EMAIL_PARAMS, message_id: "f".repeat(128), recipient: `${"a".repeat(64)}@${"b".repeat(255)}` }, + { action: "prepare", tab_id: 4, expected_page_revision: 9 }, + { action: "fill", tab_id: 4, expected_page_revision: 9, credential_token: "t".repeat(32), otp_ref: "e3" }, + ]; + for (const params of accepted) expect({ params, valid: Value.Check(schema, params) }).toEqual({ params, valid: true }); + const rejected: ReadonlyArray> = [ + { ...FILL_EMAIL_PARAMS, credential_token: "t".repeat(32) }, + { ...FILL_EMAIL_PARAMS, password_ref: "e2" }, + { ...FILL_EMAIL_PARAMS, username_ref: "e2" }, + { ...FILL_EMAIL_PARAMS, otp_ref: "e2" }, + { ...FILL_EMAIL_PARAMS, code: "123456" }, + { ...FILL_EMAIL_PARAMS, origin: "https://example.test" }, + { ...FILL_EMAIL_PARAMS, query: "from:example.test" }, + { ...FILL_EMAIL_PARAMS, purpose: "mfa" }, + { ...FILL_EMAIL_PARAMS, message_id: "not-hex" }, + { ...FILL_EMAIL_PARAMS, message_id: "f".repeat(129) }, + { ...FILL_EMAIL_PARAMS, recipient: "member@example.test\r\nBcc: other@example.test" }, + { ...FILL_EMAIL_PARAMS, recipient: "member@one@example.test" }, + { ...FILL_EMAIL_PARAMS, recipient: "Member " }, + { ...FILL_EMAIL_PARAMS, otp_refs: [] }, + { ...FILL_EMAIL_PARAMS, otp_refs: ["e1", "e1"] }, + { ...FILL_EMAIL_PARAMS, otp_refs: Array.from({ length: 13 }, (_, index) => `e${index}`) }, + { ...FILL_EMAIL_PARAMS, otp_refs: [""] }, + { action: "fill", tab_id: 4, expected_page_revision: 9, credential_token: "t".repeat(32), otp_refs: ["e1"] }, + { action: "prepare", tab_id: 4, expected_page_revision: 9, recipient: "member@example.test" }, + ]; + for (const params of rejected) expect({ params, valid: Value.Check(schema, params) }).toEqual({ params, valid: false }); + const { message_id: _messageId, ...withoutMessage } = FILL_EMAIL_PARAMS; + expect(Value.Check(schema, withoutMessage)).toBe(false); +}); + test("OMP sends an invocation idempotency key for browser_profile writes only", async () => { const tools: Array> = []; const requests: Array<{ operation: unknown; key: unknown }> = []; diff --git a/packages/sdk-python/agenttab/client.py b/packages/sdk-python/agenttab/client.py index 4b2a987..0583367 100644 --- a/packages/sdk-python/agenttab/client.py +++ b/packages/sdk-python/agenttab/client.py @@ -23,6 +23,8 @@ DEFAULT_BROWSER_WAIT_TIMEOUT = 30.0 DEFAULT_BROWSER_HANDOFF_TIMEOUT = 300.0 DEFAULT_BROWSER_CREDENTIALS_TIMEOUT = 120.0 +# Maximum email broker budget plus one native fill. +_BROWSER_EMAIL_CODE_TIMEOUT = 120.0 + 30.0 # Core reserves five seconds after a long operation's declared timeout. Keep a # second, bounded five-second margin for its response to cross the transports. LONG_OPERATION_TRANSPORT_GRACE = 10.0 @@ -227,6 +229,8 @@ def resolve_transport_timeout( default_timeout = DEFAULT_BROWSER_WAIT_TIMEOUT elif method == "browser_handoff" and params.get("operation") == "request": default_timeout = DEFAULT_BROWSER_HANDOFF_TIMEOUT + elif method == "browser_credentials" and params.get("action") == "fill_email": + default_timeout = _BROWSER_EMAIL_CODE_TIMEOUT elif method == "browser_credentials": default_timeout = DEFAULT_BROWSER_CREDENTIALS_TIMEOUT else: diff --git a/packages/sdk-python/tests/test_client.py b/packages/sdk-python/tests/test_client.py index 05ab11a..128a8ca 100644 --- a/packages/sdk-python/tests/test_client.py +++ b/packages/sdk-python/tests/test_client.py @@ -178,6 +178,25 @@ def test_long_operation_transport_deadlines_follow_protocol_timeouts(self) -> No ), DEFAULT_BROWSER_CREDENTIALS_TIMEOUT + LONG_OPERATION_TRANSPORT_GRACE, ) + self.assertEqual( + resolve_transport_timeout( + "browser_credentials", + { + "action": "fill_email", + "tab_id": 1, + "expected_page_revision": 1, + "message_id": "abc123", + "recipient": "user@service.example", + "purpose": "signup", + "otp_refs": ["e1@1"], + }, + ), + 150 + LONG_OPERATION_TRANSPORT_GRACE, + ) + self.assertEqual( + resolve_transport_timeout("browser_commit", {"staged_token": "s" * 32}), + 30.0, + ) self.assertEqual(resolve_transport_timeout("browser_tabs", {}, 45), 45) self.assertEqual( resolve_transport_timeout( @@ -188,7 +207,7 @@ def test_long_operation_transport_deadlines_follow_protocol_timeouts(self) -> No 45, ) - def test_requested_browser_wait_outlives_shorter_generic_client_timeout(self) -> None: + def test_broker_operations_and_waits_outlive_shorter_generic_client_timeout(self) -> None: class DelayedResponseStream(io.RawIOBase): def __init__(self) -> None: super().__init__() @@ -197,6 +216,7 @@ def __init__(self) -> None: def write(self, payload: bytes | bytearray) -> int: request = read_frame(io.BytesIO(payload)) + self.delayed = False self.response = io.BytesIO( encode_frame( { @@ -232,6 +252,21 @@ def flush(self) -> None: ), {"matched": True}, ) + self.assertEqual( + client.call( + "browser_credentials", + { + "action": "fill_email", + "tab_id": 1, + "expected_page_revision": 1, + "message_id": "abc123", + "recipient": "user@service.example", + "purpose": "signup", + "otp_refs": ["e1@1"], + }, + ), + {"matched": True}, + ) client.close() def test_finish_task_preserves_deferred_connection_and_closes_when_finished(self) -> None: diff --git a/packages/sdk-typescript/src/index.ts b/packages/sdk-typescript/src/index.ts index 76829b2..135e7cc 100644 --- a/packages/sdk-typescript/src/index.ts +++ b/packages/sdk-typescript/src/index.ts @@ -20,6 +20,8 @@ export const DEFAULT_REQUEST_TIMEOUT_MS = 30_000; export const DEFAULT_BROWSER_WAIT_TIMEOUT_MS = 30_000; export const DEFAULT_BROWSER_HANDOFF_TIMEOUT_MS = 300_000; export const DEFAULT_BROWSER_CREDENTIALS_TIMEOUT_MS = 120_000; +// Maximum email broker budget plus one native fill. +const DEFAULT_BROWSER_EMAIL_CODE_TIMEOUT_MS = 120_000 + 30_000; // Core gives long-running extension operations five seconds to return after their // declared timeout. Keep a second, bounded five-second margin for the response to // cross the native and Core transports before the client classifies it as unknown. @@ -27,6 +29,14 @@ export const LONG_OPERATION_TRANSPORT_GRACE_MS = 10_000; export const SNAPSHOT_TEXT_MAX_BYTES = 1_000_000; export const SCREENSHOT_MAX_BYTES = 750_000; export const SCREENSHOT_MAX_DIMENSION = 16_384; +// browser_credentials fill_email bounds; mirror schemas/rpc/v1/browser-credentials.schema.json. +export const EMAIL_CODE_PURPOSES = ["signup", "email_sign_in"] as const; +export const EMAIL_CODE_MESSAGE_ID_PATTERN = "^[0-9A-Fa-f]{1,128}$"; +export const EMAIL_CODE_RECIPIENT_MIN_CHARS = 3; +export const EMAIL_CODE_RECIPIENT_MAX_CHARS = 320; +// One printable-ASCII addr-spec without whitespace, controls, or `@<>,;` inside either part. +export const EMAIL_CODE_RECIPIENT_PATTERN = "^[!-+\\--:=?A-~]{1,64}@[!-+\\--:=?A-~]{1,255}$"; +export const EMAIL_CODE_MAX_REFS = 12; export const STANDARD_PRESS_KEYS = [ "ArrowUp", @@ -211,8 +221,19 @@ export type BrowserCredentialsParams = username_ref?: string; password_ref?: string; otp_ref?: string; + } + | { + action: "fill_email"; + tab_id: number; + expected_page_revision: number; + message_id: string; + recipient: string; + purpose: BrowserEmailCodePurpose; + otp_refs: string[]; }; +export type BrowserEmailCodePurpose = (typeof EMAIL_CODE_PURPOSES)[number]; + export interface BrowserCommitParams { staged_token: string; } @@ -718,6 +739,10 @@ function longOperationTimeoutMs( && params.operation === "request" ) { defaultTimeoutMs = DEFAULT_BROWSER_HANDOFF_TIMEOUT_MS; + } else if ( + method === "browser_credentials" && "action" in params && params.action === "fill_email" + ) { + defaultTimeoutMs = DEFAULT_BROWSER_EMAIL_CODE_TIMEOUT_MS; } else if (method === "browser_credentials") { defaultTimeoutMs = DEFAULT_BROWSER_CREDENTIALS_TIMEOUT_MS; } else { diff --git a/packages/sdk-typescript/test/client.test.ts b/packages/sdk-typescript/test/client.test.ts index 8e6869c..28aa8cb 100644 --- a/packages/sdk-typescript/test/client.test.ts +++ b/packages/sdk-typescript/test/client.test.ts @@ -127,6 +127,22 @@ describe("Core RPC transport deadlines", () => { "browser_credentials", { action: "prepare", tab_id: 1, expected_page_revision: 1 }, )).toBe(DEFAULT_BROWSER_CREDENTIALS_TIMEOUT_MS + LONG_OPERATION_TRANSPORT_GRACE_MS); + expect(resolveTransportTimeoutMs( + "browser_credentials", + { + action: "fill_email", + tab_id: 1, + expected_page_revision: 1, + message_id: "abc123", + recipient: "user@service.example", + purpose: "signup", + otp_refs: ["e1@1"], + }, + )).toBe(150_000 + LONG_OPERATION_TRANSPORT_GRACE_MS); + expect(resolveTransportTimeoutMs( + "browser_commit", + { staged_token: "s".repeat(32) }, + )).toBe(30_000); expect(resolveTransportTimeoutMs("browser_tabs", {}, 45_000)).toBe(45_000); expect(resolveTransportTimeoutMs( "browser_wait", @@ -135,7 +151,7 @@ describe("Core RPC transport deadlines", () => { )).toBe(45_000); }); - test("lets a requested browser_wait outlive a shorter generic client timeout", async () => { + test("lets broker operations and requested waits outlive a shorter generic client timeout", async () => { const endpoint = await listen((socket) => { const decoder = new FrameDecoder(1024 * 1024); socket.on("data", (chunk) => { @@ -169,6 +185,15 @@ describe("Core RPC transport deadlines", () => { condition: { kind: "load" }, timeout_ms: 1, })).resolves.toEqual({ matched: true }); + await expect(client.call("browser_credentials", { + action: "fill_email", + tab_id: 1, + expected_page_revision: 1, + message_id: "abc123", + recipient: "user@service.example", + purpose: "signup", + otp_refs: ["e1@1"], + })).resolves.toEqual({ matched: true }); client.close(); }); }); diff --git a/schemas/rpc/v1/browser-credentials.schema.json b/schemas/rpc/v1/browser-credentials.schema.json index ef644dc..6540838 100644 --- a/schemas/rpc/v1/browser-credentials.schema.json +++ b/schemas/rpc/v1/browser-credentials.schema.json @@ -22,6 +22,40 @@ { "required": ["otp_ref"] } ], "additionalProperties": false + }, + "email_code": { + "description": "Fill a brokered email verification code from one managed mailbox message. Carries no credential, code value, origin, or mailbox query; the host derives the origin and path from the task tab. A compatible masked recipient is accepted when it fits the exact granted recipient and no conflicting address is shown.", + "type": "object", + "required": ["action", "tab_id", "expected_page_revision", "message_id", "recipient", "purpose", "otp_refs"], + "properties": { + "action": { "const": "fill_email" }, + "tab_id": { "type": "integer", "minimum": 0 }, + "expected_page_revision": { "type": "integer", "minimum": 0 }, + "message_id": { + "description": "Gmail message ID of the verification email.", + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^[0-9A-Fa-f]{1,128}$" + }, + "recipient": { + "description": "Exact email address the verification code was sent to.", + "type": "string", + "minLength": 3, + "maxLength": 320, + "pattern": "^[!-+\\--:=?A-~]{1,64}@[!-+\\--:=?A-~]{1,255}$" + }, + "purpose": { "enum": ["signup", "email_sign_in"] }, + "otp_refs": { + "description": "Code input refs in code-character order: one ref for a single code input, or one ref per code character.", + "type": "array", + "minItems": 1, + "maxItems": 12, + "uniqueItems": true, + "items": { "$ref": "#/$defs/ref" } + } + }, + "additionalProperties": false } }, "oneOf": [ @@ -35,6 +69,7 @@ }, "additionalProperties": false }, - { "$ref": "#/$defs/target" } + { "$ref": "#/$defs/target" }, + { "$ref": "#/$defs/email_code" } ] } From 241ad42f394dd6caa526ae73b84fb7fd2579d32e Mon Sep 17 00:00:00 2001 From: Wolfgang Schoenberger <221313372+wolfiesch@users.noreply.github.com> Date: Fri, 30 Oct 2026 17:40:03 -0700 Subject: [PATCH 2/2] Use a platform absolute gws path in email code test fixtures --- host-rs/crates/agenttab-host/src/email_codes.rs | 10 ++++++++-- host-rs/crates/agenttab-host/src/guardrails.rs | 2 +- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/host-rs/crates/agenttab-host/src/email_codes.rs b/host-rs/crates/agenttab-host/src/email_codes.rs index 9f37683..2af2c92 100644 --- a/host-rs/crates/agenttab-host/src/email_codes.rs +++ b/host-rs/crates/agenttab-host/src/email_codes.rs @@ -1589,6 +1589,12 @@ pub(crate) mod tests { const CODE_PATTERN: &str = r"(?i:code)(?: is)?:?\s+([A-Z0-9]{6})\b"; const AUTH_PASS_SERVICE: &str = "mx.google.com; dkim=pass header.i=@service.example header.s=s1 header.b=AbCdEf; spf=pass (google.com: domain of bounce@service.example designates 192.0.2.1 as permitted sender; extra) smtp.mailfrom=bounce@service.example; dmarc=pass (p=REJECT sp=REJECT dis=NONE) header.from=service.example"; const AUTH_PASS_RELAY: &str = "mx.google.com; dkim=pass header.i=@relay.example header.s=s1 header.b=XyZ; spf=pass smtp.mailfrom=bounce@relay.example; dmarc=pass (p=QUARANTINE) header.from=relay.example"; + /// Absolute on every platform so policy validation accepts it on Windows too. + pub(crate) const FIXTURE_GWS: &str = if cfg!(windows) { + r"C:\fixture\bin\gws.exe" + } else { + "/opt/fixture/bin/gws" + }; fn fixed_now() -> u64 { NOW_MS @@ -1636,7 +1642,7 @@ pub(crate) mod tests { pub(crate) fn policy(grants: Vec) -> EmailCodePolicy { EmailCodePolicy { enabled: true, - executable: Some(PathBuf::from("/opt/fixture/bin/gws")), + executable: Some(PathBuf::from(FIXTURE_GWS)), mailbox: Some(MAILBOX.into()), grants, ..EmailCodePolicy::default() @@ -2644,7 +2650,7 @@ pub(crate) mod tests { MAILBOX, RELAY_RECIPIENT, RELAY_SENDER, - "/opt/fixture", + FIXTURE_GWS, CODE_PATTERN, ] { assert!(!rendered.contains(private), "{rendered}"); diff --git a/host-rs/crates/agenttab-host/src/guardrails.rs b/host-rs/crates/agenttab-host/src/guardrails.rs index 89f6c34..dd1ac2b 100644 --- a/host-rs/crates/agenttab-host/src/guardrails.rs +++ b/host-rs/crates/agenttab-host/src/guardrails.rs @@ -774,7 +774,7 @@ mod tests { fn email_code_policy_json(enabled: bool) -> Value { json!({ "enabled": enabled, - "executable": "/opt/fixture/bin/gws", + "executable": crate::email_codes::tests::FIXTURE_GWS, "mailbox": "synthetic.owner@gmail.com", "grants": [{ "origin": "https://id.service.example",