From bc025d007cd75658635ba420a6c6432fe40fa423 Mon Sep 17 00:00:00 2001 From: Rasmus Neikes Date: Tue, 18 Aug 2026 02:16:05 +0200 Subject: [PATCH] happy path working with stub companion app --- companion/build.js | 18 + companion/index.html | 33 ++ companion/package.json | 20 + companion/src/api.ts | 109 +++++ companion/src/base64url.ts | 22 + companion/src/constants.ts | 10 + companion/src/credentials.ts | 37 ++ companion/src/crypto.ts | 175 ++++++++ companion/src/main.ts | 398 ++++++++++++++++++ companion/src/scanner.ts | 58 +++ companion/src/state.ts | 49 +++ companion/tsconfig.json | 13 + crypto.md | 24 +- dev.md | 4 + extension/claude.md | 15 +- extension/gui.md | 21 +- extension/src/background/index.ts | 31 +- extension/src/background/run-secrets.ts | 1 + extension/src/background/run.ts | 69 ++- extension/src/background/session-status.ts | 20 + extension/src/popup/popup.ts | 36 +- extension/src/types.ts | 16 +- interfaces.md | 45 +- mobile/claude.md | 39 +- scripts/serve-companion.js | 64 +++ scripts/serve-lan-dns.sh | 55 +++ sdk/claude.md | 27 ++ sdk/src/index.ts | 23 + .../dev/locqr/server/config/CorsConfig.java | 49 ++- .../locqr/server/config/LocqrProperties.java | 2 +- .../server/config/RelayEndpointConfig.java | 36 ++ .../controller/PinConfirmationController.java | 9 +- .../server/controller/RunController.java | 3 +- .../server/dto/RunBundleUploadResponse.java | 3 +- .../server/relay/RelaySessionRegistry.java | 11 +- server/src/main/resources/application.yml | 14 +- .../locqr/server/RunFlowIntegrationTest.java | 1 + .../server/crypto/SigningServiceTest.java | 2 +- .../relay/RelaySessionRegistryTest.java | 2 +- .../RegistrationCertificateStoreTest.java | 2 +- website/index.html | 48 ++- 41 files changed, 1512 insertions(+), 102 deletions(-) create mode 100644 companion/build.js create mode 100644 companion/index.html create mode 100644 companion/package.json create mode 100644 companion/src/api.ts create mode 100644 companion/src/base64url.ts create mode 100644 companion/src/constants.ts create mode 100644 companion/src/credentials.ts create mode 100644 companion/src/crypto.ts create mode 100644 companion/src/main.ts create mode 100644 companion/src/scanner.ts create mode 100644 companion/src/state.ts create mode 100644 companion/tsconfig.json create mode 100644 extension/src/background/session-status.ts create mode 100644 scripts/serve-companion.js create mode 100644 scripts/serve-lan-dns.sh diff --git a/companion/build.js b/companion/build.js new file mode 100644 index 0000000..918f124 --- /dev/null +++ b/companion/build.js @@ -0,0 +1,18 @@ +import * as esbuild from 'esbuild'; +import { cpSync, mkdirSync } from 'node:fs'; + +const outdir = 'dist'; +mkdirSync(outdir, { recursive: true }); + +await esbuild.build({ + entryPoints: { main: 'src/main.ts' }, + bundle: true, + format: 'esm', + target: 'es2022', + outdir, + logLevel: 'info', +}); + +cpSync('index.html', `${outdir}/index.html`); + +console.log(`Built -> ${outdir}/`); diff --git a/companion/index.html b/companion/index.html new file mode 100644 index 0000000..5a1ff90 --- /dev/null +++ b/companion/index.html @@ -0,0 +1,33 @@ + + + + +LOCQR companion (stub) + + + +

LOCQR companion — dev stub

+

Not the native app (mobile/claude.md). Full protocol, no persistence, no real biometrics.

