+ ${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 : ''}`;