18 KiB
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) | @oqs/liboqs-js (npm) — unconfirmed, see below |
0.15.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),
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).
The web companion stub row above is not yet tested — it 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 should
still work there in principle. Whether to keep two different PQ libraries
across the browser-side components, or standardize on @noble/post-quantum
everywhere for consistency (simpler, no WASM asset handling anywhere), is an
open call for whoever builds the companion — not yet decided.
@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) and
possibly the web companion stub (unconfirmed); ruled out for the extension.
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
GETDELor databaseDELETE ... 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 20–25, 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.