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

440 lines
22 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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.
**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.