Skip to content

Add WebAuthn page hook answered through metro-api - #449

Open
rgarcia wants to merge 3 commits into
mainfrom
hypeship/webauthn-page-hook
Open

rgarcia wants to merge 3 commits into
mainfrom
hypeship/webauthn-page-hook

Conversation

@rgarcia

@rgarcia rgarcia commented Oct 12, 2026 •

Copy link
Copy Markdown
Contributor

Summary

This adds a WebAuthn page hook. When it is on, every page and iframe document answers navigator.credentials.get() / create() publicKey requests through the VM instead of the browser's authenticators. kernel-images forwards each request to metro-api along with the calling document's origin as Chromium reports it. Private keys never enter the VM. Anything metro-api doesn't answer falls back to the browser's own implementation.

PUT /webauthn/hook (new, x-telemetry-category: platform)

  • Request {"enabled": bool}, response 200 {"enabled": bool}. It is idempotent.
  • The flag is persisted to /run/kernel/webauthn-hook.json before it takes effect, the same way the egress policy is, so a restart of the API process restores it. If the flag can't be persisted, the request returns 500.
  • On: the hook script and binding are installed in every page/iframe session, both existing documents and later ones. Installation uses Page.addScriptToEvaluateOnNewDocument with runImmediately, and the script guards itself so it never installs twice.
  • Off: documents that already have the hook are first told to suspend, so their requests go straight to the browser's implementation with no wait. Then the hook's DevTools connection is closed, which removes its bindings and new-document scripts and detaches its sessions, so no Runtime/Page domain stays enabled on its behalf. Any request that still reaches kernel-images in between gets not_handled without contacting metro-api.
  • Turning it on waits (bounded) until the documents already open are hooked.
  • If setting up a session fails (Runtime.enable, Page.enable, Runtime.addBinding, or the script registration), it is retried once after 1 s and logged at Error if it still fails.

Its own DevTools connection

The hook does not share telemetry's connection; server/lib/cdpmonitor is unchanged. CDP domain state, Runtime.addBinding bindings and Page.addScriptToEvaluateOnNewDocument scripts are per session, and sessions belong to a connection, so telemetry turning capture on and off does not affect the hook.

Following webmcpclient's pattern, the hook dials cdpclient.DialWithEvents on the upstream manager's current DevTools URL and uses a browsersurface tracker for page and iframe targets (no target pausing; decision 11 stands). Unlike WebMCP's lazy connection, the connection exists only while the hook is on:

  • It is dialed when the hook is turned on, or at startup when the persisted flag is on.
  • It is replaced after socket loss, a failed 5-second liveness probe (catches half-open sockets after suspend), or a new upstream URL (Chrome restart), and the hook is re-installed on every page and iframe. Unanswered requests on the old connection are cancelled; their documents resend to the new one.
  • It is closed when the hook is turned off.

Page protocol (lib/webauthnhook/hook.js)

  • The script overrides CredentialsContainer.prototype.get/create only for publicKey requests. Password, federated and OTP requests go straight to the browser.
  • isUserVerifyingPlatformAuthenticatorAvailable() and isConditionalMediationAvailable() report true, and passkey capabilities are merged into getClientCapabilities().
  • signal is honored: an abort rejects with AbortError.
  • Options are sent in the WebAuthn L3 JSON form (base64url). The page never supplies an origin.
  • Each request has a page-generated ID and is resent every second until a final answer arrives. kernel-images acknowledges every send it receives and de-duplicates by (execution context, request ID). A resend after the answer gets the stored answer again. If 10 s pass without an acknowledgement, the page falls back to the browser's implementation. If calling the binding throws (binding gone), the page falls back after 3 s; a reconnect that re-adds the binding within that window still gets the request.
  • When the caller aborts, the page sends {id, op: "abort"}. kernel-images drops the call if it hasn't reached metro-api yet, cancels the in-flight HTTP request and its retries if it has, and sends no answer.
  • Only unanswered requests count toward the per-session cap of 16; hitting it is logged at Warn and the request is left to the browser. Answered requests are kept for resend de-duplication for 30 s, at most 256 per session (oldest evicted first).
  • kernel-images can send {suspend: true} (on disable) and the script sends itself {resume: true} when it runs again in the same document (on re-enable).
  • Returned credentials are built on the native prototypes, so instanceof PublicKeyCredential / AuthenticatorAttestationResponse / AuthenticatorAssertionResponse and [object PublicKeyCredential] hold. They expose ArrayBuffer fields, toJSON(), getClientExtensionResults(), getAuthenticatorData(), getPublicKey(), getPublicKeyAlgorithm() and getTransports(), with authenticatorAttachment: "platform".

Origin handling

  • Origin: taken from Runtime.executionContextCreated for the binding call's executionContextId, main world only.
  • Top origin: the page target's main frame's newest main-world context (tracked per frame, so a stale context from the previous document during navigation is not picked), following out-of-process iframes up through their parentFrameId.
  • Accepted origins: https, or http on localhost and *.localhost. Opaque and other insecure origins get not_handled.
  • Permissions policy: the calling frame's policy (Page.getPermissionsPolicyState) must allow publickey-credentials-get/-create. If it doesn't, the request is left to the browser, which rejects it exactly as it would without the hook.