+
+ + + diff --git a/companion/package.json b/companion/package.json new file mode 100644 index 0000000..95f3bd0 --- /dev/null +++ b/companion/package.json @@ -0,0 +1,20 @@ +{ + "name": "locqr-companion", + "private": true, + "version": "0.1.0", + "description": "LOCQR web companion stub (development tool, not the native app). See mobile/claude.md.", + "type": "module", + "scripts": { + "build": "node build.js" + }, + "dependencies": { + "@noble/curves": "^2.3.0", + "@noble/post-quantum": "0.6.1", + "canonicalize": "3.0.0", + "jsqr": "^1.4.0" + }, + "devDependencies": { + "esbuild": "^0.28.0", + "typescript": "^5.6.0" + } +} diff --git a/companion/src/api.ts b/companion/src/api.ts new file mode 100644 index 0000000..26b09e4 --- /dev/null +++ b/companion/src/api.ts @@ -0,0 +1,109 @@ +// Backend calls the companion makes directly (mobile/claude.md's "Backend +// API surface"; schemas in interfaces.md). Mirrors extension/src/background/api.ts's +// shape — try/catch around fetch, boolean/result objects instead of thrown +// errors, so callers can render distinct states instead of a single generic +// failure. + +import { base64UrlDecode } from './base64url.js'; + +const API_BASE = 'https://api.locqr.dev:3000'; + +export interface RunBundle { + x25519Pubkey: Uint8Array; + kemPubkey: Uint8Array; +} + +export type BundleFetchResult = + | { ok: true; bundle: RunBundle } + | { ok: false; reason: 'bundle_consumed' | 'backend_unreachable' }; + +/** + * GET /run/bundle/:runId (interfaces.md, "Run bundle fetch") — at-most-once, + * atomically destroyed on this fetch. A 404 specifically means "already + * consumed or expired," a security-class outcome (possible QR hijack), not + * a generic network failure — every other non-2xx or a thrown fetch error + * is the network class instead. + */ +export async function fetchRunBundle(runId: string): Promise { + try { + const response = await fetch(`${API_BASE}/run/bundle/${runId}`); + if (response.status === 404) { + return { ok: false, reason: 'bundle_consumed' }; + } + if (!response.ok) { + return { ok: false, reason: 'backend_unreachable' }; + } + const body = (await response.json()) as { x25519_pubkey: string; kem_pubkey: string }; + return { + ok: true, + bundle: { x25519Pubkey: base64UrlDecode(body.x25519_pubkey), kemPubkey: base64UrlDecode(body.kem_pubkey) }, + }; + } catch { + return { ok: false, reason: 'backend_unreachable' }; + } +} + +async function postRelay(runId: string, type: 'kem_ciphertext' | 'credential', payloadB64: string): Promise { + try { + const response = await fetch(`${API_BASE}/run/relay/${runId}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ type, payload_b64: payloadB64 }), + }); + return response.ok; + } catch { + return false; + } +} + +/** POST /run/relay/:runId, type "kem_ciphertext" (interfaces.md, "Relay messages"), Phase 2. */ +export function postKemCiphertext(runId: string, payloadB64: string): Promise { + return postRelay(runId, 'kem_ciphertext', payloadB64); +} + +/** POST /run/relay/:runId, type "credential" (interfaces.md, "Relay messages"), Phase 4. */ +export function postCredential(runId: string, payloadB64: string): Promise { + return postRelay(runId, 'credential', payloadB64); +} + +/** + * GET /run/relay/:runId/confirm (interfaces.md, "PIN confirmation") — a + * single poll. `main.ts`'s waitForConfirmation loop (started automatically + * on entering pin_display) calls this repeatedly rather than looping in + * here, so a user's Cancel can interrupt between polls instead of only + * after a bounded wait elapses. + */ +export async function getConfirmation(runId: string): Promise { + try { + const response = await fetch(`${API_BASE}/run/relay/${runId}/confirm`); + if (!response.ok) return false; + const body = (await response.json()) as { confirmed: boolean }; + return body.confirmed; + } catch { + return false; + } +} + +export type CompanionSecurityErrorType = 'bundle_consumed' | 'alpha_hash_mismatch' | 'token_signature_invalid'; + +/** + * POST /security/report (interfaces.md) — best-effort, same as the + * extension's reportSecurityError: a failed report must not block the + * user-facing outcome, which is already determined by this point. + * `runId` is omitted for a bad token, per interfaces.md — no run + * has been joined yet at that point. + */ +export async function reportSecurityError(runId: string | undefined, errorType: CompanionSecurityErrorType): Promise { + try { + const response = await fetch(`${API_BASE}/security/report`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ runId, error_type: errorType, timestamp: Math.floor(Date.now() / 1000) }), + }); + if (!response.ok) { + console.error(`[locqr companion] security report rejected: ${response.status}`); + } + } catch (err) { + console.error('[locqr companion] security report failed to send:', err); + } +} diff --git a/companion/src/base64url.ts b/companion/src/base64url.ts new file mode 100644 index 0000000..1d64ea8 --- /dev/null +++ b/companion/src/base64url.ts @@ -0,0 +1,22 @@ +// base64url (RFC 4648 §5), no padding — interfaces.md's shared conventions. +// Same implementation as extension/src/base64url.ts; duplicated rather than +// shared across the two independent build targets. + +export function base64UrlDecode(s: string): Uint8Array { + const unpadded = s.replace(/-/g, '+').replace(/_/g, '/'); + const padded = unpadded + '='.repeat((4 - (unpadded.length % 4)) % 4); + const binary = atob(padded); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) { + bytes[i] = binary.charCodeAt(i); + } + return bytes; +} + +export function base64UrlEncode(bytes: Uint8Array): string { + let binary = ''; + for (const byte of bytes) { + binary += String.fromCharCode(byte); + } + return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); +} diff --git a/companion/src/constants.ts b/companion/src/constants.ts new file mode 100644 index 0000000..7035d7c --- /dev/null +++ b/companion/src/constants.ts @@ -0,0 +1,10 @@ +// Compile-time pinned constant (mobile/claude.md's "Compile-time constants" +// table; crypto.md's "Compile-time key constants"). Real dev value from +// dev/keys/ed25519-public.b64 — hand-set for this slice, same as the +// extension's ED25519_PUBLIC_KEY_B64 (extension/src/constants.ts); both +// name the same key material under their own component's constant name. +// scripts/setup-dev.js's constant-patching automation is a documented +// fast-follow once this pattern is proven (dev.md). + +/** Ed25519 — verifies signed key exchange tokens (interfaces.md, "Signed token (QR payload)"). */ +export const ED25519_PUBKEY = 'Z9ppr33AB5gznf-mQU2F9Y3E-MEoNcGHtmlh7rzaTh4'; diff --git a/companion/src/credentials.ts b/companion/src/credentials.ts new file mode 100644 index 0000000..524785d --- /dev/null +++ b/companion/src/credentials.ts @@ -0,0 +1,37 @@ +// Session-only, in-memory credential store (mobile/claude.md's web stub +// scope: "No persistent credential storage" — deliberately, this is not a +// preview of the native app's Keystore-backed store). Stored per domain, +// each domain mapping to an ordered list, matching the native app's +// storage structure (mobile/claude.md, "Credential storage") minus +// encryption at rest and persistence. Seeded with more than one entry for +// test.locqr.dev so the `credential_select` state's multi-credential +// branch is actually reachable in testing — a single hardcoded credential +// would never exercise it. + +export interface CredentialEntry { + label: string; + username: string; + password: string; +} + +const store = new Map([ + [ + 'test.locqr.dev', + [ + { label: 'Personal', username: 'alice@example.com', password: 'hunter2' }, + { label: 'Work', username: 'alice.work@example.com', password: 'correct-horse-battery-staple' }, + ], + ], +]); + +/** Returns a copy — callers (main.ts's state) must not observe later addCredential() mutations of the store's own array. */ +export function credentialsForDomain(domain: string): CredentialEntry[] { + return [...(store.get(domain) ?? [])]; +} + +/** Manual entry (flows.md's "Manual entry" provisioning path) — session-only, lost on refresh. */ +export function addCredential(domain: string, entry: CredentialEntry): void { + const list = store.get(domain) ?? []; + list.push(entry); + store.set(domain, list); +} diff --git a/companion/src/crypto.ts b/companion/src/crypto.ts new file mode 100644 index 0000000..d893dd7 --- /dev/null +++ b/companion/src/crypto.ts @@ -0,0 +1,175 @@ +// Companion-side run cryptography (crypto.md; mobile/claude.md's +// "Cryptographic responsibilities"; interfaces.md's "Signed token (QR +// payload)" and "Encrypted credential payload"). Pure functions — no DOM +// access — mirrors the shape of extension/src/background/crypto.ts and +// scripts/companion-harness.mjs, which implement the extension's and a +// Node stand-in's halves of the same protocol respectively. +// +// Library choice: @noble/post-quantum + @noble/curves, not @oqs/liboqs-js — +// resolving the "open call for whoever builds the companion" left by +// crypto.md's Library choices section. liboqs-js would work here (a normal +// page context has no service-worker WASM restriction), but there's no +// reason to carry a second ~17MB WASM PQ library across the two browser +// components when @noble/post-quantum is already proven working, pure TS, +// and — via scripts/companion-harness.mjs, the closest existing reference +// for this component's protocol behaviour — already the de facto choice. +// See crypto.md's updated Library choices table. + +import { ed25519, x25519 } from '@noble/curves/ed25519.js'; +import { ml_kem768 } from '@noble/post-quantum/ml-kem.js'; +import canonicalize from 'canonicalize'; +import { base64UrlDecode } from './base64url.js'; +import { ED25519_PUBKEY } from './constants.js'; + +function concat(...parts: Uint8Array[]): Uint8Array { + const total = parts.reduce((sum, p) => sum + p.length, 0); + const out = new Uint8Array(total); + let offset = 0; + for (const part of parts) { + out.set(part, offset); + offset += part.length; + } + return out; +} + +export function bytesEqual(a: Uint8Array, b: Uint8Array): boolean { + return a.length === b.length && a.every((v, i) => v === b[i]); +} + +// interfaces.md: "The companion accepts tokens up to 30 seconds past +// expires_at to cover device clock skew. This is the only clock tolerance +// in the protocol." +const EXPIRY_LEEWAY_SECONDS = 30; + +export interface SignedTokenPayload { + v: number; + runId: string; + url: string; + expires_at: number; + alpha_hash: string; +} + +export type TokenVerificationResult = + | { ok: true; payload: SignedTokenPayload } + | { ok: false; reason: 'signature_invalid' | 'ttl_expired' }; + +/** + * verifying state (mobile/claude.md): decode, check the Ed25519 signature + * over the JCS-canonicalised payload, then check expiry. mobile/claude.md + * defines no separate "malformed" error class for the companion (unlike + * the extension's cert verification) — unparseable or structurally invalid + * content is just as untrusted as a bad signature, so it folds into the + * same signature_invalid security-class bucket rather than inventing a + * fourth class nothing else in the protocol expects. + */ +export function verifySignedToken(qrContent: string): TokenVerificationResult { + let envelope: Record; + try { + envelope = JSON.parse(new TextDecoder().decode(base64UrlDecode(qrContent))) as Record; + } catch { + return { ok: false, reason: 'signature_invalid' }; + } + + const { sig, ...payload } = envelope; + if ( + typeof sig !== 'string' || + typeof payload.v !== 'number' || + typeof payload.runId !== 'string' || + typeof payload.url !== 'string' || + typeof payload.expires_at !== 'number' || + typeof payload.alpha_hash !== 'string' + ) { + return { ok: false, reason: 'signature_invalid' }; + } + + let signatureValid: boolean; + try { + const canonical = canonicalize(payload) as string; + const message = new TextEncoder().encode(canonical); + signatureValid = ed25519.verify(base64UrlDecode(sig), message, base64UrlDecode(ED25519_PUBKEY)); + } catch { + return { ok: false, reason: 'signature_invalid' }; + } + if (!signatureValid) { + return { ok: false, reason: 'signature_invalid' }; + } + + const nowSeconds = Date.now() / 1000; + if (nowSeconds > (payload.expires_at as number) + EXPIRY_LEEWAY_SECONDS) { + return { ok: false, reason: 'ttl_expired' }; + } + + return { ok: true, payload: payload as unknown as SignedTokenPayload }; +} + +export interface X25519Keypair { + secretKey: Uint8Array; + publicKey: Uint8Array; +} + +export function generateX25519Keypair(): X25519Keypair { + return x25519.keygen(); +} + +export function x25519SharedSecret(secretKey: Uint8Array, peerPublicKey: Uint8Array): Uint8Array { + return x25519.getSharedSecret(secretKey, peerPublicKey); +} + +export function mlKem768Encapsulate(publicKey: Uint8Array): { cipherText: Uint8Array; sharedSecret: Uint8Array } { + return ml_kem768.encapsulate(publicKey); +} + +/** + * alpha_hash recomputation (interfaces.md) — must equal the commitment + * carried in the signed token, or the fetched key bundle has been tampered + * with between the extension and the backend (`alpha_hash_mismatch`, + * security-class). + */ +export async function alphaHash(x25519Pub: Uint8Array, kemPub: Uint8Array): Promise { + const digest = await crypto.subtle.digest('SHA-256', concat(x25519Pub, kemPub) as BufferSource); + return new Uint8Array(digest); +} + +async function hkdfSha256(ikm: Uint8Array, salt: Uint8Array, info: string, lengthBytes: number): Promise { + const key = await crypto.subtle.importKey('raw', ikm as BufferSource, 'HKDF', false, ['deriveBits']); + const bits = await crypto.subtle.deriveBits( + { name: 'HKDF', hash: 'SHA-256', salt: salt as BufferSource, info: new TextEncoder().encode(info) }, + key, + lengthBytes * 8, + ); + return new Uint8Array(bits); +} + +/** run_key = HKDF-SHA256(x25519_shared || kem_shared_secret, salt=runId_utf8, info="locqr-run-key-v1", 32) — crypto.md. */ +export async function deriveRunKey(x25519Shared: Uint8Array, kemSharedSecret: Uint8Array, runId: string): Promise { + const ikm = concat(x25519Shared, kemSharedSecret); + const salt = new TextEncoder().encode(runId); + return hkdfSha256(ikm, salt, 'locqr-run-key-v1', 32); +} + +/** + * PIN = HKDF-SHA256(run_key, salt=[], info="locqr-pin-v1", length=4), + * interpreted big-endian, mod 1,000,000, zero-padded to six digits + * (mobile/claude.md). Must match the extension's derivePin() exactly. + */ +export async function derivePin(runKey: Uint8Array): Promise { + const bits = await hkdfSha256(runKey, new Uint8Array(0), 'locqr-pin-v1', 4); + const value = new DataView(bits.buffer, bits.byteOffset, bits.byteLength).getUint32(0, false); + return (value % 1_000_000).toString().padStart(6, '0'); +} + +export interface Credential { + username: string; + password: string; +} + +/** Wire framing (interfaces.md): nonce[12] || ciphertext[N] || tag[16], one encryption per run key. */ +export async function encryptCredential(runKey: Uint8Array, credential: Credential): Promise { + const nonce = crypto.getRandomValues(new Uint8Array(12)); + const plaintext = new TextEncoder().encode(JSON.stringify(credential)); + const key = await crypto.subtle.importKey('raw', runKey as BufferSource, 'AES-GCM', false, ['encrypt']); + const ciphertextAndTag = new Uint8Array(await crypto.subtle.encrypt({ name: 'AES-GCM', iv: nonce as BufferSource }, key, plaintext)); + return concat(nonce, ciphertextAndTag); +} + +export { concat }; diff --git a/companion/src/main.ts b/companion/src/main.ts new file mode 100644 index 0000000..566ecf0 --- /dev/null +++ b/companion/src/main.ts @@ -0,0 +1,398 @@ +// Companion web stub entry point — drives mobile/claude.md's state model +// end to end against the real server and a real running extension. Plain +// DOM rendering (innerHTML template strings + data-action click delegation), +// no framework: same approach as extension/src/popup/popup.ts. Visual +// design is out of scope (mobile/gui.md is empty; mobile/claude.md: "Adhere +// to gui.md only where doing so costs nothing"). + +import { getConfirmation, fetchRunBundle, postCredential, postKemCiphertext, reportSecurityError } from './api.js'; +import { base64UrlDecode, base64UrlEncode } from './base64url.js'; +import { addCredential, credentialsForDomain, type CredentialEntry } from './credentials.js'; +import { + alphaHash, + bytesEqual, + concat, + deriveRunKey, + derivePin, + encryptCredential, + generateX25519Keypair, + mlKem768Encapsulate, + verifySignedToken, + x25519SharedSecret, +} from './crypto.js'; +import { startScanner, type ScannerHandle } from './scanner.js'; +import { ERROR_CLASS, type CompanionState, type ErrorReason } from './state.js'; + +const CONFIRMATION_POLL_INTERVAL_MS = 1000; +// Generous safety net, not the authoritative deadline — the extension's own +// pin_ttl alarm on the other side is what actually bounds a real wait +// (mirrors scripts/companion-harness.mjs's identical choice). +const CONFIRMATION_POLL_TIMEOUT_MS = 180_000; +const COMPLETE_DISPLAY_MS = 2000; + +let state: CompanionState = { kind: 'idle' }; +let scannerHandle: ScannerHandle | null = null; + +// Bumped on every user-initiated cancel/reset so in-flight async chains +// (verify -> key exchange -> confirmation poll) can tell they've been +// superseded and stop applying their result to a state the user has +// already left. Checked, not cleared: cheaper than plumbing an +// AbortController through every await in the chain. +let generation = 0; + +function setState(next: CompanionState): void { + state = next; + render(); +} + +/** Cancel button at any pre-delivery stage — mobile/claude.md's `→ idle (user cancels; run key discarded)`. */ +function cancelToIdle(): void { + generation++; + if (scannerHandle) { + scannerHandle.stop(); + scannerHandle = null; + } + activeVideoEl = null; + setState({ kind: 'idle' }); +} + +async function beginAuthenticating(): Promise { + setState({ kind: 'authenticating' }); +} + +/** Stub stand-in for BiometricPrompt (mobile/claude.md's web stub scope). */ +async function unlockAndScan(): Promise { + const myGeneration = ++generation; + setState({ kind: 'scanning' }); + + const video = document.createElement('video'); + video.setAttribute('playsinline', ''); // iOS Safari: without this, play() opens fullscreen instead of inline + video.muted = true; + const canvas = document.createElement('canvas'); + canvas.hidden = true; + + try { + scannerHandle = await startScanner(video, canvas, (content) => { + if (myGeneration !== generation) return; + scannerHandle?.stop(); + scannerHandle = null; + void handleQrDecoded(content); + }); + } catch (err) { + if (myGeneration !== generation) return; + // Camera permission/hardware failures happen before any run exists + // and aren't in mobile/claude.md's post-scan error taxonomy — shown + // inline on the scanning screen itself rather than forced into one + // of the three formal error classes. + setState({ kind: 'scanning', cameraError: err instanceof Error ? err.message : String(err) }); + return; + } + + if (myGeneration !== generation) { + scannerHandle?.stop(); + scannerHandle = null; + return; + } + + // No state transition happens here — still 'scanning' — so nothing + // would otherwise trigger a re-render between entering this state and + // the stream becoming ready. Attach directly and force one so the + // preview actually appears instead of sitting in a detached element. + activeVideoEl = video; + render(); +} + +let activeVideoEl: HTMLVideoElement | null = null; + +async function handleQrDecoded(content: string): Promise { + const myGeneration = generation; + setState({ kind: 'verifying' }); + + const result = verifySignedToken(content); + if (!result.ok) { + if (result.reason === 'signature_invalid') { + await reportSecurityError(undefined, 'token_signature_invalid'); + } + if (myGeneration !== generation) return; + setState({ kind: 'error', reason: result.reason }); + return; + } + + const { runId, url, expires_at: _expiresAt, alpha_hash: alphaHashB64 } = result.payload; + if (myGeneration !== generation) return; + setState({ kind: 'key_exchange', runId, url }); + + const bundleResult = await fetchRunBundle(runId); + if (!bundleResult.ok) { + if (bundleResult.reason === 'bundle_consumed') { + await reportSecurityError(runId, 'bundle_consumed'); + } + if (myGeneration !== generation) return; + setState({ kind: 'error', reason: bundleResult.reason }); + return; + } + const { x25519Pubkey, kemPubkey } = bundleResult.bundle; + + const recomputedAlphaHash = await alphaHash(x25519Pubkey, kemPubkey); + if (!bytesEqual(recomputedAlphaHash, base64UrlDecode(alphaHashB64))) { + await reportSecurityError(runId, 'alpha_hash_mismatch'); + if (myGeneration !== generation) return; + setState({ kind: 'error', reason: 'alpha_hash_mismatch' }); + return; + } + + const companionX25519 = generateX25519Keypair(); + const x25519Shared = x25519SharedSecret(companionX25519.secretKey, x25519Pubkey); + const { cipherText: kemCiphertext, sharedSecret: kemSharedSecret } = mlKem768Encapsulate(kemPubkey); + + const runKey = await deriveRunKey(x25519Shared, kemSharedSecret, runId); + const pin = await derivePin(runKey); + + const posted = await postKemCiphertext(runId, base64UrlEncode(concat(companionX25519.publicKey, kemCiphertext))); + if (!posted) { + if (myGeneration !== generation) return; + setState({ kind: 'error', reason: 'relay_error' }); + return; + } + + if (myGeneration !== generation) return; + setState({ kind: 'pin_display', runId, url, pin, runKey }); + + // No local gate here — the PIN is shown for the person to compare + // against the extension's independently-derived one (the real + // anti-MITM check), but only the extension's own Confirm click + // advances the run. Polling starts the instant the PIN exists. + void waitForConfirmation(runId, url, runKey, myGeneration); +} + +/** + * Polls interfaces.md's PIN confirmation endpoint until the extension's + * user clicks Confirm there. Wrapped in try/catch and called with `void` + * from its caller (fire-and-forget, no attached .catch) — without this, + * any unexpected throw here (e.g. a malformed `url`) would silently + * reject and leave the UI frozen on pin_display forever with no visible + * sign anything went wrong. Surfacing it as `error/relay_error` instead + * is a diagnostic net as much as a fix: if this ever actually fires, the + * console.error pinpoints what threw. + */ +async function waitForConfirmation(runId: string, url: string, runKey: Uint8Array, myGeneration: number): Promise { + try { + const deadline = Date.now() + CONFIRMATION_POLL_TIMEOUT_MS; + while (Date.now() < deadline) { + if (myGeneration !== generation) return; + const confirmed = await getConfirmation(runId); + if (confirmed) { + if (myGeneration !== generation) return; + const domain = new URL(url).hostname; + setState({ kind: 'credential_select', runId, url, runKey, domain, credentials: credentialsForDomain(domain) }); + return; + } + await new Promise((resolve) => setTimeout(resolve, CONFIRMATION_POLL_INTERVAL_MS)); + } + if (myGeneration !== generation) return; + setState({ kind: 'error', reason: 'confirmation_timeout' }); + } catch (err) { + console.error('[locqr companion] waitForConfirmation crashed:', err); + if (myGeneration !== generation) return; + setState({ kind: 'error', reason: 'relay_error' }); + } +} + +function refreshCredentialSelect(): void { + if (state.kind !== 'credential_select') return; + setState({ ...state, credentials: credentialsForDomain(state.domain) }); +} + +async function deliverCredential(credential: CredentialEntry): Promise { + if (state.kind !== 'credential_select') return; + const { runId, runKey } = state; + const myGeneration = generation; + setState({ kind: 'delivering' }); + + const payload = await encryptCredential(runKey, credential); + const posted = await postCredential(runId, base64UrlEncode(payload)); + if (!posted) { + if (myGeneration !== generation) return; + setState({ kind: 'error', reason: 'relay_error' }); + return; + } + + if (myGeneration !== generation) return; + setState({ kind: 'complete' }); + setTimeout(() => { + if (myGeneration === generation) setState({ kind: 'idle' }); + }, COMPLETE_DISPLAY_MS); +} + +function retryFromError(): void { + void beginAuthenticating(); +} + +// --- Rendering --- + +const ERROR_COPY: Record = { + signature_invalid: { heading: 'Invalid code', body: "This code's signature didn't verify. It may be forged or corrupted." }, + alpha_hash_mismatch: { heading: 'Key mismatch', body: 'The key bundle from the server did not match this code. Possible tampering.' }, + bundle_consumed: { heading: 'Already used', body: 'This code was already used by another device, or has expired.' }, + ttl_expired: { heading: 'Code expired', body: 'This code expired before it was scanned. Try scanning a fresh one.' }, + user_abort: { heading: 'Cancelled', body: 'Sign-in was cancelled.' }, + backend_unreachable: { heading: "Can't reach LOCQR", body: "We couldn't reach the LOCQR server. Check your connection and try again." }, + relay_error: { heading: 'Something went wrong', body: "We couldn't complete sign-in. Please try again." }, + confirmation_timeout: { + heading: 'No response from browser', + body: "The browser didn't confirm the code in time. Make sure you clicked Confirm there.", + }, +}; + +function credentialListHtml(state: Extract): string { + if (state.credentials.length === 0) { + return ` +

