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