locqr/flows.md

372 lines
19 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

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

# 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.