No stored credentials for ${state.domain} yet.

+
+ + + + +
+ `; + } + const items = state.credentials + .map( + (c, i) => ` + `, + ) + .join(''); + return ` +

Sign in to ${state.domain} with:

+
+ ${items} + +
+
+ Add another credential +
+ + + + +
+
+ `; +} + +function pinGroupsHtml(pin: string): string { + const digits = pin.split(''); + const group = (d: string[]) => `
${d.map((x) => `
${x}
`).join('')}
`; + return `
${group(digits.slice(0, 3))}${group(digits.slice(3, 6))}
`; +} + +function contentHtml(): string { + switch (state.kind) { + case 'idle': + return ` +

Ready

+

Tap Scan QR to sign in to a site using LOCQR.

+ + `; + case 'authenticating': + return ` +

Unlock

+

Stub stand-in for biometric/device PIN (mobile/claude.md).

+ + + `; + case 'scanning': + return ` +

Scan QR

+
+ ${state.cameraError ? `

Camera error: ${state.cameraError}

` : ''} + + `; + case 'verifying': + return `

Verifying…

Checking the code's signature and fetching the key bundle.

`; + case 'key_exchange': + return `

Connecting…

Performing key exchange with ${state.url}.

`; + case 'pin_display': + return ` +

Compare this code

+ ${pinGroupsHtml(state.pin)} +

Check this matches the code shown in the browser extension for ${state.url}, then confirm there — no action needed here.

+ + `; + case 'credential_select': + return `

Choose a credential

${credentialListHtml(state)}`; + case 'delivering': + return `

Delivering…

Sending the encrypted credential.

`; + case 'complete': + return `

Done

Credential delivered. The browser should now show "Signed in".

`; + case 'error': { + const { heading, body } = ERROR_COPY[state.reason]; + const errorClass = ERROR_CLASS[state.reason]; + const retryButton = errorClass !== 'security' ? `` : ''; + return ` +

${errorClass === 'security' ? '⚠ ' : ''}${heading}

+

${body}

