20 KiB
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. Adomain_mismatchwhere 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 pinnedBACKEND_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_atis in the past; admin must renew.not_yet_valid—issued_atis 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.
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 fromqr_ttl. The extension tracksauto_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 torun_error / ttl_exhausted; user is offered a manual retry. Awaiting companion scan and key exchange.
- If
-
phase_3— run key derived; PIN displayed with a countdown derived frompin_ttl. Awaiting user confirmation on the popup. On TTL expiry: transition torun_error / pin_timeout. -
phase_4— user confirmed PIN; awaiting encrypted credential from relay. -
delivered— credential decrypted and handed to the JS SDK; transitions automatically toidleafter brief display. -
run_error— run failed or aborted at any phase; reason and class held for display. Three classes:Security-class (extension notifies backend; user sees explicit security warning; no retry offered):
alpha_hash_mismatch— key bundle received from backend does not match the commitment in the QR; indicates tampering between extension and backend.pin_mismatch— run keys diverged; should not occur if all prior verifications passed.
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 withinpin_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.
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/pin_mismatch (security: run keys diverged)
→ run_error/user_abort (user rejects or cancels)
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) |
Further per-phase timeouts (e.g. Phase 4 relay wait) may be added to this blob as the design progresses.
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
idleunder the new cert. - Cert invalid or absent → active run discarded; tab moves to
cert_invalidornot_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 |
ready | Site verified; ready | Start run |
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) | 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 |
| 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 nothttps:; verifies the signature againstBACKEND_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/:runIdfor incoming relay messages (kem_ciphertext in Phase 2, encrypted credential in Phase 4). - 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.sessionor 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_originimmediately; 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.