# Interfaces Wire formats and cross-component message schemas for the LOCQR protocol. All schemas defined here are normative — every component must conform exactly. Cryptographic algorithm choices and rationale are in `crypto.md`. End-to-end flows that use these formats are in `flows.md`. Individual component behaviour is in the entity `claude.md` files. --- ## Shared conventions | Convention | Value | |---|---| | Binary encoding | base64url (RFC 4648 §5), no padding characters | | Timestamps | Unix epoch seconds, integer | | Signed payload serialisation | RFC 8785 (JCS) — mandatory on all platforms | | Schema version | `"v": 1` field on every signed envelope | | Run ID | UUID v4, standard hyphenated lowercase | No ISO 8601 timestamps. No other binary encoding. No bespoke serialisation. A conforming JCS library must be used on each platform; hand-rolled implementations are not permitted. --- ## Registration certificate Issued by the backend at domain registration time. Long-lived. Passed by the SDK to the extension at connect time. **Signed payload** — JCS-canonicalised then signed with ML-DSA-44: ```json { "v": 1, "domain": "test.locqr.dev", "issued_at": 1748390400, "expires_at": 1779926400, "features": ["login"] } ``` `domain` is the bare hostname — no scheme, no port. `features` lists the extension capabilities the site may use (e.g. `"login"`, `"vault"`). **Full envelope** — serialised then base64url-encoded for distribution: ```json { "v": 1, "domain": "test.locqr.dev", "issued_at": 1748390400, "expires_at": 1779926400, "features": ["login"], "sig": "" } ``` The entire envelope JSON is base64url-encoded into a single opaque string. That string is what the website operator places in `locqr.init({ cert: '...' })`. **Extension verification steps:** 1. Read `sender.tab.url`; reject immediately if scheme ≠ `https:` (`insecure_origin`). 2. base64url-decode and JSON-parse the cert string. 3. Verify `sig` over `JCS({v, domain, issued_at, expires_at, features})` against the pinned `BACKEND_PUBLIC_KEY_B64`. 4. Check `issued_at ≤ now ≤ expires_at`. 5. Check `cert.domain === origin.hostname`. Subdomains are not implicitly covered — a cert for `locqr.dev` does not cover `sub.locqr.dev`. **`GET /domain/registration?domain=`** — fetches the envelope above for a registered domain. Used by the test website on load, to obtain the cert string passed to `locqr.init({ cert })`. Response: `{ "cert": "" }` — `cert` is exactly the base64url-encoded envelope string described above, unmodified. `404` if `domain` has no certificate on file. --- ## Signed token (QR payload) Produced by the backend at Phase 1 and included in the QR code. Verified by the companion at Phase 2 before any key material is touched. **Signed payload** — JCS-canonicalised then signed with Ed25519: ```json { "v": 1, "runId": "550e8400-e29b-41d4-a716-446655440000", "url": "https://test.locqr.dev", "expires_at": 1748390460, "alpha_hash": "" } ``` `url` is the full HTTPS origin (`scheme://host`, port included only if non-standard). `alpha_hash = SHA-256(x25519_pub_bytes || kem_pub_bytes)` — classical precedes post-quantum per the normative ordering rule in `crypto.md`. **Full envelope:** ```json { "v": 1, "runId": "550e8400-e29b-41d4-a716-446655440000", "url": "https://test.locqr.dev", "expires_at": 1748390460, "alpha_hash": "", "sig": "" } ``` **QR encoding:** `base64url(JSON.stringify(envelope))` — the entire envelope JSON is base64url-encoded; that string is the QR code content. The companion base64url-decodes, JSON-parses, then verifies `sig` before proceeding. **Size:** approximately 250 bytes; fits QR version 12 at Q error correction. **Expiry leeway:** The companion accepts tokens up to **30 seconds** past `expires_at` to cover device clock skew. This is the only clock tolerance in the protocol. The companion may compare the server's `Date` response header (from the bundle fetch) to the local clock and surface a diagnostic warning if the delta exceeds 60 seconds — informational only, not used in expiry math. --- ## Run bundle upload Extension → Backend, Phase 1. **`POST /run/bundle`** Request: ```json { "runId": "550e8400-e29b-41d4-a716-446655440000", "origin": "https://test.locqr.dev", "x25519_pubkey": "", "kem_pubkey": "" } ``` `origin` is the full HTTPS origin taken from `sender.tab.url`. The server bakes it verbatim into the signed token's `url` field. Response: ```json { "signed_token": "", "qr_ttl": 90, "max_auto_refresh": 3, "pin_ttl": 120, "relay_wait_ttl": 60 } ``` `relay_wait_ttl` bounds how long the extension waits for the encrypted credential once `phase_4` begins (i.e. once the user has confirmed the PIN) before giving up with `run_error/relay_timeout`. All TTL values are seconds. Clients must not apply local defaults or overrides. --- ## Run bundle fetch Companion → Backend, Phase 2. **At-most-once: the bundle is atomically destroyed on this fetch.** **`GET /run/bundle/:runId`** Success response: ```json { "x25519_pubkey": "", "kem_pubkey": "" } ``` `404` if already consumed or expired. The companion treats a `404` on first fetch as a `bundle_consumed` security-class error, not a network error. --- ## Relay messages ### Companion → Backend **`POST /run/relay/:runId`** ```json { "type": "kem_ciphertext", "payload_b64": "" } { "type": "credential", "payload_b64": "" } ``` **`kem_ciphertext` payload framing** — `payload_b64` encodes: ``` base64url( x25519_pub_companion[32 bytes] || kem_ciphertext[1088 bytes] ) ``` Fixed-offset split: first 32 bytes are the companion's ephemeral X25519 public key; remaining 1088 bytes are the ML-KEM-768 ciphertext. The extension reads both to complete the key exchange — the X25519 pubkey for ECDH, the KEM ciphertext for decapsulation. **`credential` payload framing** — `payload_b64` is the encrypted credential ciphertext framed as `nonce[12] || ciphertext[N] || tag[16]`; see *Encrypted credential payload* below. ### Backend → Extension (WebSocket push) The extension opens a WebSocket to the relay endpoint (`wss://` scheme, same host as the HTTPS API URL, path `/run/relay/:runId`) after Phase 1 completes. The backend forwards companion relay messages over this socket unchanged: ```json { "type": "kem_ciphertext", "payload_b64": "" } { "type": "credential", "payload_b64": "" } ``` The extension validates `type` against its current run phase before processing: `kem_ciphertext` is valid only in `phase_2`; `credential` only in `phase_4`. Messages of the wrong type are discarded. No application-level keepalive is needed; WebSocket protocol-level ping/pong is sufficient. --- ## PIN confirmation Extension → Backend (POST) and Companion → Backend (GET, polled), addressed to `runId`. Single-sided: the extension's Confirm click is the only human action that gates advancement. The companion has no local confirmation step of its own — it polls automatically as soon as it derives and displays the PIN (`mobile/claude.md`'s `pin_display`), and advances the moment this resolves confirmed. The PIN being independently derived and shown on both devices is what makes this safe: the person visually compares the two values before clicking Confirm on the extension, so the check still happens, it's just not enforced by a second tap on the companion — there was no benefit found in requiring one once the display-and-compare step already exists. **`POST /run/relay/:runId/confirm`** Sent by the extension the moment its user clicks Confirm on the PIN screen (`phase_3 → phase_4`). No body. `204 No Content` on success. Fire-and-forget from the extension's side — its own `phase_4` transition is the real local gate on its side and does not depend on this call succeeding; a failed POST here only means the companion never gets unlocked and the run eventually times out. **`GET /run/relay/:runId/confirm`** Polled by the companion automatically from the moment it enters `pin_display`, until it returns confirmed or a bounded wait elapses (`mobile/claude.md`'s `error/confirmation_timeout`). ```json { "confirmed": true } ``` Always `200` with `confirmed: false` for a `runId` that has not been confirmed — including a `runId` the server has never heard of. There's no separate 404 case: the confirmation marker is a plain fire-and-forget flag (see below), not backed by a durable record of which run IDs are real, so there's nothing to distinguish "not yet confirmed" from "unknown" against. A companion polling a wrong/mistyped `runId` degrades to the same `confirmation_timeout` as one that's just slow — acceptable for this PoC scope. Server-side state here is a plain in-memory marker (`RelaySessionRegistry`, server/claude.md), not a durable/authenticated record — it exists only to carry a one-time signal for the duration of one run and is pruned well after any realistic `pin_ttl`. --- ## Encrypted credential payload **Plaintext** (UTF-8 encoded): ```json { "username": "alice@example.com", "password": "hunter2" } ``` Future fields (e.g. `totp`) will be added to this object when supported. **Ciphertext framing** — raw bytes, base64url-encoded for relay transport: ``` [ nonce: 12 bytes | ciphertext: N bytes | GCM auth tag: 16 bytes ] ``` Fixed-offset split: first 12 bytes are always the nonce, last 16 bytes are always the tag, everything between is ciphertext. `payload_b64` in the relay message is `base64url(nonce || ciphertext || tag)`. One nonce per run. Nonce reuse is structurally prevented: each run derives a fresh key from a one-time bundle, and exactly one encryption occurs per run. **Decryption:** 1. base64url-decode → bytes 2. `nonce = bytes.slice(0, 12)` 3. `tag = bytes.slice(-16)` 4. `ciphertext = bytes.slice(12, -16)` 5. AES-256-GCM decrypt with run key + nonce; verify tag before accepting plaintext 6. `JSON.parse(UTF-8 decode(plaintext))` --- ## Port message protocol (SDK ↔ Extension) Communication over the `chrome.runtime.Port` opened when `locqr.init()` runs. **Naming note:** Port message `type` values use `snake_case` (e.g. `run_started`). The SDK translates these into the colon-separated event names exposed via `locqr.on()` (e.g. `run:started`). The two namespaces are distinct; the SDK is the translation layer between them. ### SDK → Extension ```ts { type: "init", cert: string } // sent immediately on Port open { type: "request_credential" } // triggers Phase 1 { type: "abort" } // user-initiated cancel { type: "session_status", active: boolean } // advisory only — see note below ``` `session_status` never affects verification or any run's security checks — it only shapes what the popup's `idle` screen shows (a plain "Ready to sign in" vs. an "already signed in" variant). The site may send it any time after `init()`, and as often as its own session state changes (e.g. its own logout flow firing it with `active: false`). If never sent, the popup defaults to the plain `idle` screen. ### Extension → SDK ```ts // Response to "init" { type: "verification", status: "valid", features: string[] } { type: "verification", status: "error", error: LocqrError } ``` `features` in the `valid` response is taken verbatim from the `features` field of the verified registration certificate. The extension does not augment or filter the list. ```ts // Unsolicited run lifecycle events { type: "run_started" } { type: "run_delivered", credential: Credential } { type: "run_error", error: LocqrError } ``` ### Shared types ```ts type LocqrError = { code: "NOT_INSTALLED" | "NOT_REGISTERED" | "CERT_INVALID" | "ACCOUNT_ERROR" | "RUN_FAILED"; reason?: string; // sub-reason, e.g. "expired", "domain_mismatch" security?: boolean; // true for security-class errors } type Credential = { username: string; password: string; // totp and further fields deferred } ``` `NOT_INSTALLED` is produced locally by the SDK before any Port exists — it never arrives over the wire. All other codes arrive from the extension. --- ## Domain status query Extension → Backend, site verification. **`GET /domain/status?domain=`** `domain` is the bare hostname from the registration certificate (e.g. `test.locqr.dev`). Not run-scoped — the same query is made on every page load independently of any run. Response: ```json { "status": "valid" | "rejected" | "suspended" } ``` Error (HTTP 5xx or network failure) maps to `account_error / unreachable` in the extension state machine. --- ## Security report Extension or Companion → Backend. Sent on any security-class error. Unauthenticated; server is responsible for rate-limiting and aggregation. **`POST /security/report`** ```json { "runId": "550e8400-e29b-41d4-a716-446655440000", "error_type": "bundle_consumed", "timestamp": 1748390461 } ``` `runId` is optional. Omit it for pre-run cert errors (`domain_mismatch`, `cert_signature_invalid`, `cert_malformed`) where no run ID exists yet. Valid `error_type` values: | Value | Source | Meaning | |---|---|---| | `bundle_consumed` | Extension or Companion | Bundle already fetched before legitimate companion arrived | | `alpha_hash_mismatch` | Extension or Companion | Key bundle does not match QR commitment | | `pin_mismatch` | Extension | Run keys diverged; PIN did not match | | `domain_mismatch` | Extension | Cert domain does not match page origin | | `cert_signature_invalid` | Extension | Cert signature fails against pinned key | | `cert_malformed` | Extension | Cert cannot be parsed | | `token_signature_invalid` | Companion | Signed token signature fails against pinned key |