+ ${retryButton} + + `; + } + } +} + +function wireActions(): void { + document.querySelectorAll('#app button[data-action]').forEach((btn) => { + const action = btn.dataset.action; + btn.addEventListener('click', () => { + if (action === 'scan_qr') void beginAuthenticating(); + else if (action === 'unlock') void unlockAndScan(); + else if (action === 'cancel' || action === 'dismiss') cancelToIdle(); + else if (action === 'retry') retryFromError(); + }); + }); + + const selectForm = document.getElementById('credential-select-form') as HTMLFormElement | null; + selectForm?.addEventListener('submit', (e) => { + e.preventDefault(); + if (state.kind !== 'credential_select') return; + const selected = new FormData(selectForm).get('credential'); + const index = Number(selected); + const credential = state.credentials[index]; + if (credential) void deliverCredential(credential); + }); + + const addForm = document.getElementById('add-credential-form') as HTMLFormElement | null; + addForm?.addEventListener('submit', (e) => { + e.preventDefault(); + if (state.kind !== 'credential_select') return; + const data = new FormData(addForm); + const entry: CredentialEntry = { + label: String(data.get('label')), + username: String(data.get('username')), + password: String(data.get('password')), + }; + addCredential(state.domain, entry); + // Deliver immediately when this was the only-credential add path + // (empty-state form's "Add & use"); otherwise just refresh the list. + if (state.credentials.length === 0) void deliverCredential(entry); + else refreshCredentialSelect(); + }); +} + +function render(): void { + const app = document.getElementById('app'); + if (!app) return; + app.innerHTML = contentHtml(); + + if (state.kind === 'scanning' && activeVideoEl) { + const mount = document.getElementById('scanner-mount'); + mount?.appendChild(activeVideoEl); + activeVideoEl.className = 'camera-preview'; + } + + wireActions(); +} + +render(); diff --git a/companion/src/scanner.ts b/companion/src/scanner.ts new file mode 100644 index 0000000..9c9549e --- /dev/null +++ b/companion/src/scanner.ts @@ -0,0 +1,58 @@ +// Camera-based QR scanning (mobile/claude.md web stub: "Scans QR codes via +// the browser MediaDevices camera API"). Decode library: jsQR — pure JS, +// no WASM, the standard lightweight choice for in-browser barcode reading; +// not a crypto primitive, so it isn't subject to crypto.md's pinned-library +// list. + +import jsQR from 'jsqr'; + +export interface ScannerHandle { + stop: () => void; +} + +/** + * Opens the camera and polls frames via requestAnimationFrame until a QR + * code decodes, then calls `onDecode` once and stops polling on its own + * (the caller is still responsible for calling `stop()` to release the + * camera stream once it's done with the decoded content). + */ +export async function startScanner( + video: HTMLVideoElement, + canvas: HTMLCanvasElement, + onDecode: (content: string) => void, +): Promise { + const stream = await navigator.mediaDevices.getUserMedia({ video: { facingMode: 'environment' } }); + video.srcObject = stream; + await video.play(); + + const ctx = canvas.getContext('2d', { willReadFrequently: true }); + if (!ctx) throw new Error('2D canvas context unavailable'); + + let stopped = false; + let decoded = false; + + function tick(): void { + if (stopped || decoded) return; + if (video.readyState === video.HAVE_ENOUGH_DATA) { + canvas.width = video.videoWidth; + canvas.height = video.videoHeight; + ctx!.drawImage(video, 0, 0, canvas.width, canvas.height); + const frame = ctx!.getImageData(0, 0, canvas.width, canvas.height); + const code = jsQR(frame.data, frame.width, frame.height); + if (code) { + decoded = true; + onDecode(code.data); + return; + } + } + requestAnimationFrame(tick); + } + requestAnimationFrame(tick); + + return { + stop: () => { + stopped = true; + stream.getTracks().forEach((track) => track.stop()); + }, + }; +} diff --git a/companion/src/state.ts b/companion/src/state.ts new file mode 100644 index 0000000..b8294e4 --- /dev/null +++ b/companion/src/state.ts @@ -0,0 +1,49 @@ +// Companion state model (mobile/claude.md's "State model" — states and +// transitions are normative there; this is that diagram as a discriminated +// union). `authenticating` stands in for the native app's biometric/device +// PIN gate with a plain button (mobile/claude.md's web stub scope). + +import type { CredentialEntry } from './credentials.js'; + +/** mobile/claude.md, "Error classes". */ +export type ErrorReason = + | 'signature_invalid' + | 'alpha_hash_mismatch' + | 'bundle_consumed' + | 'ttl_expired' + | 'user_abort' + | 'backend_unreachable' + | 'relay_error' + | 'confirmation_timeout'; + +export type ErrorClass = 'security' | 'timeout_user' | 'network'; + +export const ERROR_CLASS: Record = { + signature_invalid: 'security', + alpha_hash_mismatch: 'security', + bundle_consumed: 'security', + ttl_expired: 'timeout_user', + user_abort: 'timeout_user', + backend_unreachable: 'network', + relay_error: 'network', + confirmation_timeout: 'network', +}; + +// `pin_display` covers both showing the PIN and waiting for the extension's +// confirmation — there is no separate user-gated step between them. The +// companion polls automatically the instant the PIN is derived; the PIN is +// still shown the whole time so the person can compare it against the +// extension's independently-derived PIN (the actual anti-MITM check), but +// nothing on the companion side gates advancement — only the extension's +// own Confirm click does (mobile/claude.md's "State model"). +export type CompanionState = + | { kind: 'idle' } + | { kind: 'authenticating' } + | { kind: 'scanning'; cameraError?: string } + | { kind: 'verifying' } + | { kind: 'key_exchange'; runId: string; url: string } + | { kind: 'pin_display'; runId: string; url: string; pin: string; runKey: Uint8Array } + | { kind: 'credential_select'; runId: string; url: string; runKey: Uint8Array; domain: string; credentials: CredentialEntry[] } + | { kind: 'delivering' } + | { kind: 'complete' } + | { kind: 'error'; reason: ErrorReason }; diff --git a/companion/tsconfig.json b/companion/tsconfig.json new file mode 100644 index 0000000..3a64ad5 --- /dev/null +++ b/companion/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2022", "DOM"], + "module": "ESNext", + "moduleResolution": "Bundler", + "strict": true, + "noUncheckedIndexedAccess": true, + "skipLibCheck": true, + "noEmit": true + }, + "include": ["src"] +} diff --git a/crypto.md b/crypto.md index e26159c..6258904 100644 --- a/crypto.md +++ b/crypto.md @@ -234,7 +234,7 @@ requires re-verifying interoperability end-to-end. | Concern | Runtime | Library | Version | |---|---|---|---| | ML-KEM-768 + ML-DSA-44 | Browser extension (MV3 service worker) | `@noble/post-quantum` (npm) | 0.6.1 | -| ML-KEM-768 + ML-DSA-44 | Web companion stub (normal page context) | `@oqs/liboqs-js` (npm) — unconfirmed, see below | 0.15.1 | +| ML-KEM-768 + ML-DSA-44 | Web companion stub (normal page context) | `@noble/post-quantum` (npm) — resolved, see below | 0.6.1 | | ML-KEM-768 + ML-DSA-44 | Node.js setup script | `@oqs/liboqs-js` (npm) | 0.15.1 | | ML-KEM-768 + ML-DSA-44 | Android / Kotlin | BouncyCastle (liboqs-java for production) | — | | ML-KEM-768 + ML-DSA-44 | Java/Spring (production) | liboqs-java | — | @@ -277,21 +277,25 @@ negative control confirmed to fail. One API difference from `@oqs/liboqs-js` worth flagging for implementers: argument order is `verify(signature, message, publicKey)`, not `verify(message, signature, publicKey)`. -The web companion stub row above is **not yet tested** — it runs in a normal -page context (`mobile/claude.md`: "single-page web application," no service -worker), where dynamic `import()` is allowed, so `@oqs/liboqs-js` should -still work there in principle. Whether to keep two different PQ libraries -across the browser-side components, or standardize on `@noble/post-quantum` -everywhere for consistency (simpler, no WASM asset handling anywhere), is an -open call for whoever builds the companion — not yet decided. +**Resolved — web companion stub standardizes on `@noble/post-quantum`, +same as the extension.** The web companion stub runs in a normal page +context (`mobile/claude.md`: "single-page web application," no service +worker), where dynamic `import()` is allowed, so `@oqs/liboqs-js` would +have worked there in principle — this wasn't ruled out the way it was for +the extension. But there was no reason to carry it: `scripts/companion-harness.mjs`, +the closest existing reference for this component's protocol behaviour, was +already built against `@noble/post-quantum`, and standardizing avoids +maintaining a second, ~17MB WASM PQ library across the two browser-side +components for no interoperability benefit — both still speak the same +wire format regardless of which library produced the bytes. **`@oqs/liboqs-js`:** Published by PQCA (Post-Quantum Cryptography Alliance), WASM bindings to the reference liboqs implementation, zero npm dependencies, MIT licence. Covers both ML-KEM-768 and ML-DSA-44 in a single package. Tracks the NIST reference implementation directly, reducing the risk of subtle algorithm deviations. Unpacked size is ~17 MB. Still the right choice for the -Node.js setup script (confirmed working — see `scripts/setup-dev.js`) and -possibly the web companion stub (unconfirmed); ruled out for the extension. +Node.js setup script (confirmed working — see `scripts/setup-dev.js`); ruled +out for the extension, and not used by the companion either (above). **JCS consistency:** All four runtimes use libraries from the same author (Samuel Erdtman, editor of RFC 8785). This maximises the likelihood of diff --git a/dev.md b/dev.md index 232720a..c5c76b0 100644 --- a/dev.md +++ b/dev.md @@ -166,6 +166,10 @@ extension/ 4. Add the hosts file entries for `test.locqr.dev` and `api.locqr.dev` 5. Load the extension unpacked from `extension/dist/` (or `extension/` once built) +6. `cd companion && npm install && npm run build`, then + `node scripts/serve-companion.js` — companion/dist/ isn't committed, so + this step is required before the companion stub is reachable at all + (unlike website/, which has no build step of its own) --- diff --git a/extension/claude.md b/extension/claude.md index 239b1c6..2167e12 100644 --- a/extension/claude.md +++ b/extension/claude.md @@ -88,7 +88,10 @@ machine. The popup reflects the state of the currently active tab. - `rejected` — account is suspended or deactivated. - `unreachable` — backend could not be reached; status unknown. Displayed distinctly from a confirmed rejection. -- `idle` — certificate valid, account confirmed; no active run. +- `idle` — certificate valid, account confirmed; no active run. Carries a + `sessionActive` hint (advisory only — interfaces.md's `session_status` + message) that changes the popup's copy/badge (`gui.md`) but nothing + about verification or the run flow itself. **Run states** (only reachable from `idle`) @@ -223,9 +226,7 @@ or overrides. | `qr_ttl` | How long a single QR code remains valid (Phase 2 countdown) | | `max_auto_refresh` | How many times the QR is automatically refreshed before the user must retry manually | | `pin_ttl` | How long the user has to confirm the PIN (Phase 3 countdown) | - -Further per-phase timeouts (e.g. Phase 4 relay wait) may be added to this blob -as the design progresses. +| `relay_wait_ttl` | How long Phase 4 waits for the credential after PIN confirmation before `run_error/relay_timeout` | ### Navigation rules @@ -273,7 +274,8 @@ opened; it always shows at minimum the current state. | `cert_invalid` (security) | security warning | Security warning; specific reason | — | | `account_error / rejected` | error | Account inactive | — | | `account_error / unreachable` | error | Cannot reach LOCQR server | — | -| `idle` | ready | Site verified; ready | Start run | +| `idle` (`sessionActive: false`) | ready | Site verified; ready | Sign in | +| `idle` (`sessionActive: true`) | ready | Already signed in (advisory hint) | Sign in again | | `phase_1` | animated | Connecting… | Abort | | `phase_2` | animated | QR code | Abort | | `phase_3` | animated | PIN; confirm or reject | Confirm, Abort | @@ -313,10 +315,11 @@ is a backend and account-management concern. | Compiled-in pubkeys | Source constants | Build time | | Per-tab state machine state | In-memory (worker) | Tab lifetime | | Site verification result | `chrome.storage.session` | Browser session | -| Run parameters (qr_ttl, max_auto_refresh, pin_ttl) | In-memory | Phase 1–4; discarded on delivery or error | +| Run parameters (qr_ttl, max_auto_refresh, pin_ttl, relay_wait_ttl) | In-memory | Phase 1–4; discarded on delivery or error | | auto_refresh_remaining counter | In-memory | Phase 2 only | | Ephemeral keypairs (X25519, ML-KEM-768) | In-memory | Phase 1–2 only | | Run key | In-memory | Phase 3–4; discarded on delivery or error | +| Session-active hint (advisory, from the site) | In-memory | Until the tab's next page load or close | | GYBBR identity keys | `chrome.storage.session` | Browser session (see below) | Private key material is never written to `chrome.storage.local`, `localStorage`, diff --git a/extension/gui.md b/extension/gui.md index 45ad0b6..04240b9 100644 --- a/extension/gui.md +++ b/extension/gui.md @@ -165,10 +165,23 @@ Same chrome, no countdown bar (none of these four are TTL-governed). A 48px badge + heading + short body, centered, shared across all four; only the badge color/glyph, heading, body, and action differ. -- **`idle`** — accent-blue tinted badge, checkmark glyph (matches the `ready` - toolbar icon). Heading "Ready to sign in," body naming the site - (`"{origin} supports secure sign-in with LOCQR."`). One primary action: - **Sign in**. +- **`idle`** — two variants depending on the site's advisory + `sessionActive` hint (`claude.md`, `interfaces.md`'s `session_status` + message); same badge treatment either way, copy and action differ: + - No session reported (default): accent-blue tinted badge, checkmark + glyph (matches the `ready` toolbar icon). Heading "Ready to sign in," + body naming the site (`"{origin} supports secure sign-in with + LOCQR."`). One primary action: **Sign in**. + - Session reported active: green tinted badge, checkmark glyph (same + glyph, badge color shifts to match `delivered`'s green — reads as "a + settled, good state," not an action item). Heading "Signed in," body + (`"You're already signed in to {origin}."`). One primary action: + **Sign in again** — same underlying action as plain `idle`'s Sign in + (starts a new run), just worded for a user who's already + authenticated. No extra confirmation before it: starting a run + doesn't affect the existing session at all, and the run's own + multi-step confirmation (QR scan, PIN compare) is already real friction + against an accidental click. - **`phase_1`** and **`phase_4`** share one template: accent-blue tinted badge containing the rotating-ring spinner (real CSS rotation, not a static frame; honors `prefers-reduced-motion` by holding still instead of diff --git a/extension/src/background/index.ts b/extension/src/background/index.ts index 720f7b1..bebba01 100644 --- a/extension/src/background/index.ts +++ b/extension/src/background/index.ts @@ -8,6 +8,7 @@ import { queryDomainStatus, reportSecurityError } from './api.js'; import { abortRun, cleanupRun, confirmPin, dismissRunError, startRun } from './run.js'; +import { clearSessionActive, getSessionActive, setSessionActive } from './session-status.js'; import { registerSdkPort, unregisterSdkPort } from './sdk-ports.js'; import { clearTabState, getTabState, setTabState } from './state.js'; import { verifyRegistrationCert } from './verify.js'; @@ -91,10 +92,30 @@ async function handleInit(port: chrome.runtime.Port, tabId: number, originUrl: s return; } - await setTabState(tabId, { kind: 'idle', origin: result.domain, features: result.features }); + await setTabState(tabId, { + kind: 'idle', + origin: result.domain, + features: result.features, + sessionActive: getSessionActive(tabId), + }); respond(port, { type: 'verification', status: 'valid', features: result.features }); } +/** + * SDK `session_status` — advisory only (types.ts's SessionStatusMessage). + * Remembered even outside `idle` (e.g. reported before verification + * finishes, or during an unrelated run) so the *next* time `idle` is + * entered it reflects the latest report, not just whichever one happened + * to arrive while already idle. + */ +async function handleSessionStatus(tabId: number, active: boolean): Promise { + setSessionActive(tabId, active); + const state = await getTabState(tabId); + if (state.kind === 'idle') { + await setTabState(tabId, { ...state, sessionActive: active }); + } +} + function respond(port: chrome.runtime.Port, message: VerificationMessage): void { try { port.postMessage(message); @@ -140,6 +161,9 @@ chrome.runtime.onConnectExternal.addListener((port) => { registerSdkPort(tabId, port); port.onDisconnect.addListener(() => unregisterSdkPort(tabId, port)); + // A fresh connection is a fresh page load — any session hint from a + // previous page at this tab is stale until this page re-reports it. + clearSessionActive(tabId); void setTabState(tabId, { kind: 'unverified' }); port.onMessage.addListener((raw: unknown) => { @@ -150,6 +174,8 @@ chrome.runtime.onConnectExternal.addListener((port) => { void handleRequestCredential(tabId, windowId); } else if (message.type === 'abort') { void handleAbort(tabId); + } else if (message.type === 'session_status') { + void handleSessionStatus(tabId, message.active); } }); }); @@ -170,6 +196,9 @@ chrome.runtime.onMessage.addListener((raw: unknown) => { async function handlePopupAction(message: PopupActionMessage): Promise { const { tabId, action } = message; const state = await getTabState(tabId); + // Diagnostic: distinguishes "message never arrived" from "arrived but + // no-opped because state didn't match" — both looked identical before. + console.log(`[locqr] popup_action received: action=${action} tabId=${tabId} currentState=${state.kind}`); switch (action) { case 'start_run': diff --git a/extension/src/background/run-secrets.ts b/extension/src/background/run-secrets.ts index 5d88297..440b043 100644 --- a/extension/src/background/run-secrets.ts +++ b/extension/src/background/run-secrets.ts @@ -16,6 +16,7 @@ export interface RunSecrets { qrTtlSeconds: number; maxAutoRefresh: number; pinTtlSeconds: number; + relayWaitTtlSeconds: number; autoRefreshRemaining: number; // claude.md: "the extension tracks auto_refresh_remaining" } diff --git a/extension/src/background/run.ts b/extension/src/background/run.ts index 87e2e77..c324d47 100644 --- a/extension/src/background/run.ts +++ b/extension/src/background/run.ts @@ -18,6 +18,7 @@ import { } from './crypto.js'; import { closeRelay, openRelay } from './relay.js'; import { clearRunSecrets, getRunSecrets, setRunSecrets, type RunSecrets } from './run-secrets.js'; +import { clearSessionActive, getSessionActive } from './session-status.js'; import { sendToSdk } from './sdk-ports.js'; import { getTabState, setTabState } from './state.js'; @@ -35,6 +36,7 @@ const DELIVERED_DISPLAY_MS = 2000; // scheduling always clears any prior alarm of the same kind first. const QR_ALARM_PREFIX = 'locqr-qr-ttl'; const PIN_ALARM_PREFIX = 'locqr-pin-ttl'; +const RELAY_WAIT_ALARM_PREFIX = 'locqr-relay-wait'; function qrAlarmName(tabId: number): string { return `${QR_ALARM_PREFIX}:${tabId}`; @@ -42,6 +44,9 @@ function qrAlarmName(tabId: number): string { function pinAlarmName(tabId: number): string { return `${PIN_ALARM_PREFIX}:${tabId}`; } +function relayWaitAlarmName(tabId: number): string { + return `${RELAY_WAIT_ALARM_PREFIX}:${tabId}`; +} function scheduleQrTtlAlarm(tabId: number, qrTtlSeconds: number): void { const name = qrAlarmName(tabId); @@ -53,9 +58,15 @@ function schedulePinTtlAlarm(tabId: number, pinTtlSeconds: number): void { chrome.alarms.clear(name); chrome.alarms.create(name, { delayInMinutes: pinTtlSeconds / 60 }); } +function scheduleRelayWaitAlarm(tabId: number, relayWaitTtlSeconds: number): void { + const name = relayWaitAlarmName(tabId); + chrome.alarms.clear(name); + chrome.alarms.create(name, { delayInMinutes: relayWaitTtlSeconds / 60 }); +} function clearRunAlarms(tabId: number): void { chrome.alarms.clear(qrAlarmName(tabId)); chrome.alarms.clear(pinAlarmName(tabId)); + chrome.alarms.clear(relayWaitAlarmName(tabId)); } export async function startRun(tabId: number, origin: string, features: string[]): Promise { @@ -82,6 +93,31 @@ export async function startRun(tabId: number, origin: string, features: string[] }); } +/** + * interfaces.md's "Run bundle upload": "`origin` is the full HTTPS origin + * taken from `sender.tab.url`. The server bakes it verbatim into the + * signed token's `url` field." TabState's own `origin` field is *not* + * that — it's the bare hostname from cert verification (`result.domain`, + * index.ts's handleInit), reused everywhere else in this codebase for + * display and cert-domain matching, where a bare hostname is exactly + * right. Using it here instead sent a schemeless string like + * `"test.locqr.dev"` into the token's `url` field — invalid as a URL + * (`new URL(...)` throws on it), silently broken until the companion + * became the first client to actually parse that field. Re-fetches the + * tab's current URL fresh here rather than threading a second origin + * value through every call site down from `startRun`. + */ +async function resolveFullOrigin(tabId: number, fallbackHostname: string): Promise { + try { + const tab = await chrome.tabs.get(tabId); + if (tab.url) return new URL(tab.url).origin; + } catch { + // Tab gone or inaccessible between the run starting and this upload + // (e.g. auto-refresh racing a closing tab) — fall through. + } + return `https://${fallbackHostname}`; +} + /** * Generates a fresh ephemeral keypair, uploads it under `runId`, and shows * the resulting QR — the whole of Phase 1's work. Used both for the @@ -99,13 +135,19 @@ async function uploadBundleAndDisplayQr(tabId: number, origin: string, features: const uploadResult = await uploadRunBundle({ runId, - origin, + origin: await resolveFullOrigin(tabId, origin), x25519_pubkey: base64UrlEncode(x25519Keys.publicKey), kem_pubkey: base64UrlEncode(kemKeys.publicKey), }); if (!uploadResult.ok) return false; - const { signed_token: qrContent, qr_ttl: qrTtlSeconds, max_auto_refresh: maxAutoRefresh, pin_ttl: pinTtlSeconds } = uploadResult.response; + const { + signed_token: qrContent, + qr_ttl: qrTtlSeconds, + max_auto_refresh: maxAutoRefresh, + pin_ttl: pinTtlSeconds, + relay_wait_ttl: relayWaitTtlSeconds, + } = uploadResult.response; const existing = getRunSecrets(tabId); const secrets: RunSecrets = { runId, @@ -114,6 +156,7 @@ async function uploadBundleAndDisplayQr(tabId: number, origin: string, features: qrTtlSeconds, maxAutoRefresh, pinTtlSeconds, + relayWaitTtlSeconds, autoRefreshRemaining: existing?.autoRefreshRemaining ?? maxAutoRefresh, }; setRunSecrets(tabId, secrets); @@ -150,11 +193,19 @@ async function handlePinTtlExpiry(tabId: number): Promise { await failRun(tabId, state.origin, state.features, 'pin_timeout', 'timeout_user'); } +/** relay_wait_ttl expired waiting for the credential (claude.md's phase_4 transitions). */ +async function handleRelayWaitExpiry(tabId: number): Promise { + const state = await getTabState(tabId); + if (state.kind !== 'phase_4') return; // stale alarm — credential already arrived or run ended + await failRun(tabId, state.origin, state.features, 'relay_timeout', 'network'); +} + chrome.alarms.onAlarm.addListener((alarm) => { const [prefix, tabIdStr] = alarm.name.split(':'); const tabId = Number(tabIdStr); if (prefix === QR_ALARM_PREFIX) void handleQrTtlExpiry(tabId); else if (prefix === PIN_ALARM_PREFIX) void handlePinTtlExpiry(tabId); + else if (prefix === RELAY_WAIT_ALARM_PREFIX) void handleRelayWaitExpiry(tabId); }); async function handleKemCiphertext(tabId: number, origin: string, features: string[], payloadB64: string): Promise { @@ -196,13 +247,14 @@ async function processCredential(tabId: number, origin: string, features: string return; } + chrome.alarms.clear(relayWaitAlarmName(tabId)); closeRelay(tabId); clearRunSecrets(tabId); await setTabState(tabId, { kind: 'delivered', origin, features }); sendToSdk(tabId, { type: 'run_delivered', credential }); setTimeout(() => { - void setTabState(tabId, { kind: 'idle', origin, features }); + void setTabState(tabId, { kind: 'idle', origin, features, sessionActive: getSessionActive(tabId) }); }, DELIVERED_DISPLAY_MS); } @@ -229,12 +281,14 @@ export async function confirmPin(tabId: number, origin: string, features: string chrome.alarms.clear(pinAlarmName(tabId)); await setTabState(tabId, { kind: 'phase_4', origin, features }); + const secrets = getRunSecrets(tabId); + if (secrets) scheduleRelayWaitAlarm(tabId, secrets.relayWaitTtlSeconds); + // Unlocks the companion, which is gated on this arriving before it will // proceed past its own local pin_display (interfaces.md, "PIN // confirmation"). Fire-and-forget: this side's own phase_4 transition // above is already the real local gate and doesn't depend on this call. - const runId = getRunSecrets(tabId)?.runId; - if (runId) void postPinConfirmation(runId); + if (secrets) void postPinConfirmation(secrets.runId); } /** Popup action: user tapped Abort at any run phase, or the SDK sent `abort`. */ @@ -244,14 +298,15 @@ export async function abortRun(tabId: number, origin: string, features: string[] /** Popup action: user dismissed a run_error screen — clears back to idle. */ export async function dismissRunError(tabId: number, origin: string, features: string[]): Promise { - await setTabState(tabId, { kind: 'idle', origin, features }); + await setTabState(tabId, { kind: 'idle', origin, features, sessionActive: getSessionActive(tabId) }); } -/** Tab closed mid-run (index.ts's chrome.tabs.onRemoved) — release everything a run held. */ +/** Tab closed (index.ts's chrome.tabs.onRemoved) — release everything a run or verification held. */ export function cleanupRun(tabId: number): void { closeRelay(tabId); clearRunSecrets(tabId); clearRunAlarms(tabId); + clearSessionActive(tabId); } async function failRun( diff --git a/extension/src/background/session-status.ts b/extension/src/background/session-status.ts new file mode 100644 index 0000000..8f71810 --- /dev/null +++ b/extension/src/background/session-status.ts @@ -0,0 +1,20 @@ +// Per-tab "does the site think it has an active session" hint, reported by +// the SDK's session_status message. Advisory only — see types.ts's +// SessionStatusMessage comment. In-memory only, same lifecycle as +// sdk-ports.ts's Port map: lost on service worker restart, which just +// means the popup shows the plain idle screen until the site re-reports +// (harmless — it's a UI hint, nothing depends on it surviving). + +const sessionActiveByTab = new Map(); + +export function setSessionActive(tabId: number, active: boolean): void { + sessionActiveByTab.set(tabId, active); +} + +export function getSessionActive(tabId: number): boolean { + return sessionActiveByTab.get(tabId) ?? false; +} + +export function clearSessionActive(tabId: number): void { + sessionActiveByTab.delete(tabId); +} diff --git a/extension/src/popup/popup.ts b/extension/src/popup/popup.ts index 60a7a6b..0737d12 100644 --- a/extension/src/popup/popup.ts +++ b/extension/src/popup/popup.ts @@ -93,14 +93,26 @@ function contentFor(state: TabState, origin: string): Content { body: origin ? `${origin} hasn't set up sign-in with LOCQR.` : "This site hasn't set up sign-in with LOCQR.", }; case 'idle': - return { - badgeClass: 'accent-tint', - iconColor: 'accent', - glyph: 'check', - heading: 'Ready to sign in', - body: `${state.origin} supports secure sign-in with LOCQR.`, - actions: [{ label: 'Sign in', kind: 'start_run', style: 'primary' }], - }; + // sessionActive is an advisory hint from the site's own SDK + // call (types.ts's SessionStatusMessage) — it only changes the + // copy/badge here, never anything about how a run itself works. + return state.sessionActive + ? { + badgeClass: 'green-tint', + iconColor: 'green', + glyph: 'check', + heading: 'Signed in', + body: `You're already signed in to ${state.origin}.`, + actions: [{ label: 'Sign in again', kind: 'start_run', style: 'primary' }], + } + : { + badgeClass: 'accent-tint', + iconColor: 'accent', + glyph: 'check', + heading: 'Ready to sign in', + body: `${state.origin} supports secure sign-in with LOCQR.`, + actions: [{ label: 'Sign in', kind: 'start_run', style: 'primary' }], + }; case 'account_error': return state.reason === 'unreachable' ? { @@ -353,4 +365,12 @@ chrome.storage.onChanged.addListener((_changes, areaName) => { if (areaName === 'session') void refresh(); }); +// Mitigates a known Linux/X11 quirk: a freshly-opened window doesn't +// always have real window-manager input focus yet, so its first click can +// be consumed as a focus-transfer rather than delivered to the page as a +// click — the popup visibly closes and the click never reaches any +// button. Explicitly focusing on load gives the window a chance to +// already be focused by the time a real click arrives. +window.focus(); + void refresh(); diff --git a/extension/src/types.ts b/extension/src/types.ts index 2aaabff..a6236c3 100644 --- a/extension/src/types.ts +++ b/extension/src/types.ts @@ -26,7 +26,18 @@ export interface AbortMessage { type: 'abort'; } -export type SdkMessage = InitMessage | RequestCredentialMessage | AbortMessage; +/** + * Advisory only, never a security signal — the site telling the extension + * "I have (or don't have) an active session" only ever shapes what the + * popup's idle screen shows (extension/claude.md). It cannot skip or + * shortcut any part of the actual credential-delivery flow. + */ +export interface SessionStatusMessage { + type: 'session_status'; + active: boolean; +} + +export type SdkMessage = InitMessage | RequestCredentialMessage | AbortMessage | SessionStatusMessage; // --- Extension -> SDK --- @@ -106,6 +117,7 @@ export interface RunBundleUploadResponse { qr_ttl: number; max_auto_refresh: number; pin_ttl: number; + relay_wait_ttl: number; } // --- Per-tab state (extension/claude.md's state model) --- @@ -149,7 +161,7 @@ export type TabState = | { kind: 'not_registered' } | { kind: 'cert_invalid'; reason: CertInvalidReason; security: boolean } | { kind: 'account_error'; reason: AccountErrorReason } - | { kind: 'idle'; origin: string; features: string[] } + | { kind: 'idle'; origin: string; features: string[]; sessionActive: boolean } | { kind: 'phase_1'; origin: string; features: string[] } | { kind: 'phase_2'; origin: string; features: string[]; qrContent: string; qrTtlSeconds: number; phaseStartedAt: number } | { kind: 'phase_3'; origin: string; features: string[]; pin: string; pinTtlSeconds: number; phaseStartedAt: number } diff --git a/interfaces.md b/interfaces.md index df78538..9c4a66f 100644 --- a/interfaces.md +++ b/interfaces.md @@ -156,10 +156,15 @@ Response: "signed_token": "", "qr_ttl": 90, "max_auto_refresh": 3, - "pin_ttl": 120 + "pin_ttl": 120, + "relay_wait_ttl": 60 } ``` +`relay_wait_ttl` bounds how long the extension waits for the encrypted +credential once `phase_4` begins (i.e. once the user has confirmed the +PIN) before giving up with `run_error/relay_timeout`. + All TTL values are seconds. Clients must not apply local defaults or overrides. --- @@ -234,14 +239,16 @@ is sufficient. ## PIN confirmation Extension → Backend (POST) and Companion → Backend (GET, polled), addressed -to `runId` — a real two-way handshake, not the companion proceeding on its -own schedule. The companion is only allowed to advance to credential -selection once *both* its own local user has tapped Continue (`mobile/claude.md`'s -`pin_display → awaiting_confirmation`) *and* this confirmation has arrived; -neither side's local action alone is sufficient. This mirrors Bluetooth -Numeric Comparison pairing, which communicates each side's confirmation to -the other as part of completing the handshake, rather than trusting the two -devices to act on the same timeline independently. +to `runId`. Single-sided: the extension's Confirm click is the only human +action that gates advancement. The companion has no local confirmation +step of its own — it polls automatically as soon as it derives and displays +the PIN (`mobile/claude.md`'s `pin_display`), and advances the moment this +resolves confirmed. The PIN being independently derived and shown on both +devices is what makes this safe: the person visually compares the two +values before clicking Confirm on the extension, so the check still +happens, it's just not enforced by a second tap on the companion — there +was no benefit found in requiring one once the display-and-compare step +already exists. **`POST /run/relay/:runId/confirm`** @@ -254,9 +261,9 @@ times out. **`GET /run/relay/:runId/confirm`** -Polled by the companion after its own local Continue tap, until it returns -confirmed or a bounded wait elapses (`mobile/claude.md`'s -`error/confirmation_timeout`). +Polled by the companion automatically from the moment it enters +`pin_display`, until it returns confirmed or a bounded wait elapses +(`mobile/claude.md`'s `error/confirmation_timeout`). ```json { "confirmed": true } @@ -323,11 +330,19 @@ the SDK is the translation layer between them. ### SDK → Extension ```ts -{ type: "init", cert: string } // sent immediately on Port open -{ type: "request_credential" } // triggers Phase 1 -{ type: "abort" } // user-initiated cancel +{ type: "init", cert: string } // sent immediately on Port open +{ type: "request_credential" } // triggers Phase 1 +{ type: "abort" } // user-initiated cancel +{ type: "session_status", active: boolean } // advisory only — see note below ``` +`session_status` never affects verification or any run's security checks — +it only shapes what the popup's `idle` screen shows (a plain "Ready to +sign in" vs. an "already signed in" variant). The site may send it any +time after `init()`, and as often as its own session state changes (e.g. +its own logout flow firing it with `active: false`). If never sent, the +popup defaults to the plain `idle` screen. + ### Extension → SDK ```ts diff --git a/mobile/claude.md b/mobile/claude.md index 15b17b4..dba7030 100644 --- a/mobile/claude.md +++ b/mobile/claude.md @@ -90,21 +90,21 @@ deferred UX decision. 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. +- `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 @@ -143,11 +143,8 @@ key_exchange → 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) + → 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) @@ -244,7 +241,7 @@ All algorithm parameters and normative ordering rules are in `../crypto.md`. - 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). + (`pin_display`, above). - Send encrypted credential ciphertext to relay addressed to run ID (Phase 4). - Report security-class errors to `/security/report`. diff --git a/scripts/serve-companion.js b/scripts/serve-companion.js new file mode 100644 index 0000000..0d999a2 --- /dev/null +++ b/scripts/serve-companion.js @@ -0,0 +1,64 @@ +#!/usr/bin/env node +// Serves companion/dist/ over HTTPS using the mkcert-issued test.locqr.dev +// cert (dev.md — same domain as the test website, different port, so the +// companion stub gets a secure context for getUserMedia()). Requires +// `npm run build` in companion/ first — this serves the built output, not +// the TypeScript source, mirroring how the extension is loaded from +// extension/dist/. +// +// Plain Node https + fs — same shape as serve-website.js. + +import { createServer } from 'node:https'; +import { readFile, stat } from 'node:fs/promises'; +import { extname, join, normalize } from 'node:path'; +import { dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const ROOT = dirname(dirname(fileURLToPath(import.meta.url))); +const COMPANION_DIST_DIR = join(ROOT, 'companion', 'dist'); +const PORT = 5174; + +const MIME = { + '.html': 'text/html; charset=utf-8', + '.js': 'text/javascript; charset=utf-8', + '.css': 'text/css; charset=utf-8', + '.json': 'application/json', +}; + +async function resolveFile(urlPath) { + const safePath = normalize(urlPath === '/' ? '/index.html' : urlPath).replace(/^(\.\.[/\\])+/, ''); + const filePath = join(COMPANION_DIST_DIR, safePath); + if (!filePath.startsWith(COMPANION_DIST_DIR)) return null; // path traversal guard + + try { + const stats = await stat(filePath); + return stats.isFile() ? filePath : null; + } catch { + return null; + } +} + +const options = { + cert: await readFile(join(ROOT, 'dev', 'certs', 'test.locqr.dev.pem')), + key: await readFile(join(ROOT, 'dev', 'certs', 'test.locqr.dev-key.pem')), +}; + +createServer(options, async (req, res) => { + const url = new URL(req.url ?? '/', 'https://test.locqr.dev'); + const filePath = await resolveFile(url.pathname); + + if (!filePath) { + res.writeHead(404).end('Not found — did you run `npm run build` in companion/?'); + return; + } + + const body = await readFile(filePath); + // No caching, ever — this is a dev stub rebuilt on every source edit, + // and mobile browsers in particular are prone to heuristically caching + // a same-URL response across page reloads when the server sends no + // explicit Cache-Control at all, silently serving a stale bundle. + res.writeHead(200, { 'Content-Type': MIME[extname(filePath)] ?? 'application/octet-stream', 'Cache-Control': 'no-store' }); + res.end(body); +}).listen(PORT, () => { + console.log(`Serving companion/dist/ -> https://test.locqr.dev:${PORT}`); +}); diff --git a/scripts/serve-lan-dns.sh b/scripts/serve-lan-dns.sh new file mode 100644 index 0000000..2e5efce --- /dev/null +++ b/scripts/serve-lan-dns.sh @@ -0,0 +1,55 @@ +#!/usr/bin/env bash +# Makes test.locqr.dev and api.locqr.dev resolvable from a phone on the same +# LAN, for testing the companion stub (companion/, dev.md) against a real +# camera. Not needed for desktop-only development — that uses /etc/hosts +# (dev.md), which a phone can't be pointed at as easily. +# +# Runs dnsmasq in the foreground, bound only to the Wi-Fi interface (not a +# systemd service, doesn't touch /etc/resolv.conf or /etc/dnsmasq.conf): +# resolves the two dev hostnames to this machine's own LAN IP and forwards +# everything else to a real upstream resolver. Ctrl+C stops it; nothing +# persists after that. +# +# Requires sudo (binding port 53). Usage: sudo scripts/serve-lan-dns.sh [interface] +# If no interface is given, the script guesses the one carrying a private +# (RFC 1918) IPv4 address — print `ip -4 addr show` yourself if it guesses +# wrong on a machine with multiple such interfaces. + +set -euo pipefail + +if [[ $EUID -ne 0 ]]; then + echo "Needs root (binds port 53). Re-run as: sudo $0 $*" >&2 + exit 1 +fi + +IFACE="${1:-}" +if [[ -z "$IFACE" ]]; then + IFACE=$(ip -4 -o addr show scope global | awk '$4 ~ /^(10\.|172\.(1[6-9]|2[0-9]|3[01])\.|192\.168\.)/ { print $2; exit }') +fi +if [[ -z "$IFACE" ]]; then + echo "Could not auto-detect a LAN interface. Pass one explicitly: $0 " >&2 + echo "(check \`ip -4 addr show\` for the one with your 192.168.x.x / 10.x.x.x address)" >&2 + exit 1 +fi + +LAN_IP=$(ip -4 -o addr show dev "$IFACE" | awk '{ print $4 }' | cut -d/ -f1) +if [[ -z "$LAN_IP" ]]; then + echo "Interface '$IFACE' has no IPv4 address." >&2 + exit 1 +fi + +echo "Resolving test.locqr.dev and api.locqr.dev -> $LAN_IP on interface $IFACE" +echo "Point your phone's Wi-Fi DNS server at $LAN_IP, then browse to https://test.locqr.dev:5174" +echo "(Ctrl+C to stop)" +echo + +exec dnsmasq \ + --no-daemon \ + --no-resolv \ + --no-hosts \ + --server=1.1.1.1 \ + --server=8.8.8.8 \ + --interface="$IFACE" \ + --bind-interfaces \ + --address="/test.locqr.dev/$LAN_IP" \ + --address="/api.locqr.dev/$LAN_IP" diff --git a/sdk/claude.md b/sdk/claude.md index d62f1f1..fbd1316 100644 --- a/sdk/claude.md +++ b/sdk/claude.md @@ -109,6 +109,33 @@ type Credential = { } ``` +### `locqr.abort()` + +Cancels an in-flight run, any phase. A no-op if no run is active. Any +pending `requestCredential()` Promise rejects through the normal +`run_error` round-trip this triggers, not synchronously from calling this. + +```js +locqr.abort(); +``` + +### `locqr.reportSession(options)` + +```ts +locqr.reportSession({ active: boolean }); +``` + +Tells the extension whether this site currently considers the user to have +an active session — purely advisory, shown as a badge/copy variant on the +popup's `idle` screen (`extension/gui.md`). Never affects verification or +any run's security checks; those are unconditional regardless of what a +site reports here. Call it any time after `init()`, and again whenever the +site's own session state changes (including its own logout flow, with +`active: false`) — LOCQR has no session mechanism of its own and doesn't +persist this across a page reload; the site's existing session handling +(cookie, server-checked session, whatever it already has) is what this +should be sourced from. + ### `locqr.on(event, handler)` / `locqr.off(event, handler)` Registers and removes handlers for extension-initiated events. diff --git a/sdk/src/index.ts b/sdk/src/index.ts index 394b7ce..bc4212e 100644 --- a/sdk/src/index.ts +++ b/sdk/src/index.ts @@ -160,6 +160,29 @@ class Locqr { }); } + /** + * Cancels an in-flight run (any phase). A no-op if no run is active — + * the extension itself decides what "no active run" means; the SDK + * doesn't try to track run state to pre-empt this call locally. + * Rejection of any pending requestCredential() promise happens through + * the normal run_error round-trip above, not from calling this directly. + */ + abort(): void { + this.port?.postMessage({ type: 'abort' }); + } + + /** + * Advisory only (types.ts's SessionStatusMessage on the extension + * side): tells the extension whether this site currently considers the + * user to have an active session, purely so the popup's idle screen + * can reflect it. Never affects verification or the run's own security + * checks — those are unconditional regardless of what a site reports + * here. + */ + reportSession(options: { active: boolean }): void { + this.port?.postMessage({ type: 'session_status', active: options.active }); + } + on(event: E, handler: EventHandler): void { if (!this.handlers.has(event)) this.handlers.set(event, new Set()); this.handlers.get(event)!.add(handler as EventHandler); diff --git a/server/src/main/java/dev/locqr/server/config/CorsConfig.java b/server/src/main/java/dev/locqr/server/config/CorsConfig.java index 5fc4508..bed1e38 100644 --- a/server/src/main/java/dev/locqr/server/config/CorsConfig.java +++ b/server/src/main/java/dev/locqr/server/config/CorsConfig.java @@ -15,10 +15,27 @@ import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; * calls with a 403 — Spring's CORS check doesn't know or care about * host_permissions, it just saw an Origin not in the allowlist. The * extension needed *no* CORS config at all (that's what host_permissions is - * for); only a real webpage does. So this stays scoped to exactly the one - * endpoint a webpage calls directly in this slice — not the whole API — - * specifically so it can never again regress an extension-only endpoint by - * accident the way the blanket {@code /**} mapping just did. + * for); only a real webpage does. So this stays scoped to exactly the + * endpoints a webpage calls directly — not the whole API — specifically so + * it can never again regress an extension-only endpoint by accident the + * way the blanket {@code /**} mapping just did. + * + * Two of the mappings below ({@code /run/relay/*}/confirm} and + * {@code /security/report}) are *shared*: the companion calls them + * directly (browser CORS applies), and the extension also calls them + * directly (browser CORS doesn't apply to it, but Spring's check still + * runs against its {@code chrome-extension://} Origin header regardless — + * same mechanism that caused the incident above). application.yml's + * {@code allowed-origins} default includes the dev extension's origin for + * exactly this reason; omitting it here would 403 the extension's own + * confirm/report calls the instant these two paths got a CORS mapping. + * + * {@code /run/relay/{runId}} (bare, no {@code /confirm} suffix) is + * deliberately not mapped here — it's handled by + * {@link dev.locqr.server.relay.RelayHttpRequestHandler} via a raw + * {@code SimpleUrlHandlerMapping} (see {@code RelayEndpointConfig}), which + * this class's {@code WebMvcConfigurer} hook does not reach. Its CORS + * config is set directly on that mapping instead. */ @Configuration public class CorsConfig implements WebMvcConfigurer { @@ -31,9 +48,31 @@ public class CorsConfig implements WebMvcConfigurer { @Override public void addCorsMappings(CorsRegistry registry) { + String[] origins = properties.allowedOrigins().toArray(new String[0]); + registry.addMapping("/domain/registration") - .allowedOrigins(properties.allowedOrigins().toArray(new String[0])) + .allowedOrigins(origins) .allowedMethods("GET") .allowedHeaders("*"); + + // Companion only (mobile/claude.md's "Backend API surface") — the + // extension never calls this path, so no extension origin needed. + registry.addMapping("/run/bundle/*") + .allowedOrigins(origins) + .allowedMethods("GET") + .allowedHeaders("*"); + + // Shared: companion GETs to poll (interfaces.md, "PIN + // confirmation"), extension POSTs to signal (PinConfirmationController). + registry.addMapping("/run/relay/*/confirm") + .allowedOrigins(origins) + .allowedMethods("GET", "POST") + .allowedHeaders("*"); + + // Shared: both extension and companion report security-class errors. + registry.addMapping("/security/report") + .allowedOrigins(origins) + .allowedMethods("POST") + .allowedHeaders("*"); } } diff --git a/server/src/main/java/dev/locqr/server/config/LocqrProperties.java b/server/src/main/java/dev/locqr/server/config/LocqrProperties.java index e59f6c4..328afcc 100644 --- a/server/src/main/java/dev/locqr/server/config/LocqrProperties.java +++ b/server/src/main/java/dev/locqr/server/config/LocqrProperties.java @@ -17,7 +17,7 @@ public record LocqrProperties( Run run, Relay relay ) { - public record Run(int qrTtlSeconds, int maxAutoRefresh, int pinTtlSeconds) {} + public record Run(int qrTtlSeconds, int maxAutoRefresh, int pinTtlSeconds, int relayWaitTtlSeconds) {} public record Relay(int bufferTtlSeconds) {} } diff --git a/server/src/main/java/dev/locqr/server/config/RelayEndpointConfig.java b/server/src/main/java/dev/locqr/server/config/RelayEndpointConfig.java index 3fef134..10d6a5e 100644 --- a/server/src/main/java/dev/locqr/server/config/RelayEndpointConfig.java +++ b/server/src/main/java/dev/locqr/server/config/RelayEndpointConfig.java @@ -4,24 +4,60 @@ import dev.locqr.server.relay.RelayHttpRequestHandler; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.core.Ordered; +import org.springframework.web.cors.CorsConfiguration; import org.springframework.web.servlet.HandlerMapping; import org.springframework.web.servlet.handler.SimpleUrlHandlerMapping; +import java.util.List; import java.util.Map; /** * Registers {@link RelayHttpRequestHandler} as the sole owner of * {@code /run/relay/*} — see that class's Javadoc for why it isn't split * across {@code @RestController} + {@code WebSocketConfigurer} instead. + * + * This bean sits outside the {@code RequestMappingHandlerMapping} that + * {@code CorsConfig}'s {@code WebMvcConfigurer.addCorsMappings()} wires + * into, so it gets its own CORS config directly rather than inheriting + * one. + * + * This path is shared after all, just not the way {@code CorsConfig}'s + * shared paths are: the companion POSTs here (kem_ciphertext, credential), + * and the extension's WebSocket connection to this same path (interfaces.md's + * "Relay messages") starts life as a plain HTTP GET with an {@code Upgrade: + * websocket} header — which goes through this exact + * {@code SimpleUrlHandlerMapping} and is subject to the CORS config below + * like any other request, before the upgrade ever happens. Missing this + * once already broke the extension outright: scoping {@code allowedMethods} + * to POST-only 403'd the extension's own GET/upgrade request (confirmed by + * hand — the extension never reconnects after that, silently stuck showing + * the QR forever since {@code relay.ts}'s {@code openRelay()} has no + * error handler). Both GET and POST need to be allowed, and both the + * companion's and the extension's origins need to be in the allow-list — + * the same lesson {@code CorsConfig}'s own history docs, just for a path + * outside that class's reach. */ @Configuration public class RelayEndpointConfig { + private final LocqrProperties properties; + + public RelayEndpointConfig(LocqrProperties properties) { + this.properties = properties; + } + @Bean public HandlerMapping relayHandlerMapping(RelayHttpRequestHandler handler) { SimpleUrlHandlerMapping mapping = new SimpleUrlHandlerMapping(); mapping.setOrder(Ordered.HIGHEST_PRECEDENCE); mapping.setUrlMap(Map.of("/run/relay/*", handler)); + + CorsConfiguration cors = new CorsConfiguration(); + cors.setAllowedOrigins(properties.allowedOrigins()); + cors.setAllowedMethods(List.of("GET", "POST")); + cors.setAllowedHeaders(List.of("*")); + mapping.setCorsConfigurations(Map.of("/run/relay/*", cors)); + return mapping; } } diff --git a/server/src/main/java/dev/locqr/server/controller/PinConfirmationController.java b/server/src/main/java/dev/locqr/server/controller/PinConfirmationController.java index a34c53b..558b3fe 100644 --- a/server/src/main/java/dev/locqr/server/controller/PinConfirmationController.java +++ b/server/src/main/java/dev/locqr/server/controller/PinConfirmationController.java @@ -11,11 +11,10 @@ import org.springframework.web.bind.annotation.RestController; /** * PIN-confirmation handshake (interfaces.md, "PIN confirmation") — the - * extension posts here when its user clicks Confirm; the companion polls - * here after its own local pin_display gate before it's allowed to proceed - * to credential_select. Neither side trusts the other's timing without - * this: the companion no longer just proceeds on its own schedule - * (mobile/claude.md, updated). + * extension posts here the moment its user clicks Confirm; the companion + * polls here automatically from the instant it enters pin_display. The + * extension's click is the only human action gating advancement — the + * companion has no local confirmation step of its own (mobile/claude.md). */ @RestController public class PinConfirmationController { diff --git a/server/src/main/java/dev/locqr/server/controller/RunController.java b/server/src/main/java/dev/locqr/server/controller/RunController.java index 76ca076..53c9665 100644 --- a/server/src/main/java/dev/locqr/server/controller/RunController.java +++ b/server/src/main/java/dev/locqr/server/controller/RunController.java @@ -47,7 +47,8 @@ public class RunController { request.runId(), request.origin(), request.x25519Pubkey(), request.kemPubkey(), expiresAt)); return new RunBundleUploadResponse( - signedToken, runParams.qrTtlSeconds(), runParams.maxAutoRefresh(), runParams.pinTtlSeconds()); + signedToken, runParams.qrTtlSeconds(), runParams.maxAutoRefresh(), runParams.pinTtlSeconds(), + runParams.relayWaitTtlSeconds()); } @GetMapping("/run/bundle/{runId}") diff --git a/server/src/main/java/dev/locqr/server/dto/RunBundleUploadResponse.java b/server/src/main/java/dev/locqr/server/dto/RunBundleUploadResponse.java index 516c201..f889335 100644 --- a/server/src/main/java/dev/locqr/server/dto/RunBundleUploadResponse.java +++ b/server/src/main/java/dev/locqr/server/dto/RunBundleUploadResponse.java @@ -6,5 +6,6 @@ public record RunBundleUploadResponse( @JsonProperty("signed_token") String signedToken, @JsonProperty("qr_ttl") int qrTtl, @JsonProperty("max_auto_refresh") int maxAutoRefresh, - @JsonProperty("pin_ttl") int pinTtl + @JsonProperty("pin_ttl") int pinTtl, + @JsonProperty("relay_wait_ttl") int relayWaitTtl ) {} diff --git a/server/src/main/java/dev/locqr/server/relay/RelaySessionRegistry.java b/server/src/main/java/dev/locqr/server/relay/RelaySessionRegistry.java index af74155..d441045 100644 --- a/server/src/main/java/dev/locqr/server/relay/RelaySessionRegistry.java +++ b/server/src/main/java/dev/locqr/server/relay/RelaySessionRegistry.java @@ -28,11 +28,12 @@ import java.util.concurrent.TimeUnit; * discarded and the run must be restarted." * *