metro-api client (lib/webauthnhook/signer.go)

  • POST {KERNEL_METRO_API_BASE_URL}/webauthn/{get|create} with Authorization: Bearer <KERNEL_INSTANCE_JWT>. The full HTTP contract is documented on MetroSigner in signer.go.
  • Body: {origin, top_origin, mediation?, public_key}.
  • Retries: 5xx and transport errors are retried with backoff for up to 10 s, each retry with a byte-identical body (origin, top_origin, mediation, public_key), so metro-api can replay a stored answer for a retried create. Any other non-200 status (including 401/403), a malformed body, or a credential missing required fields leaves the request to the browser. An unknown error name becomes NotAllowedError.
  • Forked VMs: the URL and JWT are re-read per request. The OTLP identity provider has been generalized into instanceIdentityProvider (renamed from otlp_credential.go), which also yields the metro-api URL from the applied fork-identity payload (forkidentity.MetroAPIURL, preferring kernel_metro_api_base_url).

Testing

  • go vet ./...: passes.
  • go test -race $(go list ./... | grep -v /e2e$): passes.
  • make test-runtime: passes.
  • KERNEL_CDPMONITOR_CHROME_E2E=1 go test -race ./lib/cdpmonitor -count=1: passes (cdpmonitor itself is unchanged).
  • KERNEL_WEBAUTHN_CHROME_E2E=1 go test -race ./lib/webauthnhook -count=1: passes, 3 consecutive runs, against Chromium 154. A telemetry monitor runs alongside on its own connection. It uses HTTPS sites under *.test and a fake metro-api that registers an ES256 key and signs real assertions. It covers:
    • create() returns an attestation that parses (fmt none, flags UP|UV|BE|BS|AT, counter 0, zero AAGUID, COSE key matching getPublicKey()).
    • get() returns an assertion whose signature verifies against the registered key.
    • A conditional get() issued at page load is answered.
    • A tab opened directly on a page that calls conditional get() at load is not answered on its first document; a reload is (pins decision 11).
    • not_handled falls back to the native implementation (virtual authenticator); an error answer rejects with that DOMException.
    • A cross-site OOPIF and a same-site cross-origin frame send their own origin and the top origin; an iframe without allow is left to the browser.
    • Turning telemetry capture on and off while the hook is armed: the hook keeps answering, on the same document and after navigation.
    • Disabling suspends hooked documents (immediate fallback) and leaves new documents unhooked; re-enabling resumes them.
    • A missing binding falls back after 3 s; an aborted request stops kernel-images retrying metro-api.
    • Fallback after the 10 s window when metro-api keeps returning 503 and when the connection is gone; a new upstream URL re-dials, and the same un-reloaded document is answered over the new connection.
    • Credentials pass instanceof and toJSON() matches every buffer; password credential requests are untouched; an aborted signal rejects with AbortError.
  • CI runs the WebAuthn browser suite next to the existing CDP telemetry browser regressions.

Unit tests additionally cover the unanswered-call cap, oldest-first eviction of answered calls, abort before and after dispatch, top-origin selection during navigation, the setup retry, identical retry bodies, starting and stopping the connection with the flag, and restoring the flag across restarts.

Not run: make test-e2e (Docker image e2e) and any built-image or staging check. Standby/wake and snapshot-fork behavior of the hook are not verified.

Notes for the metro-api side

  • create retries: kernel-images retries create on 5xx and transport errors with an identical body, relying on metro-api replaying the stored answer for a retried create.
  • Required fields: a credential answer must include id, rawId, response.clientDataJSON and response.authenticatorData, plus response.signature for get or response.attestationObject for create. Otherwise kernel-images treats it as not_handled. Include response.publicKey, response.publicKeyAlgorithm and response.transports on registrations so getPublicKey() and related methods return values.
  • public_key is page-supplied: treat it as untrusted. A page can call the binding directly. origin and top_origin are always Chromium's.
  • mediation is forwarded for create too: for example "conditional" for conditional create.

Known gaps

  • Pages whose first document runs before the hook attaches (new tabs, window.open, target=_blank, sandboxed iframes) need a reload. Targets are not paused on attach; a test pins this behavior.
  • Pages loaded while the hook is reconnecting to Chromium have no hook until reloaded.

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using default effort and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 501c80c. Configure here.

if event.Message.SessionID != "" {
h.protocolEvent(event.Message.SessionID, event.Message.Method, event.Message.Params)
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Failed attaches never reconnect

Medium Severity

handleEvent ignores EventDiscoveryFailed. A failed Target.attachToTarget therefore never tears down the hook connection, so that page or iframe stays unhooked until some later socket loss. cdpmonitor cancels the connection on this event so attach is retried; the new hook loop does not.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 501c80c. Configure here.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant