happy path working with stub companion app

This commit is contained in:
Rasmus Neikes 2026-08-18 02:16:05 +02:00
parent 3c11a88a33
commit bc025d007c
41 changed files with 1512 additions and 102 deletions

18
companion/build.js Normal file
View File

@ -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}/`);

33
companion/index.html Normal file
View File

@ -0,0 +1,33 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>LOCQR companion (stub)</title>
<style>
/* Bare (mobile/claude.md: "the visual design specified in gui.md does not
apply to the stub"). Functional correctness only. */
body { font-family: system-ui, sans-serif; max-width: 420px; margin: 40px auto; padding: 0 16px; color: #111; }
h1 { font-size: 18px; }
h2 { font-size: 16px; margin-top: 0; }
#app { border: 1px solid #ddd; border-radius: 8px; padding: 20px; }
button { padding: 8px 16px; font-size: 14px; cursor: pointer; margin: 4px 8px 4px 0; }
.btn-primary { background: #1a73e8; color: #fff; border: none; border-radius: 4px; }
.btn-ghost { background: none; border: 1px solid #ccc; border-radius: 4px; }
input { display: block; width: 100%; box-sizing: border-box; padding: 8px; margin: 6px 0; font-size: 14px; }
.camera-preview { width: 100%; border-radius: 4px; background: #000; }
.credential-item { display: flex; align-items: center; gap: 8px; padding: 8px; border: 1px solid #eee; border-radius: 4px; margin-bottom: 6px; }
.pin-groups { display: flex; gap: 16px; justify-content: center; margin: 16px 0; }
.pin-group { display: flex; gap: 4px; }
.pin-digit { width: 28px; height: 36px; display: flex; align-items: center; justify-content: center;
border: 1px solid #ccc; border-radius: 4px; font-family: ui-monospace, monospace; font-size: 18px; }
.error-text { color: #b3261e; }
details summary { cursor: pointer; margin-top: 8px; }
</style>
</head>
<body>
<h1>LOCQR companion — dev stub</h1>
<p><small>Not the native app (mobile/claude.md). Full protocol, no persistence, no real biometrics.</small></p>
<div id="app"></div>
<script type="module" src="main.js"></script>
</body>
</html>

20
companion/package.json Normal file
View File

@ -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"
}
}

109
companion/src/api.ts Normal file
View File

@ -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<BundleFetchResult> {
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<boolean> {
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<boolean> {
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<boolean> {
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<boolean> {
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<void> {
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);
}
}

View File

@ -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(/=+$/, '');
}

View File

@ -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';

View File

@ -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<string, CredentialEntry[]>([
[
'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);
}

175
companion/src/crypto.ts Normal file
View File

@ -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<string, unknown>;
try {
envelope = JSON.parse(new TextDecoder().decode(base64UrlDecode(qrContent))) as Record<string, unknown>;
} 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<Uint8Array> {
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<Uint8Array> {
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<Uint8Array> {
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<string> {
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<Uint8Array> {
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 };

398
companion/src/main.ts Normal file
View File

@ -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<void> {
setState({ kind: 'authenticating' });
}
/** Stub stand-in for BiometricPrompt (mobile/claude.md's web stub scope). */
async function unlockAndScan(): Promise<void> {
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<void> {
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<void> {
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<void> {
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<ErrorReason, { heading: string; body: string }> = {
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<CompanionState, { kind: 'credential_select' }>): string {
if (state.credentials.length === 0) {
return `
<p>No stored credentials for <strong>${state.domain}</strong> yet.</p>
<form id="add-credential-form">
<input name="label" placeholder="Label" required />
<input name="username" placeholder="Username" required />
<input name="password" type="password" placeholder="Password" required />
<button type="submit" class="btn-primary">Add & use</button>
</form>
`;
}
const items = state.credentials
.map(
(c, i) => `
<label class="credential-item">
<input type="radio" name="credential" value="${i}" ${i === 0 ? 'checked' : ''} />
<span><strong>${c.label}</strong><br /><small>${c.username}</small></span>
</label>`,
)
.join('');
return `
<p>Sign in to <strong>${state.domain}</strong> with:</p>
<form id="credential-select-form">
${items}
<button type="submit" class="btn-primary">Deliver credential</button>
</form>
<details>
<summary>Add another credential</summary>
<form id="add-credential-form">
<input name="label" placeholder="Label" required />
<input name="username" placeholder="Username" required />
<input name="password" type="password" placeholder="Password" required />
<button type="submit" class="btn-primary">Add</button>
</form>
</details>
`;
}
function pinGroupsHtml(pin: string): string {
const digits = pin.split('');
const group = (d: string[]) => `<div class="pin-group">${d.map((x) => `<div class="pin-digit">${x}</div>`).join('')}</div>`;
return `<div class="pin-groups">${group(digits.slice(0, 3))}${group(digits.slice(3, 6))}</div>`;
}
function contentHtml(): string {
switch (state.kind) {
case 'idle':
return `
<h2>Ready</h2>
<p>Tap Scan QR to sign in to a site using LOCQR.</p>
<button class="btn-primary" data-action="scan_qr">Scan QR</button>
`;
case 'authenticating':
return `
<h2>Unlock</h2>
<p>Stub stand-in for biometric/device PIN (mobile/claude.md).</p>
<button class="btn-primary" data-action="unlock">Continue</button>
<button class="btn-ghost" data-action="cancel">Cancel</button>
`;
case 'scanning':
return `
<h2>Scan QR</h2>
<div id="scanner-mount"></div>
${state.cameraError ? `<p class="error-text">Camera error: ${state.cameraError}</p>` : ''}
<button class="btn-ghost" data-action="cancel">Cancel</button>
`;
case 'verifying':
return `<h2>Verifying…</h2><p>Checking the code's signature and fetching the key bundle.</p>`;
case 'key_exchange':
return `<h2>Connecting…</h2><p>Performing key exchange with ${state.url}.</p>`;
case 'pin_display':
return `
<h2>Compare this code</h2>
${pinGroupsHtml(state.pin)}
<p>Check this matches the code shown in the browser extension for ${state.url}, then confirm there no action needed here.</p>
<button class="btn-ghost" data-action="cancel">Cancel</button>
`;
case 'credential_select':
return `<h2>Choose a credential</h2>${credentialListHtml(state)}<button class="btn-ghost" data-action="cancel">Cancel</button>`;
case 'delivering':
return `<h2>Delivering…</h2><p>Sending the encrypted credential.</p>`;
case 'complete':
return `<h2>Done</h2><p>Credential delivered. The browser should now show "Signed in".</p>`;
case 'error': {
const { heading, body } = ERROR_COPY[state.reason];
const errorClass = ERROR_CLASS[state.reason];
const retryButton = errorClass !== 'security' ? `<button class="btn-primary" data-action="retry">Retry</button>` : '';
return `
<h2>${errorClass === 'security' ? '⚠ ' : ''}${heading}</h2>
<p>${body}</p>
${retryButton}
<button class="btn-ghost" data-action="dismiss">Dismiss</button>
`;
}
}
}
function wireActions(): void {
document.querySelectorAll<HTMLButtonElement>('#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();

58
companion/src/scanner.ts Normal file
View File

@ -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<ScannerHandle> {
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());
},
};
}

49
companion/src/state.ts Normal file
View File

@ -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<ErrorReason, ErrorClass> = {
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 };

13
companion/tsconfig.json Normal file
View File

@ -0,0 +1,13 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2022", "DOM"],
"module": "ESNext",
"moduleResolution": "Bundler",
"strict": true,
"noUncheckedIndexedAccess": true,
"skipLibCheck": true,
"noEmit": true
},
"include": ["src"]
}

View File

@ -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

4
dev.md
View File

@ -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)
---

View File

@ -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 14; discarded on delivery or error |
| Run parameters (qr_ttl, max_auto_refresh, pin_ttl, relay_wait_ttl) | In-memory | Phase 14; discarded on delivery or error |
| auto_refresh_remaining counter | In-memory | Phase 2 only |
| Ephemeral keypairs (X25519, ML-KEM-768) | In-memory | Phase 12 only |
| Run key | In-memory | Phase 34; 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`,

View File

@ -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

View File

@ -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<void> {
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<void> {
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':

View File

@ -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"
}

View File

@ -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<void> {
@ -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<string> {
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<void> {
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<void> {
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<void> {
@ -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<void> {
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(

View File

@ -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<number, boolean>();
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);
}

View File

@ -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();

View File

@ -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 }

View File

@ -156,10 +156,15 @@ Response:
"signed_token": "<base64url signed token envelope>",
"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

View File

@ -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`.

View File

@ -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}`);
});

55
scripts/serve-lan-dns.sh Normal file
View File

@ -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 <interface>" >&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"

View File

@ -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.

View File

@ -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<E extends LocqrEvent>(event: E, handler: EventHandler<E>): void {
if (!this.handlers.has(event)) this.handlers.set(event, new Set());
this.handlers.get(event)!.add(handler as EventHandler);

View File

@ -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("*");
}
}

View File

@ -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) {}
}

View File

@ -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;
}
}

View File

@ -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 {

View File

@ -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}")

View File

@ -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
) {}

View File

@ -28,11 +28,12 @@ import java.util.concurrent.TimeUnit;
* discarded and the run must be restarted."
*
* <p>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 {

View File

@ -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

View File

@ -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)

View File

@ -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));
}
}

View File

@ -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;

View File

@ -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));
}
}

View File

@ -24,6 +24,7 @@
<input id="username" type="text" placeholder="Username" readonly />
<input id="password" type="password" placeholder="Password" readonly />
<button id="sign-in-btn" type="button" disabled>Sign in with LOCQR</button>
<button id="log-out-btn" type="button" style="display: none;">Log out (this site only)</button>
</form>
<h2 style="font-size: 14px;">Event log</h2>
@ -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 : ''}`;