Also tracks the PIN-confirmation handshake (interfaces.md, "PIN - * confirmation"): the extension posts a confirmation here when its user - * clicks Confirm, and the companion polls for it before it's allowed to - * proceed past its own local pin_display gate. Same ephemeral-state - * lifecycle and TTL as the message buffer above, so it's tracked alongside - * it rather than in a separate component. + * confirmation"): the extension posts a confirmation here the moment its + * user clicks Confirm — the only human action that gates the run, the + * companion has no local confirmation step of its own — and the companion + * polls for it automatically from the moment it displays the PIN. Same + * ephemeral-state lifecycle and TTL as the message buffer above, so it's + * tracked alongside it rather than in a separate component. */ @Component public class RelaySessionRegistry { diff --git a/server/src/main/resources/application.yml b/server/src/main/resources/application.yml index a2be97e..e79b8a1 100644 --- a/server/src/main/resources/application.yml +++ b/server/src/main/resources/application.yml @@ -16,13 +16,19 @@ locqr: test-domain: ${TEST_DOMAIN:test.locqr.dev} ed25519-secret-key-path: ${ED25519_SECRET_KEY_PATH:../dev/keys/ed25519-secret.b64} registration-cert-path: ${REGISTRATION_CERT_PATH:../dev/certs/test-registration.b64} - # Webpage-context clients only (test website, later the companion web - # stub) — the extension's service worker bypasses CORS via manifest - # host_permissions instead. Ports match dev.md's documented defaults. - allowed-origins: ${ALLOWED_ORIGINS:https://test.locqr.dev:5173,https://test.locqr.dev:5174} + # Webpage-context clients (test website, companion web stub) plus the dev + # extension's own origin. The extension's host_permissions bypasses CORS + # enforcement on its *own* side, but not the server's — CorsConfig has to + # allow-list it too on any path the extension also calls directly (PIN + # confirmation, security reports), or those start getting 403'd the + # moment that path gets a CORS mapping for the companion. Ports match + # dev.md's documented defaults; extension ID matches dev.md's "Extension + # ID in development" (derived from extension/key.pem). + allowed-origins: ${ALLOWED_ORIGINS:https://test.locqr.dev:5173,https://test.locqr.dev:5174,chrome-extension://daijigbjegngkjckedcbhhdjkampjgia} run: qr-ttl-seconds: 90 max-auto-refresh: 3 pin-ttl-seconds: 120 + relay-wait-ttl-seconds: 60 relay: buffer-ttl-seconds: 8 diff --git a/server/src/test/java/dev/locqr/server/RunFlowIntegrationTest.java b/server/src/test/java/dev/locqr/server/RunFlowIntegrationTest.java index f13c1d1..cb5e15a 100644 --- a/server/src/test/java/dev/locqr/server/RunFlowIntegrationTest.java +++ b/server/src/test/java/dev/locqr/server/RunFlowIntegrationTest.java @@ -92,6 +92,7 @@ class RunFlowIntegrationTest { assertThat(body.qrTtl()).isEqualTo(90); assertThat(body.maxAutoRefresh()).isEqualTo(3); assertThat(body.pinTtl()).isEqualTo(120); + assertThat(body.relayWaitTtl()).isEqualTo(60); assertThat(body.signedToken()).isNotBlank(); RunBundleFetchResponse firstFetch = client.get().uri("/run/bundle/{runId}", runId) diff --git a/server/src/test/java/dev/locqr/server/crypto/SigningServiceTest.java b/server/src/test/java/dev/locqr/server/crypto/SigningServiceTest.java index 1ca639c..2972d09 100644 --- a/server/src/test/java/dev/locqr/server/crypto/SigningServiceTest.java +++ b/server/src/test/java/dev/locqr/server/crypto/SigningServiceTest.java @@ -91,7 +91,7 @@ class SigningServiceTest { keyFile.toString(), "/nonexistent", java.util.List.of(), - new LocqrProperties.Run(90, 3, 120), + new LocqrProperties.Run(90, 3, 120, 60), new LocqrProperties.Relay(8)); } } diff --git a/server/src/test/java/dev/locqr/server/relay/RelaySessionRegistryTest.java b/server/src/test/java/dev/locqr/server/relay/RelaySessionRegistryTest.java index 6f23191..7ab450c 100644 --- a/server/src/test/java/dev/locqr/server/relay/RelaySessionRegistryTest.java +++ b/server/src/test/java/dev/locqr/server/relay/RelaySessionRegistryTest.java @@ -130,7 +130,7 @@ class RelaySessionRegistryTest { private RelaySessionRegistry registry(int bufferTtlSeconds) { RelaySessionRegistry registry = new RelaySessionRegistry(new LocqrProperties( "test.locqr.dev", "unused", "unused", java.util.List.of(), - new LocqrProperties.Run(90, 3, 120), + new LocqrProperties.Run(90, 3, 120, 60), new LocqrProperties.Relay(bufferTtlSeconds))); created.add(registry); return registry; diff --git a/server/src/test/java/dev/locqr/server/store/RegistrationCertificateStoreTest.java b/server/src/test/java/dev/locqr/server/store/RegistrationCertificateStoreTest.java index 1dc1ef3..c43b446 100644 --- a/server/src/test/java/dev/locqr/server/store/RegistrationCertificateStoreTest.java +++ b/server/src/test/java/dev/locqr/server/store/RegistrationCertificateStoreTest.java @@ -47,7 +47,7 @@ class RegistrationCertificateStoreTest { "unused", certPath, java.util.List.of(), - new LocqrProperties.Run(90, 3, 120), + new LocqrProperties.Run(90, 3, 120, 60), new LocqrProperties.Relay(8)); } } diff --git a/website/index.html b/website/index.html index c154d1d..7a531c6 100644 --- a/website/index.html +++ b/website/index.html @@ -24,6 +24,7 @@ +

