# 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 } ``` 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` — a real two-way handshake, not the companion proceeding on its own schedule. The companion is only allowed to advance to credential selection once *both* its own local user has tapped Continue (`mobile/claude.md`'s `pin_display → awaiting_confirmation`) *and* this confirmation has arrived; neither side's local action alone is sufficient. This mirrors Bluetooth Numeric Comparison pairing, which communicates each side's confirmation to the other as part of completing the handshake, rather than trusting the two devices to act on the same timeline independently. **`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 after its own local Continue tap, 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 ``` ### 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 |