locqr/interfaces.md

367 lines
11 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`.
---
## 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.
---
## 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 |