19 KiB
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.
- [Admin] Submits domain URL and any account metadata to the backend via the account management interface.
- [Backend] Validates account status and that the domain is not already registered to a different account.
- [Backend] Issues a registration certificate:
{ domain, issued_at, expires_at }signed with ML-DSA-44. - [Backend] Returns the certificate to the admin.
- [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 1–5 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.
- [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). - [Extension] Receives the cert and reads the real page origin from
sender.tab.url(browser-provided; cannot be spoofed by the page). Rejects immediately withinsecure_originif the scheme is nothttps:. - [Extension] Verifies the ML-DSA-44 signature locally against its pinned
BACKEND_PUBLIC_KEY_B64, checks theissued_atandexpires_atfields, and confirms the cert's domain matches the origin hostname. - [Extension] Requests the backend to confirm the account associated with the domain is in good standing.
- [Backend] Checks account status and responds.
- [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
- [Website] Signals to the extension (via JS SDK) that a login field is active and a credential is needed.
- [Extension] Generates ephemeral keypairs: one X25519 and one ML-KEM-768.
- [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). - [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.
- [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). - [Extension] Receives the signed token and session parameters.
- [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
- [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.
- [Companion] Parses the QR payload and verifies the Ed25519 signature
against its pinned
ED25519_PUBKEY. Aborts if verification fails. - [Companion] Checks that the TTL has not expired. Aborts if the session window has passed.
- [Companion] Fetches the key bundle from the backend using the run ID. The backend destroys the bundle on this fetch (at-most-once delivery).
- [Companion] Recomputes
alpha_hashfrom the received keys and compares it to the value from the QR. Aborts if they do not match. - [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. - [Companion] Performs ML-KEM-768 encapsulation against the extension's
ML-KEM-768 public key →
(kem_ciphertext, kem_shared_secret). - [Companion] Derives the run key:
run_key = HKDF-SHA256(x25519_shared || kem_shared_secret, salt=runId_utf8, info="locqr-run-key-v1", length=32). - [Companion] Sends the companion's ephemeral X25519 public key and
kem_ciphertextto the extension via the backend relay. These are framed as a singlekem_ciphertextrelay message; see../interfaces.mdfor the fixed-offset framing. - [Extension] Performs ML-KEM-768 decapsulation using its private key →
kem_shared_secret. Performs X25519 key agreement →x25519_shared. - [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
- [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. - [Extension] Derives the same PIN by the same construction and displays it.
- [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
- [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.
- [User] Selects the credential to deliver.
- [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.
- [Companion] Sends the ciphertext to the backend relay, addressed to the run ID.
- [Backend] Forwards the ciphertext to the extension. The backend cannot decrypt it.
- [Extension] Decrypts the ciphertext using the run key and verifies the GCM authentication tag. If authentication fails, the payload is discarded.
- [Extension] Delivers the credential to the website via the JS SDK —
resolving the
requestCredential()Promise and firing arun:deliveredevent 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.