14 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,
"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:
{
"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. 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).
{ "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
{ 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
// 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 |