355 lines
19 KiB
Markdown
355 lines
19 KiB
Markdown
# 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 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.
|