# Browser Extension The extension is the central hub of the LOCQR system. It interacts with websites through the JS SDK, communicates with the backend server directly and as a relay to the companion, and presents its own UI outside of the website's DOM. For GYBBR it additionally acts as an isolated cryptographic vault (see below). ## Tech stack - Manifest V3 - TypeScript - Background: service worker - Persistent session state: `chrome.storage.session` (survives service worker suspension, cleared on browser close) - External messaging: `externally_connectable` — declared in manifest; allows the JS SDK (running in the page context) to open a Port directly to the service worker without a content script - Compiled-in key constants: `BACKEND_PUBLIC_KEY_B64` (ML-DSA-44), `ED25519_PUBLIC_KEY_B64` (Ed25519) ## Internal components ### Background service worker Owns all cryptographic operations, run state, backend communication, and site verification logic. It is the only context that ever holds private key material. It has no access to the website DOM and cannot be reached by website scripts. The service worker may be suspended by the browser at any time. In-memory state that must survive suspension is stored in `chrome.storage.session` before the worker yields. In-memory state that must not survive (e.g. LOCQR run keys) is left in memory only and intentionally discarded on suspension. ### Popup UI Rendered in the extension's own window, outside the website's display boundaries. This physical separation is a deliberate security property: a phishing or look-alike site cannot replicate or overlay the extension popup. The popup can always be opened regardless of the current tab state. It always shows at minimum the current state. What it displays and which interactions it offers are fully determined by the per-tab state machine defined below. Visual design, layout, icon assets, and animation details are specified in `gui.md`. ### External messaging The SDK communicates with the service worker directly via `externally_connectable` — no content script is required. The SDK opens a long-lived Port to the extension; the service worker receives the Port connection, reads the real page URL from `sender.tab.url` (browser-provided, cannot be spoofed), and communicates bidirectionally through the Port for the lifetime of the run. ## State model State is per-tab. Every tab runs its own independent instance of the state machine. The popup reflects the state of the currently active tab. ### States **Verification states** - `unverified` — initial state on every page load; cert check not yet complete. - `not_registered` — SDK connected without supplying a certificate. - `cert_invalid` — certificate present but fails local verification. Sub-reasons determine the security classification and user-facing response: *Security-class* (possible attack; extension notifies backend; user sees an explicit security warning, not a generic error): - `domain_mismatch` — cert's domain field does not match the page origin. Most likely a phishing site serving a stolen or misrouted cert. A `domain_mismatch` where the cert's domain is itself a valid registered LOCQR domain is a stronger attack signal than an unrecognised domain. - `signature_invalid` — cert is parseable but signature fails against the pinned `BACKEND_PUBLIC_KEY_B64`. Indicates a forged or tampered cert. - `malformed` — cert cannot be parsed. May indicate garbage served deliberately by an attacker, or a severely broken deployment. *Operational-class* (admin error; user is informed; no backend notification): - `insecure_origin` — page is served over HTTP. LOCQR requires HTTPS; the extension will not activate on any non-HTTPS origin. Checked before any other cert logic. - `expired` — `expires_at` is in the past; admin must renew. - `not_yet_valid` — `issued_at` is in the future; cert deployed prematurely. - `account_error` — certificate valid locally; backend cannot confirm the account. Sub-reasons: - `rejected` — account is suspended or deactivated. - `unreachable` — backend could not be reached; status unknown. Displayed distinctly from a confirmed rejection. - `idle` — certificate valid, account confirmed; no active run. Carries a `sessionActive` hint (advisory only — interfaces.md's `session_status` message) that changes the popup's copy/badge (`gui.md`) but nothing about verification or the run flow itself. **Run states** (only reachable from `idle`) - `phase_1` — ephemeral keypairs generated; key bundle uploading to backend; awaiting signed token. On success the backend returns the signed token together with server-controlled run parameters (see *Session parameters* below). - `phase_2` — QR code displayed with a countdown derived from `qr_ttl`. The extension tracks `auto_refresh_remaining`. On TTL expiry: - If `auto_refresh_remaining > 0`: new keypairs are generated, a new key bundle is uploaded, and a new QR is displayed automatically; counter decremented. The old key bundle is abandoned and will expire server-side. - If `auto_refresh_remaining == 0`: transition to `run_error / ttl_exhausted`; user is offered a manual retry. Awaiting companion scan and key exchange. - `phase_3` — run key derived; PIN displayed with a countdown derived from `pin_ttl`. Awaiting user confirmation on the popup. On TTL expiry: transition to `run_error / pin_timeout`. - `phase_4` — user confirmed PIN (`POST /run/relay/:runId/confirm` sent to unlock the companion, interfaces.md's "PIN confirmation" — fire-and-forget; this transition itself is the extension's own real local gate and doesn't wait on that call); awaiting encrypted credential from relay. - `delivered` — credential decrypted and handed to the JS SDK; transitions automatically to `idle` after brief display. - `run_error` — run failed or aborted at any phase; reason and class held for display. Two classes currently apply to the extension: *Timeout / user class* (expected; user offered retry): - `ttl_exhausted` — QR auto-refreshes used up; user must manually retry. - `pin_timeout` — user did not confirm PIN within `pin_ttl`; run abandoned. - `user_abort` — user cancelled at any phase. *Network class* (transient; user offered retry): - `backend_unreachable` — backend could not be reached during Phase 1 or Phase 4. - `relay_timeout` — no message received from relay within expected window. - `relay_error` — relay returned an error or malformed response. A *Security-class* (extension notifies backend; user sees an explicit security warning; no retry offered) also exists in the shared `RunErrorReason` vocabulary — `gui.md`'s "Security warning — mid-run" template is built for it — but the extension has no member of it today. `alpha_hash_mismatch` and `pin_mismatch` were listed here in an earlier draft; both turned out to be companion-only detections copied across by sharing one `RunErrorReason` enum with `mobile/claude.md` (see that file's near-identical wording) — the extension never receives a second, independent value to compare against its own alpha_hash or PIN, so neither is actually detectable on this side. A human comparing the two devices' screens and declining to confirm is exactly what `user_abort` already covers. `bundle_consumed` (see the design note below) is the one concrete candidate for a real extension-side security-class reason, if the server is ever extended to push it over the relay. ### Transitions ``` page load → unverified → not_registered (cert absent) → cert_invalid (cert present; local check fails) → account_error (cert valid locally; backend rejects or unreachable) → idle (cert valid; account confirmed) idle → phase_1 (SDK login request received) phase_1 → phase_2 (key bundle uploaded; signed token + run parameters received) → run_error/backend_unreachable (backend unreachable; upload fails) phase_2 → phase_3 (kem_ciphertext received via relay WebSocket; run key derived) → phase_1 (auto-refresh) (qr_ttl expired; auto_refresh_remaining > 0; old bundle abandoned) → run_error/ttl_exhausted (qr_ttl expired; auto_refresh_remaining == 0; also the outcome when the companion aborted on bundle_consumed — extension cannot distinguish these cases) → run_error/relay_error (relay error) → run_error/user_abort (user cancels) phase_3 → phase_4 (user confirms PIN) → run_error/pin_timeout (pin_ttl expired) → run_error/user_abort (user rejects or cancels — the mechanism for a PIN the user sees not matching; see the run_error note above) phase_4 → delivered (credential received; GCM auth passes) → run_error/relay_timeout (no message within expected window) → run_error/relay_error (relay error or GCM auth failure) → run_error/user_abort (user cancels) delivered → idle (automatic after brief display) run_error → phase_1 (user retries; security-class errors do not offer retry) → idle (user dismisses) ``` User abort at any run phase transitions to `run_error / user_abort`. Network errors and timeouts are expected events, not failures — the user is offered a retry without a warning. Security-class errors do not offer retry; the user must dismiss and the error is reported to the backend. **Design note — `bundle_consumed` not signalled to extension.** When a QR hijacker fetches the key bundle before the legitimate companion, the companion detects a `404`, enters `error/bundle_consumed`, and reports to `/security/report`. The extension has no way to learn this: the bundle is already gone, and the server has no record linking the second-fetch failure to the extension's open WebSocket. The extension simply times out on QR TTL expiry and enters `run_error/ttl_exhausted`. To give the extension an immediate security-class signal, the server would need to push `{ type: "bundle_consumed" }` over the relay WebSocket on the second-fetch attempt — which requires a short-lived "consumed run IDs" record on the server, separate from the deleted bundle. This is straightforward to add if the UX distinction between "timeout" and "possible hijack" proves important. ### Run parameters All timing values are server-controlled. The backend returns a session parameters blob alongside the signed token at the end of Phase 1. The extension uses these values directly and does not apply any local defaults or overrides. | Parameter | Governs | |---|---| | `qr_ttl` | How long a single QR code remains valid (Phase 2 countdown) | | `max_auto_refresh` | How many times the QR is automatically refreshed before the user must retry manually | | `pin_ttl` | How long the user has to confirm the PIN (Phase 3 countdown) | | `relay_wait_ttl` | How long Phase 4 waits for the credential after PIN confirmation before `run_error/relay_timeout` | ### Navigation rules Navigation is detected via `chrome.tabs.onUpdated`, which fires whenever a tab's URL changes. No content script is required; the extension observes URL transitions from the background service worker. On same-domain SPA navigation where `onUpdated` does not fire (hash changes, History API pushState without a full page load), the open Port from the SDK remains active — the extension retains its state until the SDK reconnects on a new page load. This is an acceptable risk given that a legitimate same-domain navigation produces a new SDK Port connection, and the extension resets if a new connection arrives with a different or absent cert. Pages with no LOCQR SDK installed never open a Port. Those tabs remain in `unverified` (neutral icon) indefinitely. This is the correct behaviour for non-LOCQR pages; no timeout or fallback transition is applied. **Cross-domain navigation** — all tab state is discarded; the tab re-enters `unverified`. Any in-flight run (key bundle on the backend, open relay connection) is abandoned; the key bundle expires via TTL. **Same-domain navigation** — the certificate is re-checked on the new page: - Cert valid and identical to the current cert → state preserved; active run continues uninterrupted. - Cert valid but different (e.g. renewed by admin) → active run discarded; tab moves to `idle` under the new cert. - Cert invalid or absent → active run discarded; tab moves to `cert_invalid` or `not_registered`. A cert change mid-run terminates the run because the cert is an implicit precondition of the run's trust basis: the URL in the signed token is legitimised by the cert that was valid when the run started. ### UI mapping The icon is always visible in the browser toolbar. The popup can always be opened; it always shows at minimum the current state. | State | Icon | Popup content | Popup actions | |---|---|---|---| | `unverified` | neutral | Checking… | — | | `not_registered` | inactive | Site not registered with LOCQR | — (future: register site) | | `cert_invalid / insecure_origin` | error | LOCQR requires HTTPS | — | | `cert_invalid` (operational) | error | Certificate error; reason shown | — | | `cert_invalid` (security) | security warning | Security warning; specific reason | — | | `account_error / rejected` | error | Account inactive | — | | `account_error / unreachable` | error | Cannot reach LOCQR server | — | | `idle` (`sessionActive: false`) | ready | Site verified; ready | Sign in | | `idle` (`sessionActive: true`) | ready | Already signed in (advisory hint) | Sign in again | | `phase_1` | animated | Connecting… | Abort | | `phase_2` | animated | QR code | Abort | | `phase_3` | animated | PIN; confirm or reject | Confirm, Abort | | `phase_4` | animated | Waiting for credential… | Abort | | `delivered` | success | Credential delivered | — | | `run_error` (timeout/user) | error | Reason shown (expired, cancelled) | Retry, Dismiss | | `run_error` (network) | error | Network error; reason shown | Retry, Dismiss | | `run_error` (security) | security warning | Security warning; specific reason | Dismiss only | Exact icon assets, animation details, and transition design are specified in `gui.md`. The mapping above is a simple state → icon lookup; `gui.md` defines what each icon looks like, whether it is static or animated, and how transitions between icons are rendered. Security-class errors must use a visually distinct icon from generic errors — the distinction must be obvious to a user who does not read the popup text. ### Error escalation Security-class errors (cert and run) are reported to the backend immediately. The backend is the natural hub for escalation: it can aggregate signals across users and sites, detect patterns (e.g. multiple users hitting `domain_mismatch` for the same domain), and act as the notification path to site owners. Site owner notification is a required but deferred design decision. Owners need to know about security-class errors against their domain — a surge of `signature_invalid` or `domain_mismatch` events is actionable intelligence. The mechanism (dashboard alert, email, webhook) and the aggregation policy (per-event vs. rate-limited summary) are not yet decided. The extension's responsibility ends at reporting to the backend; delivery to the site owner is a backend and account-management concern. ### Data storage | Data | Location | Lifetime | |---|---|---| | Compiled-in pubkeys | Source constants | Build time | | Per-tab state machine state | In-memory (worker) | Tab lifetime | | Site verification result | `chrome.storage.session` | Browser session | | Run parameters (qr_ttl, max_auto_refresh, pin_ttl, relay_wait_ttl) | In-memory | Phase 1–4; discarded on delivery or error | | auto_refresh_remaining counter | In-memory | Phase 2 only | | Ephemeral keypairs (X25519, ML-KEM-768) | In-memory | Phase 1–2 only | | Run key | In-memory | Phase 3–4; discarded on delivery or error | | Session-active hint (advisory, from the site) | In-memory | Until the tab's next page load or close | | GYBBR identity keys | `chrome.storage.session` | Browser session (see below) | Private key material is never written to `chrome.storage.local`, `localStorage`, `IndexedDB`, or any other persistent store. ## Cryptographic responsibilities Operations the extension performs locally, in the service worker: - **Site verification:** receives the ML-DSA-44 registration certificate from the SDK via Port; reads the real page origin from `sender.tab.url`; rejects immediately if the scheme is not `https:`; verifies the signature against `BACKEND_PUBLIC_KEY_B64`, checks validity window and domain match against the origin hostname, then queries the backend for account status. - **Session initiation:** generates ephemeral X25519 and ML-KEM-768 keypairs, computes `alpha_hash`, uploads key bundle to backend, receives and verifies Ed25519-signed token. - **Run key derivation:** ML-KEM-768 decapsulation, X25519 key agreement, HKDF-SHA256 combining both shared secrets with `salt=runId_utf8`, `info="locqr-run-key-v1"`, `length=32`. - **PIN derivation:** HKDF-SHA256 over the run key with `info="locqr-pin-v1"`. - **Payload decryption:** AES-256-GCM decryption of the credential received from the companion via the backend relay. All algorithm parameters, canonical serialisation, and normative ordering rules are specified in `../crypto.md`. ## Backend API surface The extension communicates with the backend over HTTPS. Endpoints used: - Upload key bundle (run ID, X25519 pubkey, ML-KEM-768 pubkey) → receive Ed25519-signed token and run parameters. - Open and maintain a WebSocket connection to `/run/relay/:runId` for incoming relay messages (kem_ciphertext in Phase 2, encrypted credential in Phase 4). - Post PIN confirmation to `/run/relay/:runId/confirm` (phase_3 → phase_4, unlocks the companion — interfaces.md's "PIN confirmation"). - Query account status for a domain (site verification, step 3). - Report security-class errors to `/security/report`. Endpoint definitions and all request/response schemas are in `../interfaces.md`. Endpoint behaviour is in `../server/claude.md`. ## Website / SDK interface The SDK opens a Port to the service worker via `externally_connectable`. The service worker receives the connection, verifies the cert supplied by the SDK, and responds with the verification outcome and authorised feature set. All subsequent SDK↔extension communication uses this Port. The extension does not respond to SDK requests from a tab whose verification outcome is not valid. A site that supplies no cert, an invalid cert, or whose account is not in good standing receives only the verification error response; no further extension services are available. Port message schemas (message types, `LocqrError`, `Credential`) are defined in `../interfaces.md`. The SDK surface exposed to websites is defined in `../sdk/claude.md`. ## Security invariants - Private key material (ephemeral or identity) never leaves the service worker context. It is never serialised, logged, or passed to any other context. - Session keys are discarded immediately after credential delivery. They are not stored in `chrome.storage.session` or anywhere else. - The extension verifies the backend's Ed25519 signature on the signed token before displaying the QR code. It does not proceed if verification fails. - Site verification runs when the SDK connects via Port. A valid outcome is required before any extension service is offered, including the crypto vault. - The extension never activates on a non-HTTPS origin. HTTP pages receive `cert_invalid / insecure_origin` immediately; no cert is inspected, no backend is contacted. - The popup UI is rendered outside the website DOM. It cannot be spoofed by website content. --- ## GYBBR: cryptographic vault (deferred) The extension doubles as an isolated cryptographic vault for GYBBR. This functionality is deferred and will be designed in a separate document; the summary here captures the intended model so that architectural decisions for the LOCQR core do not inadvertently foreclose it. ### Vault model The vault exposes a key-ID-based API to registered sites. A website never receives key material in cleartext; it only receives operation results. The canonical request form is "encrypt/decrypt this data under key A" or, for cross-key operations, "re-encrypt from key A to key B." Keys are loaded from the website in their encrypted form and decrypted inside the vault using the user's master identity key. The master identity key is delivered to the extension via the standard LOCQR run and held in `chrome.storage.session` for the duration of the browser session. The vault never needs to destroy keys; key-encrypted-under-master leaves and re-enters the vault across browser sessions. ### Threat model A session hijack or XSS attack against the website can intercept SDK calls and observe operation results (ciphertext, plaintext of things the page requested decrypted). It cannot extract key material from the vault, forge operations the site is not authorised to request, or access keys scoped to a different domain. ### Domain isolation Key material is namespaced by the domain of the registered site. A key created or loaded for site A is inaccessible to site B even if both use the LOCQR extension and the same underlying key scheme. This isolation is enforced by the extension, not by the website. ### Feature sets via registration certificate The registration certificate issued by the backend can encode which extension features a site is permitted to use (e.g. standard LOCQR login only, or login plus crypto vault). This allows the extension to offer different feature sets to different sites under a single consistent trust model, with the backend as the authority on what each registered domain may access.