locqr/extension/claude.md
2026-08-16 22:00:08 +02:00

22 KiB
Raw Blame History

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.
    • expiredexpires_at is in the past; admin must renew.
    • not_yet_validissued_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.

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)

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 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 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 14; discarded on delivery or error
auto_refresh_remaining counter In-memory Phase 2 only
Ephemeral keypairs (X25519, ML-KEM-768) In-memory Phase 12 only
Run key In-memory Phase 34; 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 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.