locqr/mobile/claude.md
2026-08-16 22:00:08 +02:00

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 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. User reads the PIN and taps Continue on the companion once they've compared it.
  • awaiting_confirmation — user has tapped Continue; polling GET /run/relay/:runId/confirm (interfaces.md, "PIN confirmation") for the extension's own confirmation, which only exists once its user has separately clicked Confirm there. Advancing requires both: this device's local tap and the other device's remote signal, neither one alone. Superseded design note: an earlier version of this document had the companion proceed the instant its own user tapped Continue, with "the companion does not receive a signal from the extension." That turned out to be weaker than necessary for no real benefit — Bluetooth-style Numeric Comparison pairing has always communicated the confirmation between devices as part of completing the handshake, and there was no good reason for this protocol to do less. 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
  → awaiting_confirmation     (user taps Continue)
  → idle                      (user cancels; run key discarded)

awaiting_confirmation
  → credential_select         (extension's confirmation received)
  → 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.mdCompanion 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 (awaiting_confirmation, 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.