complete documentation

This commit is contained in:
Rasmus Neikes 2026-08-16 12:36:29 +02:00
parent 5c03fabd6b
commit ee1b7e1667
13 changed files with 2665 additions and 38 deletions

View File

@ -52,49 +52,60 @@ Extending the scope of LOCQR to gain accelerated financial viability results in
### Mobile Companion App
Leveraging mobile platforms with their combination of hard- and software that is designed for data saftey seems like a natural choice to store user data and secrets. Harsware vaults secure data at rest, and take care of isolating it from other apps and unauthorized users. Potentially doubles as a 2FA token - i.e. the choice to put passwords onto a separate device gives us the option to tie the device to its user and explicitely ensure that it is part of the login process. The mobile app is where data is stored at rest, and the main user stories are providing that data to the website and receiving the data from other systems to be stored in the first place. That transmision of that data neets to run across secure channels - where "secure" means safe from encryption attempts for the foreseeable future, assumming adversaries with access to uantum computing. For gybbr, specfically, password creation in dedicated formats should also be provided. (We are contemplating an abstraction of that feature that would make it available to other services as well.)
Leveraging mobile platforms with their combination of hard- and software that is designed for data safety seems like a natural choice to store user data and secrets. Hardware vaults secure data at rest, and take care of isolating it from other apps and unauthorized users. Potentially doubles as a 2FA token - i.e. the choice to put passwords onto a separate device gives us the option to tie the device to its user and explicitly ensure that it is part of the login process. The mobile app is where data is stored at rest, and the main user stories are providing that data to the website and receiving the data from other systems to be stored in the first place. That transmission of that data needs to run across secure channels - where "secure" means safe from encryption attempts for the foreseeable future, assuming adversaries with access to quantum computing. For gybbr, specifically, password creation in dedicated formats should also be provided. (We are contemplating an abstraction of that feature that would make it available to other services as well.)
Source of trust: The mobile app is distributed through platforms like the official app stores of apple and google. It will have hardcoded keys that allow it to identify the central servr as a legitimate source of information.
Source of trust: The mobile app is distributed through platforms like the official app stores of Apple and Google. It will have hardcoded keys that allow it to identify the central server as a legitimate source of information.
### Browser Extension
At the core, we needed a way to encapsulate encryption keys away from the website, to prevent anyone from lifting encryption keys out of the gybbr websire whilst in use. Essentially, mimicking the core functionality of CryptoKey in the WebCRypot API. The WebCrypto API itself was deemed insufficient, as it does not yet provide post uantum security. Other security aspects were discovered during early planning, e.g. by providing its own UI outside of the website DOM and display boundaries, phishing attemps by look-alike websites could be prevents.
At the core, we needed a way to encapsulate encryption keys away from the website, to prevent anyone from lifting encryption keys out of the gybbr website whilst in use. Essentially, mimicking the core functionality of CryptoKey in the WebCrypto API. The WebCrypto API itself was deemed insufficient, as it does not yet provide post-quantum security. Other security aspects were discovered during early planning, e.g. by providing its own UI outside of the website DOM and display boundaries, phishing attempts by look-alike websites could be prevented.
The Browser Extensions is main hub for communication, it interacts with websites - directly and through the JS SDK, with the user through its own UI. It communicates with the backend server - both as a relay and provider of information - and the mobile - directly through the display of QR codes that can be read by the phone, as well as through the backend server as a relay and security gate.
The Browser Extension is the main hub for communication, it interacts with websites - directly and through the JS SDK, with the user through its own UI. It communicates with the backend server - both as a relay and provider of information - and the mobile - directly through the display of QR codes that can be read by the phone, as well as through the backend server as a relay and security gate.
Source of trust: The broiwser extension will be provided through official websites, from the company or entity that lies at the core of gybbr and LOCQR. It will have hardcoded keys that allow it to identify the central servr as a legitimate source of information.
Source of trust: The browser extension will be provided through official websites, from the company or entity that lies at the core of gybbr and LOCQR. It will have hardcoded keys that allow it to identify the central server as a legitimate source of information.
### Backend Server
The backend server provides two main services. Account management, and communicartion relay.
The backend server provides two main services. Account management, and communication relay.
For the SaaS offered by LOCQR, participating websites need account manangement for registration, settinga, payment, etc. For the purposes of development, these functions will be performed by the backend server. Whilst most of this can be provided via a stand alone website, some of the settings and other data needs to be made available to the relay-part of the system - hence the descision to treat both aspects as one single entity, at least initially.
For the SaaS offered by LOCQR, participating websites need account management for registration, settings, payment, etc. For the purposes of development, these functions will be performed by the backend server. Whilst most of this can be provided via a standalone website, some of the settings and other data needs to be made available to the relay-part of the system - hence the decision to treat both aspects as one single entity, at least initially.
Mainly, however, the backend server will facilitate communication between the broiwser extension and mobile phones. Both entities have eüheremal existance in the sense that they are not provided with any reliable identitis, domains or IP Addresses. In order to communicate, they need a relay station with a known access point: A backend server with a fixed domain.
Mainly, however, the backend server will facilitate communication between the browser extension and mobile phones. Both entities have ephemeral existence in the sense that they are not provided with any reliable identities, domains or IP addresses. In order to communicate, they need a relay station with a known access point: a backend server with a fixed domain.
The server will also be used to identify websites for the extention - websites with an account will register their domains with the backend server; the extension can challenge the server to confirm that any websites is legitimate by providing its URL to the server. This isolates differentr websites; it prevents spoofing of the domain (since the extension can access it from outside of the website content space) and ties it more fluid information like account lifetime.
The server will also be used to identify websites for the extension - websites with an account will register their domains with the backend server; the extension can challenge the server to confirm that any website is legitimate by providing its URL to the server. This isolates different websites; it prevents spoofing of the domain (since the extension can access it from outside of the website content space) and ties it to more fluid information like account lifetime.
As part of the relaying functionality, the backend server will provide and monitor differnet one-time-tokens used by the other entities; it will guard transmission from expired or invalid credentials, so that only the intended receivers can connect to communications initiated by any seder.
As part of the relaying functionality, the backend server will provide and monitor different one-time tokens used by the other entities; it will guard transmission from expired or invalid credentials, so that only the intended receivers can connect to communications initiated by any sender.
Soure of trust: The backend server is controlled by the entity resposnible for the project, it will communicate through SSL. It's domain and certificte will identify it. It will possess the secret key that lies at the center of all legitimacy within the system as described later.
Source of trust: The backend server is controlled by the entity responsible for the project, it will communicate through SSL. Its domain and certificate will identify it. It will possess the secret key that lies at the center of all legitimacy within the system as described later.
### JavaAScript SDK
#### Self-Hosted Delegate Server
Embedded into a customer website (or the gybber website), it provides a well-defined interface for any communication between the website and the extension. TRhe extension will provide normal messaging to an from the website; and this will simply make these avaialbe to developers without any need for them to create their own boilerplate.
An option planned for future website administrators is the availability of a self-hosted backend server. All of its main functions will be taken over by the delegate server: token verification, traffic relaying, etc. The sole purpose of that construct is to allow offering a paid tier of the service with unlimited usage. Since the compute costs for crypto operations on the backend server are relatively high, a free tier would otherwise result in a high risk for ourselves; whilst a per-transaction model would create a similar risk for highly popular websites with many user logins.
Source of trust: The SDK is provided by the project ownners and as such, the security can be verified. The site is out of scope for the sake of this document: Once information has been passed to the website, it has left the secure sphere of this project.
The technical solution here is simply to extend the per-website certificate to include an optional URL for the delegate server of that specific website; and include it under the umbrella of the backend server's signature. All communication between the main backend server and any delegate server will be independent of individual transactions. There might be an exchange of revocation notifications, or the account management could still be handled by the main server, etc.
Source of trust: Hosted by the same entity as the website that it is being used for; and authorized by the same source of the extension, the companion app, that the user is already familiar with.
Scope: For future consideration. Currently not referenced anywhere else.
### JavaScript SDK
Embedded into a customer website (or the gybbr website), it provides a well-defined interface for any communication between the website and the extension. The extension will provide normal messaging to and from the website; and this will simply make these available to developers without any need for them to create their own boilerplate.
Source of trust: The SDK is provided by the project owners and as such, the security can be verified. The site is out of scope for the sake of this document: once information has been passed to the website, it has left the secure sphere of this project.
### Website
This refers to the websites provided by gybbr or LOCWR customers directly. The user accounts and services on these are the beneficators of the security provided by LOCWR. During developmtn, these websites will be represneted by a simple test site that will be ussed to trigger different events and process flows.
This refers to the websites provided by gybbr or LOCQR customers directly. The user accounts and services on these are the beneficiaries of the security provided by LOCQR. During development, these websites will be represented by a simple test site that will be used to trigger different events and process flows.
Source of trust: External to the project. Users decide to use these websites and trust them for their own reasons. That trust is out of scope for LOCR. However, the websites can signal their legitimacy as regsitered participants of our servec throgh a token that signs their URL and an expiry timestamp with the secret key held by the backend server. This identifies the specific account; and will allow the system to guard the site from interacting with any data stored for other websites or services.
Source of trust: External to the project. Users decide to use these websites and trust them for their own reasons. That trust is out of scope for LOCQR. However, the websites can signal their legitimacy as registered participants of our service through a token that signs their URL and an expiry timestamp with the secret key held by the backend server. This identifies the specific account; and will allow the system to guard the site from interacting with any data stored for other websites or services.
### User
Thoug unorthodox, we chose to include the end user as part of the entire system. They participate in different processes not only by triggering specific flows but because their choices and confirmations will influence the control flow of the entire system, and because we need to be prepared for users to make mistakes or simly fail to act somewaht freuently. Is the user using the correct device, and app, to scan the correct R code? If the answer to any of these uestions is "no" we need to make sure that the conseuences of the mistake are ngelibile.
Though unorthodox, we chose to include the end user as part of the entire system. They participate in different processes not only by triggering specific flows but because their choices and confirmations will influence the control flow of the entire system, and because we need to be prepared for users to make mistakes or simply fail to act somewhat frequently. Is the user using the correct device, and app, to scan the correct QR code? If the answer to any of these questions is "no" we need to make sure that the consequences of the mistake are negligible.
Source of trust: The user owns all data, they are trusted implicitely and by design.
Source of trust: The user owns all data, they are trusted implicitly and by design.
## Connections
@ -133,21 +144,37 @@ The entities can communicate via different media and channels:
```
## Trust and Secure Channel
In order to achieve the main goal of the project - the safe delivery of login credentials to a website - elements within the system need to trust each other. The description of the entities describes why and how they can be trusted individually, but it is important to understand how that individual trust can be assembled into a chain of trust that allows a secure connection between the extension and the mobile phone.
Trust in the system is rooted in compile-time constants. The extension and companion app each carry pinned public keys for the backend server, baked in at build time. Nothing at runtime can substitute them. All session legitimacy traces back to the backend server's ability to sign, and the clients' ability to verify against those constants.
The secure channel between extension and companion is bootstrapped through the QR code. The QR code is not itself the channel — it is a physical, air-gap-equivalent link that carries a cryptographic commitment to a one-time key bundle stored server-side, together with a backend-signed token that binds that commitment to a specific URL, run ID, and TTL. A network attacker cannot intercept what is on screen. The companion verifies the backend's signature before touching anything else.
Once the companion has fetched and verified the one-time key bundle — atomically, exactly once, from the backend — it performs a hybrid classical/post-quantum key encapsulation. Both sides independently derive the same session key. A six-digit code derived from that key is displayed on both devices simultaneously; the user confirms the match, proving both sides share the same key. From that point, all extension-to-companion traffic is end-to-end encrypted and relayed through the backend, which cannot read it.
The cryptographic primitives and exact parameters behind each of these steps are specified in crypto.md. Individual flows that use this channel are described in flows.md.
## Structure
The following structure of files and folders oints to the roots of the different subrojects as well as the remaining documentation.
The following structure of files and folders points to the roots of the different subprojects as well as the remaining documentation.
locqr/
├── claude.md ← root overview (current)
├── crypto.md ← encryption scheme
├── flows.md ← flows
├── extension/
│ └── claude.md
├── mobile/
│ └── claude.md
├── sdk/
│ └── claude.md
├── server/
│ └── claude.md
└── website/
└── claude.md
locqr/
├── claude.md ← root overview (current)
├── crypto.md ← encryption scheme and library choices
├── flows.md ← end-to-end flows
├── interfaces.md ← wire formats and cross-component message schemas
├── dev.md ← local development environment and setup script
├── extension/
│ └── claude.md
├── mobile/
│ └── claude.md ← native Android app + web stub; see also companion/
├── companion/ ← web companion stub source; documented in mobile/claude.md
├── sdk/
│ └── claude.md
├── server/
│ └── claude.md
└── website/
└── claude.md

314
crypto.md
View File

@ -1 +1,315 @@
# 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 / web stub | `@oqs/liboqs-js` (npm) | 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 | — |
| 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) | — |
**`@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; the WASM binary will be large in
the extension bundle. Before writing any ML-KEM or ML-DSA code, verify that the
async `init()` call is compatible with the Manifest V3 service worker
environment: no DOM dependency, compatible with `import()` or `importScripts`.
**Fallback:** If `@oqs/liboqs-js` proves incompatible with the service worker
context, `@noble/post-quantum@0.6.1` (npm, pure TypeScript, audited by Trail
of Bits) covers both algorithms and is unconditionally service-worker safe. It
is not WASM; use it only if the WASM init path cannot be made to work. Any
switch from `@oqs/liboqs-js` to `@noble/post-quantum` requires a full
cross-component interoperability test before adoption.
**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.

