426 lines
20 KiB
Markdown
426 lines
20 KiB
Markdown
# 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; 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. 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 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.
|
||
|
||
### 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 `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 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 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).
|
||
- 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.
|