11 KiB
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
MediaDevicescamera 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; sendingkem_ciphertextto 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 pollsGET /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 toidle.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 pinnedED25519_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 sendkem_ciphertextor 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 ofalpha_hashfrom 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_ciphertextandkem_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_ciphertextto 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
completeor on any transition toidleorerror. - 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_PUBKEYis rejected before any network call is made. - The companion does not proceed past
verifyingif 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.