178
dev.md Normal file
View File

@ -0,0 +1,178 @@
# Development environment
The LOCQR development environment is four components running locally, wired
together by a single setup script. Running the script once produces a fully
functional environment; re-running it on a new machine reproduces the same
environment without regenerating any committed artifacts.
---
## Components
| Component | Directory | Default URL |
|---|---|---|
| Test website | `website/` | `https://test.locqr.dev:5173` |
| Node server stub | `server/` | `https://api.locqr.dev:3000` |
| Web companion stub | `companion/` | `https://test.locqr.dev:5174` |
| Browser extension | `extension/` | Loaded unpacked in browser |
All four must run simultaneously for end-to-end testing. Start order:
server stub → extension (load unpacked) → test website → companion stub.
---
## Setup script
**`scripts/setup-dev.sh`** (or `setup-dev.js`) — run once per machine.
Idempotent: re-running it on an existing environment is safe and changes
nothing if all committed artifacts are already in place.
### What it does
**1. mkcert**
Checks that `mkcert` is installed; prints install instructions and exits if
not. Installs the local CA (`mkcert -install`) if not already trusted. Exports
the root CA certificate to `dev/certs/rootCA.pem` for sharing with other
developers.
Generates TLS certificates if not already present:
- `dev/certs/test.locqr.dev.pem` + `test.locqr.dev-key.pem`
- `dev/certs/api.locqr.dev.pem` + `api.locqr.dev-key.pem`
The generated cert files are committed to the repository. On other machines
the script skips generation and just installs the root CA, making the
committed certs trusted.
**2. Signing keypairs**
Checks for committed keypair files. If present, reads them. If absent
(first-ever run on a clean checkout), generates them and prints a reminder
to commit the result.
- ML-DSA-44 keypair → `dev/keys/ml-dsa-44-public.b64` and
`dev/keys/ml-dsa-44-secret.b64`
- Ed25519 keypair → `dev/keys/ed25519-public.b64` and
`dev/keys/ed25519-secret.b64`
These are dev-only artifacts. The production server never commits key material;
the dev stub does. The files are clearly namespaced under `dev/` to prevent
confusion with production secrets.
**3. Pinned constants**
Patches the public key constants into the source files that carry them:
| Constant | File |
|---|---|
| `BACKEND_PUBLIC_KEY_B64` | `extension/src/constants.ts` |
| `ED25519_PUBLIC_KEY_B64` | `extension/src/constants.ts` |
| `ED25519_PUBKEY` | `companion/src/constants.ts` (web stub) |
| `ED25519_PUBKEY` | `mobile/src/main/java/.../Constants.kt` (native, when it exists) |
**4. Test registration certificate**
Issues a registration certificate for `test.locqr.dev` signed with the
ML-DSA-44 dev key. Writes it to `dev/certs/test-registration.b64`. The
server stub reads this file at startup and serves it from a known endpoint.
The test website fetches it on load and passes it to `locqr.init()`.
**5. Extension identity**
Checks that `extension/key.pem` is present. If missing (first-ever run),
generates it and prints a reminder to commit it. This file pins the unpacked
extension's ID; committing it ensures the ID is stable across machines and
rebuilds. The test website and the extension manifest both use the ID derived
from this key.
**6. Environment files**
Writes `server/.env.dev` with the dev signing key paths and local domain
config, overwriting any previous version. Components read this file at
startup; it is not committed (listed in `.gitignore`), as it contains paths
that may differ per machine.
```
ML_DSA_44_SECRET_KEY_PATH=../../dev/keys/ml-dsa-44-secret.b64
ED25519_SECRET_KEY_PATH=../../dev/keys/ed25519-secret.b64
TEST_DOMAIN=test.locqr.dev
API_PORT=3000
```
---
## Hardcoded defaults
These values are baked into the script and into the extension manifest. They
can be overridden by passing arguments to the setup script but the defaults
are what every developer uses unless there is a specific reason to change them.
| Setting | Default |
|---|---|
| Test site domain | `test.locqr.dev` |
| API domain | `api.locqr.dev` |
| Test site port | `5173` |
| API port | `3000` |
| Companion stub port | `5174` |
These domains require a hosts file entry on each machine:
```
127.0.0.1 test.locqr.dev
127.0.0.1 api.locqr.dev
```
The setup script checks for these entries and prints instructions if they are
missing. It does not modify the hosts file automatically.
---
## Committed artifacts
These files are generated once and committed. They must not be regenerated
unless there is a deliberate decision to rotate keys or change domains, as
regeneration would invalidate every other developer's environment.
```
dev/
├── certs/
│ ├── rootCA.pem ← mkcert CA cert; import on new machines
│ ├── test.locqr.dev.pem
│ ├── test.locqr.dev-key.pem
│ ├── api.locqr.dev.pem
│ ├── api.locqr.dev-key.pem
│ └── test-registration.b64 ← ML-DSA-44 signed cert for test.locqr.dev
└── keys/
├── ml-dsa-44-public.b64
├── ml-dsa-44-secret.b64 ← dev only; never use in production
├── ed25519-public.b64
└── ed25519-secret.b64 ← dev only; never use in production
extension/
└── key.pem ← pins the unpacked extension ID
```
---
## Adding a new machine
1. Install `mkcert` (OS package manager or https://github.com/FiloSottile/mkcert)
2. Clone the repository (all committed artifacts are already present)
3. Run `scripts/setup-dev.sh` — installs the root CA and writes `.env.dev`
4. Add the hosts file entries for `test.locqr.dev` and `api.locqr.dev`
5. Load the extension unpacked from `extension/dist/` (or `extension/` once
built)
---
## Rotating dev keys
If the signing keys must be rotated (e.g. after an accidental exposure):
1. Delete `dev/keys/*` and `dev/certs/test-registration.b64`
2. Run `scripts/setup-dev.sh` — regenerates keys, patches constants, reissues
the registration cert
3. Commit all changed files
4. All other developers re-run `scripts/setup-dev.sh` on their next pull
The extension constants will change, requiring a rebuild of the extension.
The extension ID is unaffected (it is derived from `key.pem`, which is not
rotated).

View File

@ -1 +1,425 @@
# Browser Extension
The extension is the central hub of the LOCQR system. It interacts with websites
through the JS SDK, communicates with the backend server directly and as a relay
to the companion, and presents its own UI outside of the website's DOM. For GYBBR
it additionally acts as an isolated cryptographic vault (see below).
## Tech stack
- Manifest V3
- TypeScript
- Background: service worker
- Persistent session state: `chrome.storage.session` (survives service worker
suspension, cleared on browser close)
- External messaging: `externally_connectable` — declared in manifest; allows
the JS SDK (running in the page context) to open a Port directly to the
service worker without a content script
- Compiled-in key constants: `BACKEND_PUBLIC_KEY_B64` (ML-DSA-44),
`ED25519_PUBLIC_KEY_B64` (Ed25519)
## Internal components
### Background service worker
Owns all cryptographic operations, run state, backend communication, and
site verification logic. It is the only context that ever holds private key
material. It has no access to the website DOM and cannot be reached by website
scripts.
The service worker may be suspended by the browser at any time. In-memory state
that must survive suspension is stored in `chrome.storage.session` before the
worker yields. In-memory state that must not survive (e.g. LOCQR run keys)
is left in memory only and intentionally discarded on suspension.
### Popup UI
Rendered in the extension's own window, outside the website's display boundaries.
This physical separation is a deliberate security property: a phishing or
look-alike site cannot replicate or overlay the extension popup.
The popup can always be opened regardless of the current tab state. It always
shows at minimum the current state. What it displays and which interactions it
offers are fully determined by the per-tab state machine defined below. Visual
design, layout, icon assets, and animation details are specified in `gui.md`.
### External messaging
The SDK communicates with the service worker directly via `externally_connectable`
— no content script is required. The SDK opens a long-lived Port to the
extension; the service worker receives the Port connection, reads the real page
URL from `sender.tab.url` (browser-provided, cannot be spoofed), and
communicates bidirectionally through the Port for the lifetime of the run.
## State model
State is per-tab. Every tab runs its own independent instance of the state
machine. The popup reflects the state of the currently active tab.
### States
**Verification states**
- `unverified` — initial state on every page load; cert check not yet complete.
- `not_registered` — SDK connected without supplying a certificate.
- `cert_invalid` — certificate present but fails local verification. Sub-reasons
determine the security classification and user-facing response:
*Security-class* (possible attack; extension notifies backend; user sees an
explicit security warning, not a generic error):
- `domain_mismatch` — cert's domain field does not match the page origin. Most
likely a phishing site serving a stolen or misrouted cert. A `domain_mismatch`
where the cert's domain is itself a valid registered LOCQR domain is a
stronger attack signal than an unrecognised domain.
- `signature_invalid` — cert is parseable but signature fails against the
pinned `BACKEND_PUBLIC_KEY_B64`. Indicates a forged or tampered cert.
- `malformed` — cert cannot be parsed. May indicate garbage served
deliberately by an attacker, or a severely broken deployment.
*Operational-class* (admin error; user is informed; no backend notification):
- `insecure_origin` — page is served over HTTP. LOCQR requires HTTPS; the
extension will not activate on any non-HTTPS origin. Checked before any
other cert logic.
- `expired``expires_at` is in the past; admin must renew.
- `not_yet_valid``issued_at` is in the future; cert deployed prematurely.
- `account_error` — certificate valid locally; backend cannot confirm the
account. Sub-reasons:
- `rejected` — account is suspended or deactivated.
- `unreachable` — backend could not be reached; status unknown. Displayed
distinctly from a confirmed rejection.
- `idle` — certificate valid, account confirmed; no active run.
**Run states** (only reachable from `idle`)
- `phase_1` — ephemeral keypairs generated; key bundle uploading to backend;
awaiting signed token. On success the backend returns the signed token together
with server-controlled run parameters (see *Session parameters* below).
- `phase_2` — QR code displayed with a countdown derived from `qr_ttl`. The
extension tracks `auto_refresh_remaining`. On TTL expiry:
- If `auto_refresh_remaining > 0`: new keypairs are generated, a new key bundle
is uploaded, and a new QR is displayed automatically; counter decremented.
The old key bundle is abandoned and will expire server-side.
- If `auto_refresh_remaining == 0`: transition to `run_error / ttl_exhausted`;
user is offered a manual retry.
Awaiting companion scan and key exchange.
- `phase_3` — run key derived; PIN displayed with a countdown derived from
`pin_ttl`. Awaiting user confirmation on the popup. On TTL expiry: transition
to `run_error / pin_timeout`.
- `phase_4` — user confirmed PIN; awaiting encrypted credential from relay.
- `delivered` — credential decrypted and handed to the JS SDK; transitions
automatically to `idle` after brief display.
- `run_error` — run failed or aborted at any phase; reason and class
held for display. Three classes:
*Security-class* (extension notifies backend; user sees explicit security
warning; no retry offered):
- `alpha_hash_mismatch` — key bundle received from backend does not match the
commitment in the QR; indicates tampering between extension and backend.
- `pin_mismatch` — run keys diverged; should not occur if all prior
verifications passed.
*Timeout / user class* (expected; user offered retry):
- `ttl_exhausted` — QR auto-refreshes used up; user must manually retry.
- `pin_timeout` — user did not confirm PIN within `pin_ttl`; run abandoned.
- `user_abort` — user cancelled at any phase.
*Network class* (transient; user offered retry):
- `backend_unreachable` — backend could not be reached during Phase 1 or
Phase 4.
- `relay_timeout` — no message received from relay within expected window.
- `relay_error` — relay returned an error or malformed response.
### Transitions
```
page load
→ unverified
→ not_registered (cert absent)
→ cert_invalid (cert present; local check fails)
→ account_error (cert valid locally; backend rejects or unreachable)
→ idle (cert valid; account confirmed)
idle
→ phase_1 (SDK login request received)
phase_1
→ phase_2 (key bundle uploaded; signed token + run
parameters received)
→ run_error/backend_unreachable (backend unreachable; upload fails)
phase_2
→ phase_3 (kem_ciphertext received via relay WebSocket;
run key derived)
→ phase_1 (auto-refresh) (qr_ttl expired; auto_refresh_remaining > 0;
old bundle abandoned)
→ run_error/ttl_exhausted (qr_ttl expired; auto_refresh_remaining == 0;
also the outcome when the companion aborted
on bundle_consumed — extension cannot
distinguish these cases)
→ run_error/relay_error (relay error)
→ run_error/user_abort (user cancels)
phase_3
→ phase_4 (user confirms PIN)
→ run_error/pin_timeout (pin_ttl expired)
→ run_error/pin_mismatch (security: run keys diverged)
→ run_error/user_abort (user rejects or cancels)
phase_4
→ delivered (credential received; GCM auth passes)
→ run_error/relay_timeout (no message within expected window)
→ run_error/relay_error (relay error or GCM auth failure)
→ run_error/user_abort (user cancels)
delivered
→ idle (automatic after brief display)
run_error
→ phase_1 (user retries; security-class errors do not offer
retry)
→ idle (user dismisses)
```
User abort at any run phase transitions to `run_error / user_abort`.
Network errors and timeouts are expected events, not failures — the user is
offered a retry without a warning. Security-class errors do not offer retry;
the user must dismiss and the error is reported to the backend.
**Design note — `bundle_consumed` not signalled to extension.** When a QR
hijacker fetches the key bundle before the legitimate companion, the companion
detects a `404`, enters `error/bundle_consumed`, and reports to
`/security/report`. The extension has no way to learn this: the bundle is
already gone, and the server has no record linking the second-fetch failure
to the extension's open WebSocket. The extension simply times out on QR TTL
expiry and enters `run_error/ttl_exhausted`. To give the extension an
immediate security-class signal, the server would need to push
`{ type: "bundle_consumed" }` over the relay WebSocket on the second-fetch
attempt — which requires a short-lived "consumed run IDs" record on the
server, separate from the deleted bundle. This is straightforward to add if
the UX distinction between "timeout" and "possible hijack" proves important.
### Run parameters
All timing values are server-controlled. The backend returns a session
parameters blob alongside the signed token at the end of Phase 1. The
extension uses these values directly and does not apply any local defaults
or overrides.
| Parameter | Governs |
|---|---|
| `qr_ttl` | How long a single QR code remains valid (Phase 2 countdown) |
| `max_auto_refresh` | How many times the QR is automatically refreshed before the user must retry manually |
| `pin_ttl` | How long the user has to confirm the PIN (Phase 3 countdown) |
Further per-phase timeouts (e.g. Phase 4 relay wait) may be added to this blob
as the design progresses.
### Navigation rules
Navigation is detected via `chrome.tabs.onUpdated`, which fires whenever a
tab's URL changes. No content script is required; the extension observes URL
transitions from the background service worker. On same-domain SPA navigation
where `onUpdated` does not fire (hash changes, History API pushState without a
full page load), the open Port from the SDK remains active — the extension
retains its state until the SDK reconnects on a new page load. This is an
acceptable risk given that a legitimate same-domain navigation produces a new
SDK Port connection, and the extension resets if a new connection arrives with
a different or absent cert.
Pages with no LOCQR SDK installed never open a Port. Those tabs remain in
`unverified` (neutral icon) indefinitely. This is the correct behaviour for
non-LOCQR pages; no timeout or fallback transition is applied.
**Cross-domain navigation** — all tab state is discarded; the tab re-enters
`unverified`. Any in-flight run (key bundle on the backend, open relay
connection) is abandoned; the key bundle expires via TTL.
**Same-domain navigation** — the certificate is re-checked on the new page:
- Cert valid and identical to the current cert → state preserved; active run
continues uninterrupted.
- Cert valid but different (e.g. renewed by admin) → active run discarded;
tab moves to `idle` under the new cert.
- Cert invalid or absent → active run discarded; tab moves to `cert_invalid`
or `not_registered`.
A cert change mid-run terminates the run because the cert is an implicit
precondition of the run's trust basis: the URL in the signed token is
legitimised by the cert that was valid when the run started.
### UI mapping
The icon is always visible in the browser toolbar. The popup can always be
opened; it always shows at minimum the current state.
| State | Icon | Popup content | Popup actions |
|---|---|---|---|
| `unverified` | neutral | Checking… | — |
| `not_registered` | inactive | Site not registered with LOCQR | — (future: register site) |
| `cert_invalid / insecure_origin` | error | LOCQR requires HTTPS | — |
| `cert_invalid` (operational) | error | Certificate error; reason shown | — |
| `cert_invalid` (security) | security warning | Security warning; specific reason | — |
| `account_error / rejected` | error | Account inactive | — |
| `account_error / unreachable` | error | Cannot reach LOCQR server | — |
| `idle` | ready | Site verified; ready | Start run |
| `phase_1` | animated | Connecting… | Abort |
| `phase_2` | animated | QR code | Abort |
| `phase_3` | animated | PIN; confirm or reject | Confirm, Abort |
| `phase_4` | animated | Waiting for credential… | Abort |
| `delivered` | success | Credential delivered | — |
| `run_error` (timeout/user) | error | Reason shown (expired, cancelled) | Retry, Dismiss |
| `run_error` (network) | error | Network error; reason shown | Retry, Dismiss |
| `run_error` (security) | security warning | Security warning; specific reason | Dismiss only |
Exact icon assets, animation details, and transition design are specified in
`gui.md`. The mapping above is a simple state → icon lookup; `gui.md` defines
what each icon looks like, whether it is static or animated, and how transitions
between icons are rendered. Security-class errors must use a visually distinct
icon from generic errors — the distinction must be obvious to a user who does
not read the popup text.
### Error escalation
Security-class errors (cert and run) are reported to the backend
immediately. The backend is the natural hub for escalation: it can aggregate
signals across users and sites, detect patterns (e.g. multiple users hitting
`domain_mismatch` for the same domain), and act as the notification path to
site owners.
Site owner notification is a required but deferred design decision. Owners
need to know about security-class errors against their domain — a surge of
`signature_invalid` or `domain_mismatch` events is actionable intelligence.
The mechanism (dashboard alert, email, webhook) and the aggregation policy
(per-event vs. rate-limited summary) are not yet decided. The extension's
responsibility ends at reporting to the backend; delivery to the site owner
is a backend and account-management concern.
### Data storage
| Data | Location | Lifetime |
|---|---|---|
| Compiled-in pubkeys | Source constants | Build time |
| Per-tab state machine state | In-memory (worker) | Tab lifetime |
| Site verification result | `chrome.storage.session` | Browser session |
| Run parameters (qr_ttl, max_auto_refresh, pin_ttl) | In-memory | Phase 14; discarded on delivery or error |
| auto_refresh_remaining counter | In-memory | Phase 2 only |
| Ephemeral keypairs (X25519, ML-KEM-768) | In-memory | Phase 12 only |
| Run key | In-memory | Phase 34; discarded on delivery or error |
| GYBBR identity keys | `chrome.storage.session` | Browser session (see below) |
Private key material is never written to `chrome.storage.local`, `localStorage`,
`IndexedDB`, or any other persistent store.
## Cryptographic responsibilities
Operations the extension performs locally, in the service worker:
- **Site verification:** receives the ML-DSA-44 registration certificate from
the SDK via Port; reads the real page origin from `sender.tab.url`; rejects
immediately if the scheme is not `https:`; verifies the signature against
`BACKEND_PUBLIC_KEY_B64`, checks validity window and domain match against the
origin hostname, then queries the backend for account status.
- **Session initiation:** generates ephemeral X25519 and ML-KEM-768 keypairs,
computes `alpha_hash`, uploads key bundle to backend, receives and verifies
Ed25519-signed token.
- **Run key derivation:** ML-KEM-768 decapsulation, X25519 key agreement,
HKDF-SHA256 combining both shared secrets with `salt=runId_utf8`,
`info="locqr-run-key-v1"`, `length=32`.
- **PIN derivation:** HKDF-SHA256 over the run key with `info="locqr-pin-v1"`.
- **Payload decryption:** AES-256-GCM decryption of the credential received from
the companion via the backend relay.
All algorithm parameters, canonical serialisation, and normative ordering rules
are specified in `../crypto.md`.
## Backend API surface
The extension communicates with the backend over HTTPS. Endpoints used:
- Upload key bundle (run ID, X25519 pubkey, ML-KEM-768 pubkey) →
receive Ed25519-signed token and run parameters.
- Open and maintain a WebSocket connection to `/run/relay/:runId` for
incoming relay messages (kem_ciphertext in Phase 2, encrypted credential in
Phase 4).
- Query account status for a domain (site verification, step 3).
- Report security-class errors to `/security/report`.
Endpoint definitions and all request/response schemas are in `../interfaces.md`.
Endpoint behaviour is in `../server/claude.md`.
## Website / SDK interface
The SDK opens a Port to the service worker via `externally_connectable`. The
service worker receives the connection, verifies the cert supplied by the SDK,
and responds with the verification outcome and authorised feature set. All
subsequent SDK↔extension communication uses this Port.
The extension does not respond to SDK requests from a tab whose verification
outcome is not valid. A site that supplies no cert, an invalid cert, or whose
account is not in good standing receives only the verification error response;
no further extension services are available.
Port message schemas (message types, `LocqrError`, `Credential`) are defined
in `../interfaces.md`. The SDK surface exposed to websites is defined in
`../sdk/claude.md`.
## Security invariants
- Private key material (ephemeral or identity) never leaves the service worker
context. It is never serialised, logged, or passed to any other context.
- Session keys are discarded immediately after credential delivery. They are not
stored in `chrome.storage.session` or anywhere else.
- The extension verifies the backend's Ed25519 signature on the signed token
before displaying the QR code. It does not proceed if verification fails.
- Site verification runs when the SDK connects via Port. A valid outcome is
required before any extension service is offered, including the crypto vault.
- The extension never activates on a non-HTTPS origin. HTTP pages receive
`cert_invalid / insecure_origin` immediately; no cert is inspected, no
backend is contacted.
- The popup UI is rendered outside the website DOM. It cannot be spoofed by
website content.
---
## GYBBR: cryptographic vault (deferred)
The extension doubles as an isolated cryptographic vault for GYBBR. This
functionality is deferred and will be designed in a separate document; the
summary here captures the intended model so that architectural decisions for
the LOCQR core do not inadvertently foreclose it.
### Vault model
The vault exposes a key-ID-based API to registered sites. A website never
receives key material in cleartext; it only receives operation results. The
canonical request form is "encrypt/decrypt this data under key A" or, for
cross-key operations, "re-encrypt from key A to key B."
Keys are loaded from the website in their encrypted form and decrypted inside
the vault using the user's master identity key. The master identity key is
delivered to the extension via the standard LOCQR run and held in
`chrome.storage.session` for the duration of the browser session. The
vault never needs to destroy keys; key-encrypted-under-master leaves and
re-enters the vault across browser sessions.
### Threat model
A session hijack or XSS attack against the website can intercept SDK calls and
observe operation results (ciphertext, plaintext of things the page requested
decrypted). It cannot extract key material from the vault, forge operations the
site is not authorised to request, or access keys scoped to a different domain.
### Domain isolation
Key material is namespaced by the domain of the registered site. A key created
or loaded for site A is inaccessible to site B even if both use the LOCQR
extension and the same underlying key scheme. This isolation is enforced by the
extension, not by the website.
### Feature sets via registration certificate
The registration certificate issued by the backend can encode which extension
features a site is permitted to use (e.g. standard LOCQR login only, or login
plus crypto vault). This allows the extension to offer different feature sets to
different sites under a single consistent trust model, with the backend as the
authority on what each registered domain may access.

210
extension/gui.md Normal file
View File

@ -0,0 +1,210 @@
# Popup Visual Design
Layout, color, and asset specs for the popup UI. State transitions, data, and
which screen maps to which state are defined in `claude.md`; this document
defines what each screen looks like. The popup itself is the browser's native
extension popup window — no custom window chrome is drawn; everything below
starts at the popup's content area.
## Shared chrome
Every run-phase screen (`phase_1``phase_4`, `delivered`) is built from the
same structure:
- **Header** — brand mark (20×20px, rounded 6px, solid accent color) + `locqr`
wordmark (bold, 14.5px) on the left; site origin (e.g. `localhost`) on the
right, secondary text color. Bottom border divider.
- **Body** — 28px top / 22px bottom / 20px horizontal padding, content
centered.
- **Countdown bar** — 4px tall, full width, rounded-full track and fill.
Bound to whichever TTL governs the current phase (`qr_ttl` in phase_2,
`pin_ttl` in phase_3). Same component throughout the run; only the value
driving it changes.
- **Caption** — 12.5px, secondary text color, centered, below the primary
content.
### Color tokens
| Token | Light | Dark |
|---|---|---|
| `--bg` (page, outside popup) | `#eef1f6` | `#0a0d13` |
| `--card-bg` | `#ffffff` | `#12161f` |
| `--card-border` | `rgba(15,23,42,.08)` | `rgba(255,255,255,.08)` |
| `--text-primary` | `#0f172a` | `#f1f5f9` |
| `--text-secondary` | `#667085` | `#94a3b8` |
| `--text-tertiary` | `#94a3b8` | `#64748b` |
| `--accent` | `#2f6fed` | `#5b8bff` |
| `--accent-track` | `#e2e8f0` | `#232a38` |
| `--box-border` (PIN digit boxes) | `#d7dce4` | `#2a3140` |
| `--box-bg` (PIN digit boxes) | `#f8fafc` | `#171c26` |
| `--divider` | `rgba(15,23,42,.07)` | `rgba(255,255,255,.08)` |
Dark mode follows the user's OS/browser theme (`prefers-color-scheme`).
## Phase 2 — QR
- Square QR image, no `border-radius` — rounding clips the finder-pattern
squares, which sit flush in each corner.
- Two colorways, selected by theme:
**Light (default):** plain black (`#1a1a1a`-ish, i.e. the QR library's
standard module color) on white. Quiet zone is baked into the raster: ~4
modules per the QR spec's minimum, measured at the reference asset's actual
module pitch (~3.14px/module → ~13px margin at this asset's resolution).
This one needs a real margin because its background is plain white, not
guaranteed to match whatever it's rendered on.
**Dark (inverted):** modules white (`#ffffff`), background set to the exact
dark `--card-bg` value (`#12161f`) rather than plain black. Cropped tight
to the module grid with **no** baked-in margin — because the raster
background is identical to the card background, the card's own padding
serves as the quiet zone, and no raster pixels are spent on margin.
Confirmed scannable on an older Android device during design review;
treat companion-app support for inverted QR decoding as a real
requirement, not a nice-to-have, since older scanners are the ones most
likely to reject reversed polarity.
- Display size 190×190px in the card body.
- Caption: "Scan with the companion app."
- Countdown bound to `qr_ttl`.
## Phase 3 — PIN confirmation
- 6-digit code, grouped 3+3 (wider gap between groups than within a group —
reads as one code, not two numbers).
- Each digit in its own box: 42×50px, 1.5px border, 10px corner radius,
monospace, 21px / weight 650.
- **Display-only.** The user does not type into these boxes — they compare
the shown code against the companion app's independently-derived value and
tap Confirm. (Confirmation semantics — what mismatch means, what the
underlying value is derived from — are specified in `../crypto.md` and
`claude.md`; this document only covers presentation.)
- Countdown bound to `pin_ttl`, same bar component as phase 2.
- Caption: "Confirm this code matches your companion app."
- Actions: **Confirm** (primary, filled, full width) above **Abort** (ghost /
text button, subdued). Confirm is the expected action and gets the visual
weight; Abort stays available without competing for attention.
## Toolbar icon set
Seven states, all built on the same rounded-square badge used for the header
brand mark (10px corner radius at 36px toolbar size) — the icon system reads
as one family rather than a separate alert-icon library bolted on. Severity
is never carried by color alone: color and glyph change together, so the
distinction holds under color blindness and under a glance too quick to read
text.
| State | Fill | Glyph |
|---|---|---|
| `neutral` (unverified) | `--neutral-badge` (`#94a3b8` / `#475569`) | small dot |
| `inactive` (not registered) | `--inactive-badge` (`#cbd5e1` / `#334155`) | dash |
| `ready` (idle) | `--accent` | checkmark |
| `animated` (phase_14) | `--accent` | rotating dashed ring |
| `success` (delivered) | `--green` (`#16a34a` / `#22c55e`) | bold checkmark |
| `error` (operational, retry-able) | `--amber` (`#f59e0b` / `#fbbf24`) | "!" |
| `security warning` (blocked, reported) | `--red` (`#dc2626` / `#ef4444`) | shield with "!" |
Color pairing is deliberate, not decorative: amber tracks every row in the
`claude.md` state table that offers Retry or needs no action; red tracks
every row that is Dismiss-only and already reported to the backend. The
shield glyph specifically for security warnings borrows the vocabulary
browsers already use for HTTPS/security indicators, so it doesn't depend on
the user reading anything.
## Error and security-warning popups
Three body variants, all using the shared chrome (header + card), differing
in the `card-body` content:
**Generic / operational error** — 48px amber badge (tinted background, not
solid), heading in normal text color, reason text, then full actions: primary
filled **Retry** above a plain-text **Dismiss**. Covers `cert_invalid`
(`insecure_origin`, operational reasons), `account_error`, and `run_error`
(timeout/user, network) — the icon is identical across these; only the body
copy and whether Retry is offered differ per `claude.md`'s table.
**Security warning — ambient** — 48px red badge (shield glyph), heading text
itself reads "Security warning" (explicit label, not just the reason —
required by `claude.md`: "user sees an explicit security warning, not a
generic error"), reason text, **no action buttons**. Used for `cert_invalid`
security-class reasons (`domain_mismatch`, `signature_invalid`, `malformed`),
which are ambient page state rather than part of an active run — nothing to
retry or dismiss; it persists until the cert or page changes.
**Security warning — mid-run** — same red badge and "Security warning"
heading, but with a single **Dismiss** button. Used for `run_error`
security-class reasons (`alpha_hash_mismatch`, `pin_mismatch`), which
interrupt something the user started. Dismiss uses the outlined/ghost button
style, never the filled accent button — nothing about a blocked state should
visually read as "the expected path," the way Confirm does in phase 3.
**Placeholder copy — needs a plain-language pass.** The reason strings used
while designing this ("relay timed out," "run has been stopped and
reported") are internal/protocol vocabulary — run, relay, bundle, etc. — and
were only good enough to validate the visual treatment. The end user has no
reason to know what a "run" or a "relay" is. Before this ships, every
user-facing string in this section needs to be rewritten in plain terms (what
happened, what it means for them, what to do), independent of the visual
design above, which is settled.
## Status screens — idle, phase_1, phase_4, delivered
Same chrome, no countdown bar (none of these four are TTL-governed). A 48px
badge + heading + short body, centered, shared across all four; only the
badge color/glyph, heading, body, and action differ.
- **`idle`** — accent-blue tinted badge, checkmark glyph (matches the `ready`
toolbar icon). Heading "Ready to sign in," body naming the site
(`"{origin} supports secure sign-in with LOCQR."`). One primary action:
**Sign in**.
- **`phase_1`** and **`phase_4`** share one template: accent-blue tinted
badge containing the rotating-ring spinner (real CSS rotation, not a
static frame; honors `prefers-reduced-motion` by holding still instead of
spinning), heading, short body, single **Cancel** action (plain-text
button, not filled). Only copy differs between the two:
- `phase_1`: "Connecting…" / "Setting up a secure connection to your
phone."
- `phase_4`: "Almost done…" / "Waiting for your phone to finish sending
your sign-in details."
- **`delivered`** — green tinted badge, bold checkmark (matches the
`success` toolbar icon). Heading "Signed in," body confirming the site
(`"You're securely signed in to {origin}."`), no action buttons, small
"Closing automatically…" note instead — matches `claude.md`'s "brief
display then auto-transition to `idle`."
## Ambient screens — unverified, not_registered
Two low-key states, neither an error nor part of a run, and deliberately
distinct from each other: `unverified` is transient (resolves quickly on any
page that does have LOCQR); `not_registered` is a settled fact about the
page. Neither uses the run-phase spinner — that motion means "a multi-step
exchange is actively in progress," and reusing it here would blur that
meaning. Neither has an action button, matching `claude.md`'s table (both
rows currently show `—`); `not_registered`'s table row notes a possible
future "register site" action, but no button is speculated here since that
capability is undecided, not just uncopywritten.
- **`unverified`** — neutral-gray tinted badge, small dot glyph, with a soft
opacity pulse (1 → 0.35 → 1, ~1.6s) rather than the rotating spinner — reads
as "still looking" without borrowing the run-phase meaning. Heading
"Checking…," body "Looking for LOCQR sign-in support on this page."
Respects `prefers-reduced-motion` (pulse disabled).
- **`not_registered`** — inactive-gray tinted badge (lighter than neutral),
static dash glyph, no animation. Heading "Not available here," body naming
the site (`"{origin} hasn't set up sign-in with LOCQR."`).
- Even the header brand mark picks up the neutral/inactive tint on these two
screens instead of the usual accent blue, so the header itself signals
"nothing active" before any text is read.
## Open / not yet specified
- Icons: deliberately deferred until the rest of the popup design is
finalized — polish, not blocking.
- A full plain-language copy pass across every state, not just the error
family — see the caveat above. Most copy drafted in this document made a
real attempt at plain language already, but none of it has had a dedicated
review pass.
- `mobile/gui.md` — the companion app's screens (scan, key exchange, PIN
display) are entirely undesigned. Some of what's settled here — especially
the PIN/code display concept — should visually correspond to whatever the
companion shows, since the user compares the two side by side.

BIN
extension/gui/img.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

370
flows.md
View File

@ -1 +1,371 @@
# Flows
This document describes the individual sequences that make up system operation.
Each flow names its actors, lists the steps in order, and identifies the points
at which the flow can fail. Cryptographic primitives and algorithm rationale are in crypto.md. Wire formats
and message schemas for all cross-component communication are in interfaces.md.
The overall architecture and trust model are in claude.md.
Steps are labelled with the actor that performs them. A step may produce output
consumed by a later step in the same flow; those dependencies are noted inline.
---
## Domain registration
A website owner registers their domain with the backend before any user session
can take place. The registration produces a signed certificate that the extension
verifies on each page load.
**Actors:** Website admin, Backend server
**Precondition:** Website admin has a backend account.
1. **[Admin]** Submits domain URL and any account metadata to the backend
via the account management interface.
2. **[Backend]** Validates account status and that the domain is not already
registered to a different account.
3. **[Backend]** Issues a registration certificate:
`{ domain, issued_at, expires_at }` signed with ML-DSA-44.
4. **[Backend]** Returns the certificate to the admin.
5. **[Admin]** Supplies the certificate to the SDK initialisation call in the
website code (`locqr.init({ cert: '...' })`). The SDK forwards it to the
extension at connect time. The underlying delivery path is different from
serving a static asset, but the effect is the same: the cert travels from
the backend to the extension via the website operator.
**Success:** Certificate is present on the domain, verifiable against
`BACKEND_PUBLIC_KEY_B64`.
**Failure — account invalid or domain conflict:** Backend rejects at step 2;
admin must resolve before retrying.
**Failure — certificate expired:** Extension detects expiry on the next page
load. Admin must renew through the account interface (steps 15 with a new
expiry).
---
## Site verification
The extension checks every page against the backend's registration records.
This check runs when the SDK connects, independently of any user action or
login request. Its outcome determines the extension's UI state for that page.
**Actors:** SDK, Extension, Backend
**Precondition:** None. The check runs on every SDK connection regardless of
whether a session will be initiated.
1. **[SDK]** On page load, opens a Port to the extension via
`externally_connectable`, sending the registration certificate (supplied to
the SDK at initialisation by the website developer).
2. **[Extension]** Receives the cert and reads the real page origin from
`sender.tab.url` (browser-provided; cannot be spoofed by the page). Rejects
immediately with `insecure_origin` if the scheme is not `https:`.
3. **[Extension]** Verifies the ML-DSA-44 signature locally against its pinned
`BACKEND_PUBLIC_KEY_B64`, checks the `issued_at` and `expires_at` fields,
and confirms the cert's domain matches the origin hostname.
4. **[Extension]** Requests the backend to confirm the account associated with
the domain is in good standing.
5. **[Backend]** Checks account status and responds.
6. **[Extension]** Responds to the SDK via the Port with the verification
outcome and, on success, the feature set authorised for this site.
**Outcome — no certificate supplied:** SDK connected without a cert, or cert
field is empty. Extension responds with `not_registered`; SDK stays dormant.
**Outcome — local cert error (signature mismatch):** Certificate is present but
does not verify against the pinned key. Extension marks the site as invalid and
shows no LOCQR UI. This should not occur for a legitimately registered site.
**Outcome — cert expired:** `expires_at` is in the past. Extension shows an
error state; session initiation is blocked until the admin renews.
**Outcome — cert not yet valid:** `issued_at` is in the future. Treated as
invalid. Extension shows no LOCQR UI.
**Outcome — remote error (backend cannot verify):** Backend cannot confirm the
account — account may be suspended, deactivated, or the backend is unreachable.
Extension shows an error state; session initiation is blocked.
**Outcome — valid:** Signature is correct, certificate is within its validity
period, and the account is confirmed in good standing. Extension activates its
LOCQR UI for this site. Session initiation becomes available.
---
## LOCQR Run
A run transfers one credential from the companion app to the browser extension,
where it is delivered to the current website via the JS SDK. A run has four
phases: initiation, scan, key confirmation, and credential delivery. The phases
execute sequentially with no overlap.
**Actors:** User, Website, Extension, Backend, Companion
**Precondition:** The extension has completed site verification for the current
page with a valid outcome. The companion app holds the credential the user
intends to transfer. The user is physically present with both devices.
The companion's home screen is accessible without authentication. Biometric or
device PIN is required at the point the user initiates a run (taps "Scan QR"),
before the camera opens. This gates the entire run rather than a specific phase.
The vault remains unlocked for the duration of the run; lock policy after run
completion is a companion-internal concern.
### Phase 1 — Initiation
1. **[Website]** Signals to the extension (via JS SDK) that a login field is
active and a credential is needed.
2. **[Extension]** Generates ephemeral keypairs: one X25519 and one ML-KEM-768.
3. **[Extension]** Computes `alpha_hash = SHA-256(x25519_pub_bytes || kem_pub_bytes)`
(classical bytes precede post-quantum bytes per the normative ordering rule
in crypto.md).
4. **[Extension]** Generates a run ID and uploads the full key bundle
(both public keys) to the backend, along with the run ID and the domain from
the verified registration certificate. The server determines all TTL values.
5. **[Backend]** Stores the key bundle. Signs
`{ alpha_hash, url, runId, expires_at }` with its Ed25519 key → signed token.
Returns the signed token together with server-controlled session parameters
(`qr_ttl`, `max_auto_refresh`, `pin_ttl`).
6. **[Extension]** Receives the signed token and session parameters.
7. **[Extension]** Constructs the QR payload from the signed token and displays
the QR code.
The extension's ephemeral private keys never leave the extension. The QR carries
the commitment (alpha_hash) and the backend's signature, not the keys themselves.
**Failure — backend unreachable:** Extension cannot store bundle or receive
signed token; session does not start.
### Phase 2 — Scan
8. **[User]** Taps "Scan QR" on the companion. The companion prompts for
biometric or device PIN. On success the vault is unlocked and the camera
opens. The user scans the QR code.
9. **[Companion]** Parses the QR payload and verifies the Ed25519 signature
against its pinned `ED25519_PUBKEY`. Aborts if verification fails.
10. **[Companion]** Checks that the TTL has not expired. Aborts if the session
window has passed.
11. **[Companion]** Fetches the key bundle from the backend using the run ID.
The backend destroys the bundle on this fetch (at-most-once delivery).
12. **[Companion]** Recomputes `alpha_hash` from the received keys and compares
it to the value from the QR. Aborts if they do not match.
13. **[Companion]** Generates its own ephemeral X25519 keypair. Performs
X25519 key agreement using its ephemeral private key and the extension's
X25519 public key → `x25519_shared`.
14. **[Companion]** Performs ML-KEM-768 encapsulation against the extension's
ML-KEM-768 public key → `(kem_ciphertext, kem_shared_secret)`.
15. **[Companion]** Derives the run key:
`run_key = HKDF-SHA256(x25519_shared || kem_shared_secret, salt=runId_utf8, info="locqr-run-key-v1", length=32)`.
16. **[Companion]** Sends the companion's ephemeral X25519 public key and
`kem_ciphertext` to the extension via the backend relay. These are framed
as a single `kem_ciphertext` relay message; see `../interfaces.md` for
the fixed-offset framing.
17. **[Extension]** Performs ML-KEM-768 decapsulation using its private key →
`kem_shared_secret`. Performs X25519 key agreement → `x25519_shared`.
18. **[Extension]** Derives the run key by the same HKDF construction.
Both sides now independently hold the same run key.
**Failure — TTL expired:** Companion detects expiry at step 10 and displays
an expiry message. If the extension still has auto-refreshes remaining it will
have already replaced the QR; the user should scan the current code. If
`max_auto_refresh` is exhausted the extension has entered `session_error /
ttl_exhausted` and the user must trigger a full restart from Phase 1.
**Failure — bundle already consumed:** Backend returns a `404` at step 11.
This means either the bundle was already fetched (possible QR hijack) or the
run expired server-side. Companion aborts, enters `error/bundle_consumed`
(security-class), and reports to `/security/report`. The legitimate user is
protected: the attacker cannot complete the run without the extension's
private keys. The extension receives no notification of this event; it simply
times out on `qr_ttl` expiry and enters `run_error/ttl_exhausted`. See the
design note in `extension/claude.md` for what adding an explicit extension
signal would require.
**Failure — alpha_hash mismatch:** Companion aborts at step 12. The key bundle
received from the backend does not match the commitment in the QR. This
indicates tampering between extension and backend.
**Failure — signature verification failure:** Companion aborts at step 9 if
the Ed25519 signature does not verify, or if the URL in the signed token does
not match the URL the extension reported.
### Phase 3 — Key confirmation
19. **[Companion]** Derives the PIN from the run key:
`pin_bytes = HKDF-SHA256(run_key, salt=[], info="locqr-pin-v1", length=4)`.
`pin_string = (pin_bytes as uint32 big-endian % 1_000_000)`, zero-padded to
six digits.
20. **[Extension]** Derives the same PIN by the same construction and displays it.
21. **[User]** Reads the PIN displayed on the companion and confirms the match
on the extension.
The confirmation must happen on the extension. This is the point of the PIN:
it proves to the extension that the user at the browser is in possession of
the companion that completed the key exchange. Confirming elsewhere provides
no protection. The extension does not proceed to Phase 4 until the user
explicitly confirms.
The PIN does not prevent a race between a legitimate user and a QR hijacker:
the at-most-once bundle delivery at step 11 is the load-bearing control
against that attack.
**Failure — PIN mismatch:** User sees different codes on the two devices and
does not confirm on the extension. This indicates the run keys differ,
which should not happen if all earlier verifications passed. The extension
abandons the run. Neither device must attempt to continue.
### Phase 4 — Credential delivery
22. **[Companion]** Presents the credentials stored for the current site (the
URL is known from the signed token parsed at step 9). The vault is already
unlocked from the biometric at step 8. If exactly one credential is stored
for the site, it may be pre-selected. If multiple are stored, the user must
choose.
23. **[User]** Selects the credential to deliver.
24. **[Companion]** Encrypts the credential with the run key (AES-256-GCM).
A fresh nonce is generated for this encryption; one nonce is used per run.
25. **[Companion]** Sends the ciphertext to the backend relay, addressed to the
run ID.
26. **[Backend]** Forwards the ciphertext to the extension. The backend cannot
decrypt it.
27. **[Extension]** Decrypts the ciphertext using the run key and verifies
the GCM authentication tag. If authentication fails, the payload is discarded.
28. **[Extension]** Delivers the credential to the website via the JS SDK —
resolving the `requestCredential()` Promise and firing a `run:delivered`
event with the credential payload.
**Success:** Credential is in the website's login form. The run key is
discarded by both extension and companion; no future use is possible.
**Failure — decryption or authentication failure:** Extension discards the
payload at step 27. This should not occur under normal operation; if it does,
it indicates a relay integrity failure. User must restart the session.
---
## Credential provisioning
Before a run can deliver a credential, the credential must exist in the
companion. There are four provisioning paths.
**Manual entry.** The user types the credential directly into the companion app.
Companion-internal; no cross-entity protocol required.
**File import.** The user imports credentials from a file on the device
(e.g. a password manager export). Companion-internal; no cross-entity protocol
required. File format and parsing are deferred.
**Website-commanded storage.** The website initiates a store operation,
sending a credential to the companion for safekeeping. This is the reverse of
the run's delivery flow: the website sends encrypted data through the
extension to the companion over an established run channel. This path
requires its own flow; deferred.
**Format-guided generation.** The companion generates a credential locally
according to a format specification supplied by the website or the extension.
This is primarily intended for GYBBR, where credential structure is known, but
the generation interface may be generalised to arbitrary format descriptions for
other services. The format spec travels from website to extension to companion
before or during run initiation; the companion generates the credential
locally and never transmits it in cleartext. This path requires its own flow;
deferred.
The two companion-internal paths (manual entry, file import) produce a
credential that is then available as a run precondition. The two cross-entity
paths (website-commanded storage, format-guided generation) reuse the run
infrastructure and will reference the run flow.
**TOTP / classical 2FA.** TOTP secrets (RFC 6238) may be stored in the
companion alongside passwords and delivered as part of the same run. The
run-initiation biometric or PIN requirement (step 8) means that storing
TOTP in the same vault does not reduce 2FA to single-factor: possession of the
device and biometric or PIN are still both required. The biometric gates the
entire run, not just credential delivery; the second factor is therefore
enforced before any data leaves the device, regardless of how many credentials
or TOTP codes are transferred. TOTP integration is deferred; the storage and
delivery model is compatible with the current architecture without structural
changes.
---
## Companion loss, replacement, and recovery
All credentials are stored exclusively on the companion. There is no server-side
copy. This is a deliberate security property, but it means the companion is a
single point of failure for the user's credential store.
### Lost or stolen device
The run-initiation biometric or PIN requirement limits the immediate risk: an
attacker with a stolen device cannot trigger a run without clearing that
gate. However, the user should assume credentials are at risk if the device is
lost and act accordingly — changing passwords for sensitive accounts.
The current architecture has no companion revocation mechanism. The companion
holds no registered identity with the backend; it operates anonymously.
Introducing revocation would require companion registration, which in turn
would require user accounts on the backend — breaking the anonymity of the app
as it currently stands. Whether revocation is worth that cost is an open
question. Until it is resolved, the run-initiation biometric or PIN is the sole
protection against misuse of a stolen device.
### New device
When a user replaces their device, credentials must transfer to the new
companion. The transfer must happen without exposing credentials to the network
in recoverable form. Options under consideration:
**Encrypted backup.** Credentials are encrypted under a key derived from a
master passphrase chosen by the user, and stored in a location the user
controls (local file, cloud storage of their choice). Restoring on a new device
requires the passphrase. The backup is useless without it. This is the
simplest model and places recovery entirely in the user's hands.
**Device-to-device transfer.** Old and new companions establish a direct
encrypted channel — using a mechanism analogous to the QR-based run
bootstrap — and transfer the credential store in a single operation. Requires
both devices to be present simultaneously. No passphrase needed, but the window
of exposure is the transfer itself.
Both options are deferred. Until a backup or transfer mechanism exists, a lost
or replaced device means credentials must be re-added manually.
### Multiple devices
Holding the companion on more than one device simultaneously implies keeping
credential stores in sync. This introduces conflict resolution, ordering, and
consistency concerns that are out of scope for the PoC. Deferred.
---
## Error handling
The specific actions taken on error — UI state, SDK events, logging, retry
behaviour — are deferred to the entity-level documentation.
**Security errors.** Failures such as signature verification failure,
alpha_hash mismatch, and bundle already consumed are indicators of a potential
active attack, not benign operational faults. These warrant a response beyond
informing the user: the relevant actors should notify the backend, and the user
should receive a clear security warning rather than a generic error message.
The exact notification and escalation paths are deferred.
**Network errors and timeouts.** A run has several points where
network failure produces an ambiguous result — the operation may or may not
have completed on the remote side. Notable cases:
- Backend unreachable during Phase 1: the extension cannot store the key bundle
or receive a signed token. No run starts; clean failure.
- Timeout during key bundle fetch (Phase 2, step 11): the companion does not
know whether the backend destroyed the bundle before the connection dropped.
The run must be abandoned; the user must restart. The extension should
proactively expire and discard the bundle on its side.
- Connection lost during ciphertext relay (Phase 4): the extension cannot
distinguish a lost connection from a delayed delivery. It should not attempt
to re-use the run key for a retry; the run must be restarted from Phase 1.
Network errors are never treated as security events. They do not trigger attack
notifications. They require clear user-facing messages and, where safe, an
offer to retry from Phase 1.

366
interfaces.md Normal file
View File

@ -0,0 +1,366 @@
# 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`.
---
## 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 |

View File

@ -1 +1,245 @@
# Mobile Companion App
The companion is the credential store and the mobile half of the LOCQR run.
It holds all user credentials at rest, performs the mobile side of the
cryptographic key exchange, authenticates the user via biometric or device PIN
before each delivery, and sends the encrypted credential to the backend relay.
The companion exists in two implementations: a **web stub** used during
development, and the **native app** that is the production target.
---
## Web stub (development tool)
The web stub is a single-page web application that implements the full
cryptographic protocol without the security properties of the native app. Its
purpose is to allow end-to-end development and testing of the extension without
requiring a native build.
**What it does:**
- Scans QR codes via the browser `MediaDevices` camera API
- Performs all crypto operations in full (X25519, ML-KEM-768, HKDF, AES-256-GCM)
using WebCrypto and a WASM ML-KEM library
- Derives and displays the PIN
- Delivers a credential — either a hardcoded payload or any credential entered
or received during the current browser session
- Communicates with the backend relay over HTTPS
**What it deliberately omits:**
- No persistent credential storage
- No biometric or device PIN gate (replaced by a simple button)
- No hardware-backed key storage
- No platform security guarantees
The stub is a first-class development artifact. It should be maintained
alongside the extension so that the full run can always be exercised
in a browser.
The visual design specified in `gui.md` does not apply to the stub. Adhere to
it only where doing so costs nothing — the stub's purpose is functional
correctness, not UI fidelity.
---
## Native app
Visual design, screen layouts, and interaction details for the native app are
specified in `gui.md`.
### Platform
Android-first, written in Kotlin. iOS is deferred.
| Concern | Android |
|---|---|
| Credential encryption key | Android Keystore (hardware-backed where available) |
| Biometric auth | `BiometricPrompt` API |
| QR scanning | ML Kit Barcode Scanning |
| Compile-time constants | `BuildConfig` fields set at build time |
### Compile-time constants
| Constant | Algorithm | Purpose |
|---|---|---|
| `ED25519_PUBKEY` | Ed25519 | Verifies signed tokens from backend |
Pinned at build time. Any signed token that does not verify against this key
is rejected before any other processing occurs.
---
## State model
The companion's run flow is linear. There is at most one active run at a time.
The companion's home screen is freely accessible without authentication, following
the pattern of transaction-gated apps (e.g. PayPal). Biometric or device PIN is
required at the point the user initiates a run — when they tap "Scan QR" —
before the camera opens. The vault remains unlocked for the duration of the run.
Lock policy after run completion (immediate re-lock vs. timeout window) is a
deferred UX decision.
### States
- `idle` — home screen; freely accessible; no active run.
- `authenticating` — biometric or device PIN prompt active; triggered by tapping
"Scan QR."
- `scanning` — authentication passed; vault unlocked; camera active.
- `verifying` — QR parsed; running: Ed25519 signature check, TTL check, backend
bundle fetch, alpha_hash recomputation.
- `key_exchange` — verification passed; performing X25519 key agreement and
ML-KEM-768 encapsulation; sending `kem_ciphertext` to backend relay.
- `pin_display` — run key derived; PIN displayed to user. User reads
the PIN, confirms the match on the extension, then taps Continue on the
companion to advance. The companion does not receive a signal from the
extension; the two devices proceed independently from this point.
- `credential_select` — credentials for the current site presented; vault
already unlocked from biometric at session initiation. If exactly one
credential is stored for the site it may be pre-selected. If multiple are
stored the user must choose.
- `delivering` — credential selected; encrypted with AES-256-GCM under the
run key; ciphertext sent to backend relay.
- `complete` — ciphertext sent; run key discarded; brief confirmation shown;
returns to `idle`.
- `error` — run failed at any phase; reason and class held for display.
### Transitions
```
idle
→ authenticating (user taps "Scan QR")
authenticating
→ scanning (biometric or PIN accepted; vault unlocked)
→ idle (auth failed or user cancels)
scanning
→ verifying (QR code parsed)
→ idle (user cancels)
verifying
→ key_exchange (all checks pass)
→ error/ttl_expired (TTL in QR token has passed)
→ error/signature_invalid (security: Ed25519 signature fails)
→ error/alpha_hash_mismatch (security: key bundle does not match QR commitment)
→ error/bundle_consumed (security: backend reports bundle already fetched)
→ error/backend_unreachable (network: cannot fetch key bundle)
→ idle (user cancels)
key_exchange
→ pin_display (kem_ciphertext sent; run key derived)
→ error/relay_error (network: cannot send kem_ciphertext)
pin_display
→ credential_select (user taps Continue)
→ idle (user cancels; run key discarded)
credential_select
→ delivering (user selects credential)
→ idle (user cancels; run key discarded)
delivering
→ complete (ciphertext sent to relay)
→ error/relay_error (network: relay unreachable or error)
complete
→ idle (automatic after brief display)
error
→ authenticating (user retries; security-class errors do not offer retry)
→ idle (user dismisses)
```
### Error classes
*Security-class* (companion notifies backend; user sees explicit security
warning; no retry offered):
- `signature_invalid` — Ed25519 signature on the QR token does not verify
against the pinned `ED25519_PUBKEY`.
- `alpha_hash_mismatch` — key bundle fetched from backend does not match the
commitment in the QR. Indicates tampering between the extension and backend.
- `bundle_consumed` — backend reports the bundle was already fetched before the
legitimate companion reached it. Possible QR hijack.
*Timeout / user class* (expected; retry offered):
- `ttl_expired` — the TTL in the signed token had already passed when the
companion scanned.
- `user_abort` — user cancelled at any phase.
*Network class* (transient; retry offered):
- `backend_unreachable` — key bundle fetch failed.
- `relay_error` — could not send `kem_ciphertext` or credential ciphertext.
---
## Credential storage
Credentials are the companion's primary data. The companion is the only entity
in the system that holds them; there is no server-side copy.
### Structure
Credentials are stored per domain. Each domain maps to an ordered list of
credential entries. A credential entry holds at minimum a label, a username,
and a password. TOTP secrets are a planned addition (deferred; see
`../flows.md`).
### Encryption at rest
The credential store is encrypted under a key held in the platform secure
enclave (Android Keystore). The key never leaves the enclave; all encrypt and
decrypt operations are performed in hardware. The encrypted store can be backed
up to user-controlled storage without exposing credentials — the backup is
useless without the enclave key, which does not travel with it.
Backup and restore, device-to-device transfer, and multiple-device sync are
deferred. See `../flows.md`*Companion loss, replacement, and recovery*.
---
## Cryptographic responsibilities
- **QR verification:** Ed25519 signature check against pinned `ED25519_PUBKEY`;
TTL check; recomputation of `alpha_hash` from the fetched key bundle.
- **Key exchange:** X25519 key agreement against the extension's public key;
ML-KEM-768 encapsulation against the extension's ML-KEM-768 public key →
`kem_ciphertext` and `kem_shared_secret`.
- **Session key derivation:**
`HKDF-SHA256(x25519_shared || kem_shared_secret, salt=runId_utf8, info="locqr-run-key-v1", length=32)`
classical value precedes post-quantum per the normative ordering rule in `../crypto.md`.
- **PIN derivation:** `HKDF-SHA256(run_key, salt=[], info="locqr-pin-v1",
length=4)`, displayed as a zero-padded six-digit decimal.
- **Payload encryption:** AES-256-GCM with a fresh nonce; exactly one
encryption per run key.
All algorithm parameters and normative ordering rules are in `../crypto.md`.
---
## Backend API surface
- Fetch key bundle by run ID (at-most-once; bundle destroyed on fetch).
- Send `kem_ciphertext` to relay addressed to run ID (Phase 2).
- Send encrypted credential ciphertext to relay addressed to run ID (Phase 4).
- Report security-class errors to `/security/report`.
Request/response schemas are in `../interfaces.md`. Endpoint behaviour is in
`../server/claude.md`.
---
## Security invariants
- The run key is never written to persistent storage. It is discarded
immediately on `complete` or on any transition to `idle` or `error`.
- Biometric or device PIN authentication is required to initiate a run
(before the camera opens). The vault remains unlocked for the run duration.
Authentication is not re-requested mid-run.
- Any signed token that does not verify against the pinned `ED25519_PUBKEY` is
rejected before any network call is made.
- The companion does not proceed past `verifying` if the alpha_hash recomputed
from the fetched key bundle does not match the value in the QR token.
- Security-class errors are reported to the backend. No retry is offered.
- The companion operates anonymously with respect to the backend. It holds no
registered identity and requires no user account.

0
mobile/gui.md Normal file
View File

View File

@ -1 +1,190 @@
# JavaScript SDK
The SDK is a JavaScript library embedded in participating websites. It wraps
the browser messaging layer between the page and the extension, exposing a
clean API to website developers. Without it, a site could handle Port messaging
directly — the SDK makes that unnecessary.
The SDK has no logic of its own. It does not perform cryptography, hold state,
or make network requests. It is a thin bridge.
---
## Initialisation
The website developer initialises the SDK with the site's registration
certificate, obtained from the LOCQR backend admin panel when registering the
domain. The cert is a static value that changes only on renewal.
```js
locqr.init({ cert: 'BASE64_ENCODED_CERT' });
```
The cert is not embedded in the page DOM. Passing it at initialisation is
sufficient; the SDK forwards it to the extension at connect time.
---
## Communication model
The SDK communicates with the extension service worker directly via
`chrome.runtime.connect(extensionId)` — no content script is involved. This
opens a long-lived bidirectional **Port**. The extension reads the real page
URL from `sender.tab.url` (browser-provided; cannot be spoofed by the page).
```
website code ↔ SDK ↔ chrome.runtime Port ↔ service worker
```
All SDK↔extension communication for the lifetime of the page uses this Port.
The full Port message protocol (message types, `LocqrError`, `Credential`
schemas) is defined in `../interfaces.md`.
---
## Activation
After `locqr.init()`, the SDK opens the Port and sends the cert. The extension
verifies it and responds with the outcome. `locqr.ready` is a Promise that
resolves with the authorised feature set on success, or rejects with the
verification error.
```js
try {
const { features } = await locqr.ready;
// features: e.g. ['login']
} catch (err) {
// site not registered, cert invalid, or account error
}
```
The SDK exposes only the methods corresponding to the declared features. A site
whose cert does not include `"vault"` does not get `locqr.encrypt()`.
---
## API surface
### `locqr.init(options)`
Initialises the SDK. Must be called before any other method.
```ts
locqr.init({ cert: string });
```
### `locqr.ready → Promise<{ features: string[] }>`
Resolves when the extension has verified the site and returned an authorised
feature set. Rejects with an error object if the extension is not installed, the
site is not registered, the cert is invalid, or the account is not in good
standing.
```ts
type LocqrError = {
code: 'NOT_INSTALLED' | 'NOT_REGISTERED' | 'CERT_INVALID' | 'ACCOUNT_ERROR' | 'RUN_FAILED';
reason?: string; // sub-reason for CERT_INVALID / ACCOUNT_ERROR / RUN_FAILED
security?: boolean; // true for security-class errors
}
```
`NOT_INSTALLED` is the only code produced before a Port is opened. All other
codes arrive from the extension over the Port after the connection succeeds.
### `locqr.requestCredential() → Promise<Credential>`
Signals to the extension that a credential is needed for the current site.
Triggers Phase 1 of the run. Resolves when the credential is delivered at the
end of Phase 4. Rejects if the run fails or is aborted.
```js
const { username, password } = await locqr.requestCredential();
```
```ts
type Credential = {
username: string;
password: string;
// totp and further fields deferred
}
```
### `locqr.on(event, handler)` / `locqr.off(event, handler)`
Registers and removes handlers for extension-initiated events.
| Event | When | Payload |
|---|---|---|
| `ready` | Verification complete | `{ features: string[] }` |
| `run:started` | Phase 1 complete; QR displayed | `{}` |
| `run:delivered` | Credential delivered | `{ credential: Credential }` |
| `run:error` | Run failed | `{ error: LocqrError }` |
`run:delivered` fires alongside the `requestCredential()` Promise resolving.
Sites using the event model do not need to call `requestCredential()`. Both
models are valid; mixing them on the same run is not.
---
## GYBBR: vault API (deferred)
For GYBBR, the SDK will expose cryptographic vault operations once the `"vault"`
feature is declared in the site's cert. The website requests operations by key
ID and receives results; key material never leaves the extension.
Anticipated surface (not yet designed):
```js
const ciphertext = await locqr.encrypt({ keyId: 'k1', data: plaintext });
const plaintext = await locqr.decrypt({ keyId: 'k1', data: ciphertext });
```
Extension-initiated vault notifications (e.g. identity key available) will be
delivered as events via `locqr.on`. Exact method signatures and event names are
deferred to the GYBBR design phase.
---
## Static properties
The SDK exposes stable reference values as static properties, available
immediately after the script loads — before `locqr.init()` and regardless of
whether the extension is installed. Website developers can use these to build
install prompts, help text, or footer links without hardcoding URLs themselves.
```js
locqr.installLinks // { chrome: string, firefox: string, ... }
locqr.mobileLinks // { android: string, ios: string }
locqr.website // LOCQR public website URL
```
`locqr.ready` rejecting with `code: 'NOT_INSTALLED'` is the signal to surface
these links. How they are presented is entirely the website's decision; the SDK
provides the data only. DOM helpers, install-prompt components, etc. are
deferred.
`ios` within `mobileLinks` is present as a placeholder but empty until the iOS
app exists.
---
## Distribution
How the SDK is packaged and distributed (npm, CDN script tag, bundled asset)
is deferred. The extension ID baked into the SDK is stable for the lifetime of
the Store-published extension; it changes only on a full re-publication, which
is an extraordinary event. Development builds use a different ID configured via
`locqr.init({ cert, extensionId: DEV_ID })`.
---
## Security notes
- The SDK never receives key material. It receives only operation results
(credentials, ciphertext, plaintext of things the site requested decrypted).
- The real page URL is read by the extension from `sender.tab.url` — the page
cannot supply or spoof it.
- Feature enforcement is in the extension. The SDK surface is a reflection of
what the cert permits, not an independent gate.
- Once a credential is passed to the website via `requestCredential()`, it has
left the LOCQR security boundary. What the site does with it is out of scope.

View File

@ -1 +1,197 @@
# Backend Server
The backend server is the trust anchor for the entire system. Its signing keys
are the only compile-time constants; all legitimacy within the system traces
back to its ability to sign, and the clients' ability to verify against those
pinned keys. It provides two services: run infrastructure (key bundle
storage, token signing, relay) and account management (domain registration,
certificates, billing).
The server exists in two implementations: a **Node.js stub** used during
development, and the **Java/Spring production server** which is the long-term
target.
---
## Node.js stub (development tool)
A minimal Express server implementing only the parts the extension and companion
actually touch. Its purpose is to allow end-to-end development and testing of
the full run flow without the production infrastructure.
**What it does:**
- Stores key bundles in memory; destroys on fetch (at-most-once)
- Signs key exchange tokens with Ed25519 (Node built-in `crypto` module)
- Returns server-controlled run parameters with each signed token
- Accepts WebSocket connections from the extension for relay
- Forwards relay messages (kem_ciphertext, encrypted credential) from companion
to extension
- Serves a pre-generated test domain certificate (ML-DSA-44); does not
dynamically issue certificates
- Accepts domain status queries; returns valid for the hardcoded test domain
**What it deliberately omits:**
- No persistence — all state is in-memory
- No ML-DSA-44 signing at runtime (test cert pre-generated by the dev setup
script in `../dev.md`; the stub reads it from `dev/certs/test-registration.b64`
at startup)
- No account management
- No billing or registration flows
---
## Production server
Java/Spring. Deferred. The Node stub defines the API contract that production
must honour.
| Concern | Production |
|---|---|
| Key bundle storage | Redis (`GETDEL` for atomic at-most-once delivery) |
| Domain registrations / accounts | PostgreSQL |
| ML-DSA-44 signing | BouncyCastle or liboqs-java |
| WebSocket relay | Spring WebSocket |
| Account management | Full SaaS (registration, billing, domain admin) |
---
## Signing keys
The server holds two secret keys. Neither is ever stored in source code or
committed to version control — injected via environment variables or a secrets
manager at startup.
| Secret key | Algorithm | Corresponding pinned constant | Purpose |
|---|---|---|---|
| `ED25519_SECRET_KEY` | Ed25519 | `ED25519_PUBLIC_KEY_B64` (extension), `ED25519_PUBKEY` (companion) | Signs key exchange tokens |
| `ML_DSA_44_SECRET_KEY` | ML-DSA-44 | `BACKEND_PUBLIC_KEY_B64` (extension) | Signs domain registration certificates |
Key regeneration patches all pinned constants in extension and companion source
and requires redeployment of all three components.
---
## API surface
All request and response body schemas are defined in `../interfaces.md`. This
section describes endpoint behaviour; the interfaces document is the normative
source for field names, types, and encodings.
### Extension endpoints
**`POST /run/bundle`**
Extension uploads key bundle at Phase 1.
Request: `{ runId, origin, x25519_pubkey_b64, kem_pubkey_b64 }`
`origin` is the full HTTPS origin of the site (`https://host:port`), taken from
the registration certificate the extension verified. The server bakes it
verbatim into the signed token as `url`. The extension must reject any origin
whose scheme is not `https:` before the bundle upload is ever attempted.
The server:
1. Stores the key bundle associated with the run ID.
2. Computes `alpha_hash = SHA-256(x25519_pub_bytes || kem_pub_bytes)`.
3. Signs `{ alpha_hash, url, runId, expires_at }` with Ed25519 → signed token.
4. Returns signed token and run parameters.
Response: `{ signed_token, qr_ttl, max_auto_refresh, pin_ttl }`
The server should eventually verify that the origin is registered to an active
account before signing — this ownership verification mechanism is deferred for
the stub. TTL values in run parameters are server-controlled; clients must
not apply local defaults.
**`GET /domain/status?domain=<hostname>`**
Extension queries account status during site verification.
Response: `{ status: "valid" | "rejected" | "suspended" }` or error if
unreachable.
**`POST /security/report`**
Extension or companion reports a security-class error.
Request: `{ runId, error_type, timestamp }`. Server logs and aggregates.
Site owner notification mechanism deferred — see `../extension/claude.md`,
*Error escalation*.
### Companion endpoints
**`GET /run/bundle/:runId`**
Companion fetches key bundle at Phase 2. **At-most-once: the bundle is
atomically destroyed on this fetch.** A second request for the same run ID returns
an error.
Response: `{ x25519_pubkey_b64, kem_pubkey_b64 }` or `404` if already consumed
or expired.
This is a load-bearing security property. Node stub uses `Map.get()` +
`Map.delete()` (safe for single-process development). Production uses Redis
`GETDEL`.
**`GET /run/relay/:runId` (WebSocket upgrade — extension only)**
The extension upgrades this endpoint to a WebSocket connection immediately after
Phase 1 completes. The server maps the run ID to the live socket and uses it to
push relay messages to the extension. If the service worker suspends and the
socket drops, the extension is responsible for reconnecting before any relay
message arrives.
**`POST /run/relay/:runId` (HTTP — companion only)**
Companion sends a relay message (kem_ciphertext in Phase 2, encrypted credential
in Phase 4) addressed to the run ID.
Request: `{ type, payload_b64 }`. Server forwards the complete message object
over the extension's WebSocket for that run ID. If the WebSocket is not currently open (service worker
suspended), the message is buffered briefly. Buffer lifetime is short (seconds);
if the extension does not reconnect within that window the message is discarded
and the run must be restarted.
### Admin / website endpoints
Domain registration and account management. Deferred for the stub; stub serves
a hardcoded test domain certificate. Production endpoints are a full SaaS
concern.
---
## WebSocket relay
The extension opens a WebSocket connection to `/run/relay/:runId` after
completing Phase 1. The server maintains a mapping of run ID → active connection.
**Message flow:**
1. Extension opens WS for run ID (after Phase 1).
2. Companion POSTs kem_ciphertext → server pushes to extension WS (Phase 2).
3. Companion POSTs encrypted credential → server pushes to extension WS (Phase 4).
4. WS closes on run end (delivered, error, or TTL expiry).
The extension is responsible for keeping its WS connection alive across service
worker suspensions using `chrome.alarms` or equivalent. The server-side buffer
provides a short grace window but is not a reliable recovery mechanism.
---
## State model
| Data | Storage | Lifetime |
|---|---|---|
| Key bundles | In-memory / Redis | `qr_ttl`; destroyed on fetch or expiry |
| Active WS connections (runId → socket) | In-memory | Run lifetime |
| Relay message buffer (undelivered) | In-memory | Seconds; discarded if extension does not reconnect |
| Domain registration certificates | Static file / DB | Until expiry or renewal |
| Account records | Hardcoded / DB | Persistent |
| Signing keys | Environment / secrets manager | Permanent |
---
## Security invariants
- Signing keys are never stored in source code or version control.
- The key bundle is atomically destroyed on first fetch. No partial reads; no
second deliveries.
- The server cannot decrypt relay messages. It forwards opaque blobs; the
run key is never transmitted through it.
- The signed token binds `alpha_hash`, `url`, `runId`, and `expires_at` together.
Substituting any field invalidates the signature.
- Security-class error reports are accepted from any client without
authentication — rate-limiting and aggregation are the server's responsibility.

View File

@ -1 +1,110 @@
# Website (Test)
The test website is a minimal HTML page that exercises the full LOCQR session
flow end-to-end. It is a permanent development artifact — not a prototype of
a real product — and will not be replaced by a production website. Its only
purpose is to provide a realistic SDK integration target for testing the
extension, the web companion stub, and the Node server stub together.
---
## What it does
- Initialises the SDK with the test domain's registration certificate
- Presents a simple login form
- Calls `locqr.requestCredential()` on form focus or button press
- Displays the delivered credential to confirm end-to-end delivery worked
- Exercises error paths — unregistered domain, invalid cert, session failure —
to confirm the extension's error states and UI behave correctly
It is intentionally bare. No styling beyond readability; no application logic
beyond triggering and displaying LOCQR flows.
A visible event log panel is a core part of the test page, not an optional
addition. Every SDK event, Promise resolution, rejection, and error — including
the full payload — is appended to the log in real time with a timestamp. The
log is the primary debugging tool for the development environment; it should
capture everything the SDK surfaces, including security-class errors and their
reasons.
---
## Development environment
The test site is one part of a four-piece local development environment:
| Component | Implementation |
|---|---|
| Website | This test site |
| Extension | The real extension, loaded unpacked |
| Backend | Node.js server stub |
| Companion | Web companion stub (mobile browser or desktop) |
Running all four together constitutes a complete local LOCQR environment.
Start order, environment variables, shared configuration, and the one-time
setup script are documented in `../dev.md`.
### HTTPS requirement
`externally_connectable` requires a secure context. Service workers require
HTTPS. The test site must be served over HTTPS even in local development.
Options:
- **`mkcert`** — creates a locally-trusted CA and issues certs for custom local
domains. Recommended: clean, no browser warnings, no external dependency.
- **Self-signed cert with manual trust** — works but requires trusting the cert
in each browser profile used for testing.
- **Tunnel (ngrok, Cloudflare Tunnel)** — provides a real HTTPS domain; useful
for testing on physical mobile devices for the companion stub. Adds an
external dependency.
### Local domain
The registration certificate is domain-scoped. `localhost` is unsuitable —
it cannot hold a meaningful domain cert and is treated specially by browsers.
A custom local domain (e.g. `test.locqr.dev`) with a hosts file entry and a
`mkcert`-issued cert is the recommended approach.
The same domain must appear in:
- The registration certificate issued by the Node server stub at startup
- The `externally_connectable` origins list in the extension manifest
- The SDK `locqr.init({ cert })` call in the test page
### Extension ID in development
The unpacked extension has a different ID from the Store-published version.
The test site initialises the SDK with the dev extension ID:
```js
locqr.init({
cert: TEST_CERT,
extensionId: DEV_EXTENSION_ID
});
```
`DEV_EXTENSION_ID` is stable as long as the same `key.pem` is used for the
unpacked extension. Pinning `key.pem` in the repo (non-secret for dev builds)
keeps it consistent across machines.
### SSL certificates for stubs
The Node server stub also needs HTTPS — the extension and companion both
communicate with it over HTTPS. A second `mkcert` cert for the local server
domain (e.g. `api.locqr.dev`) covers this. The companion web stub, if opened
in a mobile browser over a tunnel, needs the tunnel's HTTPS certificate.
A shared `mkcert` setup script that issues certs for all local domains and
places them where each stub expects them would reduce per-developer setup
friction. Deferred until the dev environment stabilises.
---
## Registration certificate
The test domain's ML-DSA-44 registration certificate is generated by the Node
server stub at startup (using its pre-generated signing key) and either:
- Written to a known location for the test site to copy-paste into its
`locqr.init()` call, or
- Served by the stub at a known endpoint so the test site can fetch it on load
The exact mechanism is deferred; either works for development purposes.