locqr/crypto.md

355 lines
19 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Encryption Scheme
The LOCQR ecosystem requires cryptographic operations across three independently
developed components: the browser extension, the LOCQR server, and the companion
app. These components are built on different platforms and runtimes. Any ambiguity
in algorithm parameters, key encoding, or data serialisation will produce silent
interoperability failures — incorrect signatures that always fail, or run keys
that never match.
## The trust problem
The companion app and browser extension have no pre-existing relationship. A
shared secret must be established between them across an untrusted network relay,
without that secret ever crossing the relay in recoverable form.
The QR code solves the bootstrapping problem: a network attacker cannot intercept
what is displayed on screen. But the QR code is not a cryptographic primitive —
it is the channel that makes MITM structurally impossible during key exchange.
The cryptography rides on top of that property: the QR carries a cryptographic
commitment and a signed token, not keys.
The backend server is the sole trust anchor. Its secret key material never leaves
a controlled environment. Its corresponding public keys are compiled into both the
extension and the companion at build time and cannot be substituted at runtime.
All legitimacy within the system traces back to the backend's ability to sign, and
the clients' ability to verify against those pinned constants.
## Three security concerns
The architecture addresses three distinct security problems. They share the same
flow but require different cryptographic answers.
**Key exchange integrity.** The companion must be certain it is exchanging keys
with the correct extension, not an attacker's. The backend signs the key exchange
parameters — a commitment to the key bundle, the target URL, a run ID, and a TTL.
This signature only needs to hold for the duration of the exchange window
(60 seconds): a forged signature within that window would allow an attacker to
substitute a fake key bundle, but executing Shor's algorithm against a 256-bit
elliptic curve key in under 60 seconds is not achievable on any near-term quantum
computer.
**Payload confidentiality against future quantum adversaries.** The credential
delivered through the exchange may remain sensitive for years. The run key
must therefore be irrecoverable even by an adversary who records the exchange
today and acquires a cryptographically relevant quantum computer (CRQC) later.
This protection is permanent and completely independent of the exchange window.
**Website registration legitimacy.** Before initiating any exchange, the extension
must confirm the website is a registered LOCQR participant. The backend issues
signed registration certificates. These are long-lived (months to years) and must
resist a future CRQC that could forge a certificate retroactively. This tier is
fully post-quantum.
## Threat model
**Passive network attacker** records the key exchange for later decryption.
Defeated by the post-quantum component of the hybrid key exchange: a CRQC cannot
derive the run key from recorded ciphertext.
**Active MITM during key exchange** attempts to substitute a fake key bundle
during the 60-second exchange window. Defeated by the backend's Ed25519 signature:
the companion rejects any exchange parameters that do not verify against its
pinned key before proceeding.
**Real-time quantum attack** attempts to forge an Ed25519 signature within the
60-second window to conduct a live MITM attack. Not achievable on any near-term
quantum computer.
**Future quantum adversary** acquires a CRQC and attempts to decrypt recorded
recorded ciphertext. Defeated by ML-KEM-768: the run key cannot be derived
from ciphertext without the extension's private key, which is never transmitted.
**QR hijacker** scans the displayed code before the legitimate user. Defeated by
at-most-once key bundle delivery: the backend destroys the bundle on first fetch,
converting this from a network race to a physical race.
**Compromised or phishing website** attempts XSS or credential theft. The
extension operates outside the website's security context and verifies
registration certificates independently. Data transferred to the website is out
of scope once delivered.
## Algorithm choices
### ML-DSA-44 — registration signing
ML-DSA-44 (NIST FIPS 204, CRYSTALS-Dilithium) signs domain registration
certificates. A registration certificate is long-lived; a future CRQC could
be used to forge one retroactively. Full post-quantum signature security is
required here. The large signature size (2420 bytes) is acceptable at
registration-time, where certificates are exchanged once and cached.
ML-DSA-44 is not used for key exchange signing: its 2420-byte signatures would
make a QR code unscannable at any practical error correction level.
### Ed25519 — key exchange integrity
Ed25519 signs the backend's assertion over the key exchange parameters: the
key bundle commitment, the target URL, a run ID, and a TTL. Its sole purpose is
to prevent MITM substitution during the key exchange. It does not protect the
delivered payload — that is the job of the run key.
Its 64-byte signatures are essential: compact enough to be carried in a scannable
QR code. The quantum risk is bounded to the 60-second exchange window; forging
Ed25519 in real-time within that window is not achievable on any near-term
quantum computer.
Should the quantum outlook change, Ed25519 can be replaced for future exchanges
without any effect on data already delivered. Past payloads are protected by
ML-KEM-768, not by Ed25519. Upgrading the signing algorithm is a deployment
change — new backend signing key, new pinned constants in extension and companion
— not an architectural one.
### Hybrid X25519 + ML-KEM-768 — run key exchange
The run key is derived from two parallel exchanges: X25519 (classical ECDH)
and ML-KEM-768 (NIST FIPS 203, CRYSTALS-Kyber). Their shared secrets are
combined and fed into HKDF. This hybrid provides defense in depth: if either
primitive is broken, the run key remains protected by the other.
ML-KEM-768 is chosen over ML-KEM-512 because the payload may be sensitive for
years. ML-KEM-512 provides approximately AES-128 equivalent security; ML-KEM-768
provides approximately AES-192. The more conservative choice is appropriate when
the protected content has an indefinite sensitivity lifetime.
X25519 is retained in the hybrid for its compact 32-byte public key and
negligible performance cost. Its quantum risk within the 60-second exchange window
is negligible, and the ML-KEM-768 component ensures the run key is
irrecoverable regardless.
### AES-256-GCM — payload encryption
The session payload is encrypted with the derived run key. At 256-bit key
length, Grover's algorithm reduces effective security to 128 bits — which remains
practically unbreakable. GCM provides authenticated encryption: a tampered
ciphertext fails authentication before decryption is attempted.
Nonce reuse — the primary GCM failure mode — is structurally prevented: each
exchange derives a fresh key from a one-time key bundle, and exactly one payload
encryption occurs per run.
### HKDF-SHA256 — run key derivation
HKDF derives the run key from the combined X25519 and ML-KEM shared secrets:
```
run_key = HKDF-SHA256(
ikm = x25519_shared || kem_shared_secret,
salt = runId_utf8,
info = "locqr-run-key-v1",
length = 32
)
```
`runId_utf8` is the UUID v4 string in its standard hyphenated form, encoded as
UTF-8 bytes (36 bytes). The salt binds the derived key to the specific run: a
key bundle replayed under a different run ID produces a different key. The info
string provides domain separation from other HKDF derivations within the same
protocol.
Note: "run key" is this project's term for what cryptographic literature
standardly calls a "session key" — a short-lived symmetric key scoped to one
protocol execution.
### HTTPS requirement
The extension enforces HTTPS at the origin level. A page served over HTTP
receives `cert_invalid / insecure_origin` immediately; no certificate is
inspected and no backend call is made. This is non-negotiable: the security
scheme provides no meaningful protection on an unencrypted transport.
The registration certificate `domain` field is the bare hostname
(`test.locqr.dev`). The signed token `url` field and the bundle upload `origin`
field are the full HTTPS origin (`https://test.locqr.dev`). The extension
checks that the cert's `domain` matches the hostname component of the
`sender.tab.url` origin, and that the scheme is `https:`. Subdomains are not
implicitly covered — a cert for `locqr.dev` does not validate for
`sub.locqr.dev`.
### RFC 8785 (JCS) — canonical JSON
All sign and verify operations require a canonical byte representation of the
signed data. RFC 8785 (JSON Canonicalization Scheme) is mandatory across all
components and platforms. Field ordering differences between platforms produce
different digests and silent signature failures. Bespoke implementations are not
permitted — a conforming library must be used on every platform.
---
## Normative rule: hybrid construction ordering
In all hybrid (classical + post-quantum) constructions, the classical value
precedes the post-quantum value in any concatenation. This applies to input
key material for HKDF, hash commitments over public key bundles, and any future
hybrid construction not yet defined.
---
## Reference
### Algorithms and key sizes
| Algorithm | Security concern | Public key | Sig / ciphertext |
| ----------- | --------------------------------- | ----------- | ----------------------- |
| ML-DSA-44 | Registration signing | 1312 bytes | 2420 bytes |
| ML-KEM-768 | Session key exchange (PQ) | 1184 bytes | 1088 bytes (ciphertext) |
| X25519 | Session key exchange (classical) | 32 bytes | — |
| Ed25519 | Key exchange integrity | 32 bytes | 64 bytes |
| AES-256-GCM | Payload encryption | — | — |
| HKDF-SHA256 | Run key derivation | — | — |
### Compile-time key constants
| Constant | Algorithm | Held by | Purpose |
| ------------------------ | --------- | --------- | ---------------------------------------- |
| `BACKEND_PUBLIC_KEY_B64` | ML-DSA-44 | Extension | Verifies domain registration certs |
| `ED25519_PUBLIC_KEY_B64` | Ed25519 | Extension | Verifies key exchange tokens |
| `ED25519_PUBKEY` | Ed25519 | Companion | Verifies key exchange tokens |
All three are compiled at build time by `sign-domain.js` and never negotiated at
runtime. Any key material received at runtime that does not match a pinned
constant is rejected before verification runs. Key regeneration (`--new-keys`)
patches all source files and requires redeployment of both the extension and the
companion.
All npm dependencies providing cryptographic primitives must be version-pinned
and hash-verified via lockfile. No runtime download of cryptographic code is
permitted.
### Library choices
These are pinned for all implementations. Changing a library mid-project
requires re-verifying interoperability end-to-end.
| Concern | Runtime | Library | Version |
|---|---|---|---|
| ML-KEM-768 + ML-DSA-44 | Browser extension (MV3 service worker) | `@noble/post-quantum` (npm) | 0.6.1 |
| ML-KEM-768 + ML-DSA-44 | Web companion stub (normal page context) | `@noble/post-quantum` (npm) — resolved, see below | 0.6.1 |
| ML-KEM-768 + ML-DSA-44 | Node.js setup script | `@oqs/liboqs-js` (npm) | 0.15.1 |
| ML-KEM-768 + ML-DSA-44 | Android / Kotlin | BouncyCastle (liboqs-java for production) | — |
| ML-KEM-768 + ML-DSA-44 | Java/Spring (production) | liboqs-java | — |
| X25519 | Browser extension (MV3 service worker) | `@noble/curves` (npm) | 2.3.0 |
| X25519 | Node.js (companion test harness, setup script) | `@noble/curves` (npm) | 2.3.0 |
| JCS (RFC 8785) | Node.js | `canonicalize` (npm) | 3.0.0 |
| JCS (RFC 8785) | Browser | `canonicalize` (same package, ESM build) | 3.0.0 |
| JCS (RFC 8785) | Android / Kotlin | `io.github.erdtman:java-json-canonicalization` (Maven Central) | — |
| JCS (RFC 8785) | Java/Spring | `io.github.erdtman:java-json-canonicalization` (Maven Central) | — |
**X25519 had no pinned library at all until the run-flow slice.** Native
WebCrypto X25519 support is inconsistent enough across Chrome versions to be
a real risk, and untested here — rather than add a second unverified
platform dependency alongside ML-KEM/ML-DSA, `@noble/curves` was chosen
specifically because it's the same audited "noble" family as
`@noble/post-quantum`, already proven working in this exact service worker
context (pure JS, static imports, no WASM). Used on the Node side
(companion test harness, and available to the setup script) too, so the
X25519 implementation isn't duplicated across two different libraries for
one shared protocol.
**Resolved 2026-08-16 — `@oqs/liboqs-js` does not work in the extension's
service worker, empirically, not just theoretically.** This was flagged
below as something to verify before writing any ML-KEM/ML-DSA code; it's now
verified. Built a minimal MV3 extension, loaded it in real Chrome via
Puppeteer/CDP (not a simulated environment), and read the actual service
worker console: `@oqs/liboqs-js` lazy-loads each algorithm's WASM via
`await import(...)` computed inside its own module, and Chrome threw
`TypeError: import() is disallowed on ServiceWorkerGlobalScope by the HTML
specification` — a hard platform restriction (referenced in the error:
[w3c/ServiceWorker#1356](https://github.com/w3c/ServiceWorker/issues/1356)),
not a bug or a config problem to work around.
**The extension now uses `@noble/post-quantum@0.6.1`** (npm, pure
TypeScript, audited by Trail of Bits, static imports only, no WASM) — this
was already named below as the fallback for exactly this scenario. Verified
working the same way: real ML-DSA-44 signature from `dev/certs/test-registration.b64`
verified correctly inside the actual service worker, plus a tampered-payload
negative control confirmed to fail. One API difference from `@oqs/liboqs-js`
worth flagging for implementers: argument order is
`verify(signature, message, publicKey)`, not `verify(message, signature, publicKey)`.
**Resolved — web companion stub standardizes on `@noble/post-quantum`,
same as the extension.** The web companion stub runs in a normal page
context (`mobile/claude.md`: "single-page web application," no service
worker), where dynamic `import()` is allowed, so `@oqs/liboqs-js` would
have worked there in principle — this wasn't ruled out the way it was for
the extension. But there was no reason to carry it: `scripts/companion-harness.mjs`,
the closest existing reference for this component's protocol behaviour, was
already built against `@noble/post-quantum`, and standardizing avoids
maintaining a second, ~17MB WASM PQ library across the two browser-side
components for no interoperability benefit — both still speak the same
wire format regardless of which library produced the bytes.
**`@oqs/liboqs-js`:** Published by PQCA (Post-Quantum Cryptography Alliance),
WASM bindings to the reference liboqs implementation, zero npm dependencies,
MIT licence. Covers both ML-KEM-768 and ML-DSA-44 in a single package.
Tracks the NIST reference implementation directly, reducing the risk of subtle
algorithm deviations. Unpacked size is ~17 MB. Still the right choice for the
Node.js setup script (confirmed working — see `scripts/setup-dev.js`); ruled
out for the extension, and not used by the companion either (above).
**JCS consistency:** All four runtimes use libraries from the same author
(Samuel Erdtman, editor of RFC 8785). This maximises the likelihood of
byte-identical canonical forms across platforms. Any JCS library substitution
requires a cross-platform signature round-trip test before adoption.
### Deferred decisions
- **At-most-once key bundle delivery** — the key bundle must be atomically
destroyed on first fetch. This is a load-bearing security property: it converts
a QR hijacking attack from a network race to a physical race. Implementation:
Redis `GETDEL` or database `DELETE ... RETURNING`.
- **Delegate server trust chain** — self-hosted delegate servers require the main
backend to countersign the delegate's Ed25519 key. The pinned-key path is the
only active path in the PoC.
- **Key rotation** policy and procedure
- **Certificate revocation** mechanism (bloom filter design — deferred)
- **Hardware-backed key storage** in the companion app (deferred)
- **Android Keystore** integration (deferred)
### Algorithm alternatives
The following are drop-in substitutions that would change key sizes or security
margins but require no architectural changes. None affect the system structure,
flows, or data formats beyond the keys themselves.
**ML-KEM-1024 in place of ML-KEM-768.** Raises the KEM security level from
approximately AES-192 to AES-256 equivalent. Public key grows from 1184 to
1568 bytes; ciphertext from 1088 to 1568 bytes. Since the key bundle is stored
server-side and never carried in the QR code, there is no visual or UX cost —
only marginally larger server storage and network payloads.
**X448 in place of X25519.** Uses Curve448 (224-bit classical security) rather
than Curve25519 (128-bit). Public keys grow from 32 to 56 bytes. The practical
improvement is modest given that the classical component is already in a hybrid
with ML-KEM, but if ML-KEM-1024 is adopted, X448 brings the classical component
to a consistent security level.
**Falcon-512 in place of Ed25519.** Falcon-512 (NIST FIPS 206) is the only
post-quantum signature scheme compact enough to be a realistic candidate for
QR-based signing. Its signatures are 666 bytes — substantially larger than
Ed25519's 64 bytes, but far smaller than ML-DSA-44's 2420 bytes. A 666-byte
signature, combined with the rest of the QR payload, produces a scannable code
at around QR version 2025, which remains practical.
Adopting Falcon-512 would eliminate the one remaining classical signature from
the system. The argument for keeping Ed25519 is that it only needs to hold for
60 seconds; the argument for Falcon-512 is that the QR size cost is manageable
and a fully post-quantum system is cleaner. Either is defensible. The switch
requires redeployment of backend, extension, and companion (new keys throughout)
but no structural changes.
**HKDF-SHA384 or SHA512 in place of SHA256.** If ML-KEM-1024 is adopted,
upgrading HKDF's hash brings key derivation to a consistent security level.
The practical difference is negligible — HKDF uses the hash as a PRF, not for
collision resistance — but it avoids mixing security levels across the scheme.