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

422 lines
13 KiB
Markdown

# 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": "<base64url ML-DSA-44 signature, 2420 bytes unencoded>"
}
```
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=<hostname>`** — 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": "<base64url envelope>" }``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": "<base64url SHA-256, 32 bytes unencoded>"
}
```
`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": "<base64url>",
"sig": "<base64url Ed25519 signature, 64 bytes unencoded>"
}
```
**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": "<base64url, 32 bytes>",
"kem_pubkey": "<base64url, 1184 bytes>"
}
```
`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": "<base64url signed token envelope>",
"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": "<base64url, 32 bytes>",
"kem_pubkey": "<base64url, 1184 bytes>"
}
```
`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": "<base64url>" }
{ "type": "credential", "payload_b64": "<base64url>" }
```
**`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": "<base64url>" }
{ "type": "credential", "payload_b64": "<base64url>" }
```
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=<hostname>`**
`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 |