375 lines
11 KiB
Markdown
375 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`.
|
|
|
|
**`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.
|
|
|
|
---
|
|
|
|
## 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 |
|