locqr/mobile/claude.md

267 lines
11 KiB
Markdown

# 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 for comparison
against the extension's independently-derived PIN — that visual
comparison is the actual anti-MITM check, not a gate the companion enforces
itself. No local action is required or offered here: the companion polls
`GET /run/relay/:runId/confirm` (interfaces.md, "PIN confirmation")
automatically the instant the PIN is derived, and advances the moment the
extension's user clicks Confirm there — that single click is the only
human action gating the run. Design history: this document previously
described a two-way handshake (companion's own tap *and* the extension's
signal, neither sufficient alone), reasoning from Bluetooth-style Numeric
Comparison pairing. That was reverted — no benefit was found in a second,
companion-side tap once the PIN is already independently displayed and
visually checked; it was pure friction on a device that has nothing
further to decide. See interfaces.md's "PIN confirmation" section for the
wire-level detail.
- `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 (extension's confirmation received — polled
automatically, no local action)
→ error/confirmation_timeout (bounded wait elapses with no confirmation —
network class, retry offered)
→ 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.
- `confirmation_timeout` — polled for the extension's PIN confirmation
past a bounded wait with no response. Not security-class: the absence of
a signal isn't evidence of tampering, just that the other side hasn't
(yet, or ever) clicked Confirm — could be an inattentive user, a closed
tab, or a genuine network issue.
---
## 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).
- Poll for the extension's PIN confirmation, addressed to run ID
(`pin_display`, above).
- 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.