13 KiB
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:
{
"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:
{
"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:
- Read
sender.tab.url; reject immediately if scheme ≠https:(insecure_origin). - base64url-decode and JSON-parse the cert string.
- Verify
sigoverJCS({v, domain, issued_at, expires_at, features})against the pinnedBACKEND_PUBLIC_KEY_B64. - Check
issued_at ≤ now ≤ expires_at. - 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:
{
"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:
{
"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:
{
"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:
{
"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:
{
"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
{ "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:
{ "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).
{ "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):
{ "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:
- base64url-decode → bytes
nonce = bytes.slice(0, 12)tag = bytes.slice(-16)ciphertext = bytes.slice(12, -16)- AES-256-GCM decrypt with run key + nonce; verify tag before accepting plaintext
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
{ type: "init", cert: string } // sent immediately on Port open
{ type: "request_credential" } // triggers Phase 1
{ type: "abort" } // user-initiated cancel
Extension → SDK
// 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.
// Unsolicited run lifecycle events
{ type: "run_started" }
{ type: "run_delivered", credential: Credential }
{ type: "run_error", error: LocqrError }
Shared types
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:
{ "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
{
"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 |