locqr/interfaces.md

11 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:

  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:

{
  "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 framingpayload_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 framingpayload_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.


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:

  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

{ 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