Event log

@@ -51,6 +52,19 @@ // in development"). Stable as long as key.pem doesn't change. const DEV_EXTENSION_ID = 'daijigbjegngkjckedcbhhdjkampjgia'; + // Stands in for this site's own real session mechanism (a cookie, a + // server-checked session — whatever it already uses). LOCQR has no + // opinion on this and doesn't provide it; reportSession() just needs + // *something* to report, and localStorage is the simplest thing that + // survives a refresh for demo purposes. + const SESSION_KEY = 'locqr-test-session'; + const hasSimulatedSession = () => localStorage.getItem(SESSION_KEY) === 'active'; + const setSimulatedSession = (active) => { + if (active) localStorage.setItem(SESSION_KEY, 'active'); + else localStorage.removeItem(SESSION_KEY); + logOutBtn.style.display = active ? '' : 'none'; + }; + async function main() { log('page:loaded'); @@ -74,7 +88,20 @@ locqr.on('ready', (payload) => log('event:ready', payload)); locqr.on('run:started', (payload) => log('event:run:started', payload)); - locqr.on('run:delivered', (payload) => log('event:run:delivered', payload)); + locqr.on('run:delivered', (payload) => { + log('event:run:delivered', payload); + // Fills the form here too, not just inside the Sign-in button's own + // requestCredential() promise — this event fires for a credential + // delivered from *any* trigger (e.g. the extension popup's own + // Sign in button), and the form should reflect that regardless of + // which side started the run (sdk/claude.md: "sites using the + // event model do not need to call requestCredential()"). + usernameEl.value = payload.credential.username; + passwordEl.value = payload.credential.password; + setSimulatedSession(true); + locqr.reportSession({ active: true }); + log('reportSession', { active: true }); + }); locqr.on('run:error', (payload) => log('event:run:error', payload)); locqr.init({ cert, extensionId: DEV_EXTENSION_ID }); @@ -84,6 +111,11 @@ statusEl.textContent = `Ready — features: ${result.features.join(', ')}`; log('ready:resolved', result); signInBtn.disabled = false; + + const active = hasSimulatedSession(); + logOutBtn.style.display = active ? '' : 'none'; + locqr.reportSession({ active }); + log('reportSession', { active }); } catch (error) { statusEl.textContent = `Not ready — ${error.code}${error.reason ? ' / ' + error.reason : ''}`; log('ready:rejected', error); @@ -91,18 +123,28 @@ } const signInBtn = document.getElementById('sign-in-btn'); + const logOutBtn = document.getElementById('log-out-btn'); const usernameEl = document.getElementById('username'); const passwordEl = document.getElementById('password'); + logOutBtn.addEventListener('click', () => { + setSimulatedSession(false); + usernameEl.value = ''; + passwordEl.value = ''; + locqr.reportSession({ active: false }); + log('reportSession', { active: false }); + }); + signInBtn.addEventListener('click', async () => { signInBtn.disabled = true; usernameEl.value = ''; passwordEl.value = ''; log('requestCredential:called'); try { + // Field population happens in the run:delivered event listener + // above, not here — that fires for a delivery from any trigger, + // this promise only resolves for one started by this exact click. const credential = await locqr.requestCredential(); - usernameEl.value = credential.username; - passwordEl.value = credential.password; log('requestCredential:resolved', { username: credential.username }); } catch (error) { statusEl.textContent = `Sign-in didn't complete — ${error.code}${error.reason ? ' / ' + error.reason : ''}`;