happy path working with stub companion app
This commit is contained in:
parent
3c11a88a33
commit
bc025d007c
18
companion/build.js
Normal file
18
companion/build.js
Normal 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
33
companion/index.html
Normal 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
20
companion/package.json
Normal 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
109
companion/src/api.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
22
companion/src/base64url.ts
Normal file
22
companion/src/base64url.ts
Normal 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(/=+$/, '');
|
||||
}
|
||||
10
companion/src/constants.ts
Normal file
10
companion/src/constants.ts
Normal 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';
|
||||
37
companion/src/credentials.ts
Normal file
37
companion/src/credentials.ts
Normal 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
175
companion/src/crypto.ts
Normal 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
398
companion/src/main.ts
Normal 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
58
companion/src/scanner.ts
Normal 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
49
companion/src/state.ts
Normal 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
13
companion/tsconfig.json
Normal 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"]
|
||||
}
|
||||
24
crypto.md
24
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
|
||||
|
||||
4
dev.md
4
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)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@ -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`,
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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':
|
||||
|
||||
@ -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"
|
||||
}
|
||||
|
||||
|
||||
@ -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(
|
||||
|
||||
20
extension/src/background/session-status.ts
Normal file
20
extension/src/background/session-status.ts
Normal 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);
|
||||
}
|
||||
@ -93,7 +93,19 @@ 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 {
|
||||
// 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',
|
||||
@ -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();
|
||||
|
||||
@ -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 }
|
||||
|
||||
@ -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 }
|
||||
@ -326,8 +333,16 @@ the SDK is the translation layer between them.
|
||||
{ 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
|
||||
|
||||
@ -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`.
|
||||
|
||||
|
||||
64
scripts/serve-companion.js
Normal file
64
scripts/serve-companion.js
Normal 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
55
scripts/serve-lan-dns.sh
Normal 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"
|
||||
@ -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.
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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("*");
|
||||
}
|
||||
}
|
||||
|
||||
@ -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) {}
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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}")
|
||||
|
||||
@ -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
|
||||
) {}
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 : ''}`;
|
||||
|
||||
Loading…
Reference in New Issue
Block a user