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 |
|
| 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 | 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 | 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 | Android / Kotlin | BouncyCastle (liboqs-java for production) | — |
|
||||||
| ML-KEM-768 + ML-DSA-44 | Java/Spring (production) | liboqs-java | — |
|
| 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
|
worth flagging for implementers: argument order is
|
||||||
`verify(signature, message, publicKey)`, not `verify(message, signature, publicKey)`.
|
`verify(signature, message, publicKey)`, not `verify(message, signature, publicKey)`.
|
||||||
|
|
||||||
The web companion stub row above is **not yet tested** — it runs in a normal
|
**Resolved — web companion stub standardizes on `@noble/post-quantum`,
|
||||||
page context (`mobile/claude.md`: "single-page web application," no service
|
same as the extension.** The web companion stub runs in a normal page
|
||||||
worker), where dynamic `import()` is allowed, so `@oqs/liboqs-js` should
|
context (`mobile/claude.md`: "single-page web application," no service
|
||||||
still work there in principle. Whether to keep two different PQ libraries
|
worker), where dynamic `import()` is allowed, so `@oqs/liboqs-js` would
|
||||||
across the browser-side components, or standardize on `@noble/post-quantum`
|
have worked there in principle — this wasn't ruled out the way it was for
|
||||||
everywhere for consistency (simpler, no WASM asset handling anywhere), is an
|
the extension. But there was no reason to carry it: `scripts/companion-harness.mjs`,
|
||||||
open call for whoever builds the companion — not yet decided.
|
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),
|
**`@oqs/liboqs-js`:** Published by PQCA (Post-Quantum Cryptography Alliance),
|
||||||
WASM bindings to the reference liboqs implementation, zero npm dependencies,
|
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.
|
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
|
Tracks the NIST reference implementation directly, reducing the risk of subtle
|
||||||
algorithm deviations. Unpacked size is ~17 MB. Still the right choice for the
|
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
|
Node.js setup script (confirmed working — see `scripts/setup-dev.js`); ruled
|
||||||
possibly the web companion stub (unconfirmed); ruled out for the extension.
|
out for the extension, and not used by the companion either (above).
|
||||||
|
|
||||||
**JCS consistency:** All four runtimes use libraries from the same author
|
**JCS consistency:** All four runtimes use libraries from the same author
|
||||||
(Samuel Erdtman, editor of RFC 8785). This maximises the likelihood of
|
(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`
|
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
|
5. Load the extension unpacked from `extension/dist/` (or `extension/` once
|
||||||
built)
|
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.
|
- `rejected` — account is suspended or deactivated.
|
||||||
- `unreachable` — backend could not be reached; status unknown. Displayed
|
- `unreachable` — backend could not be reached; status unknown. Displayed
|
||||||
distinctly from a confirmed rejection.
|
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`)
|
**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) |
|
| `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 |
|
| `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) |
|
| `pin_ttl` | How long the user has to confirm the PIN (Phase 3 countdown) |
|
||||||
|
| `relay_wait_ttl` | How long Phase 4 waits for the credential after PIN confirmation before `run_error/relay_timeout` |
|
||||||
Further per-phase timeouts (e.g. Phase 4 relay wait) may be added to this blob
|
|
||||||
as the design progresses.
|
|
||||||
|
|
||||||
### Navigation rules
|
### Navigation rules
|
||||||
|
|
||||||
@ -273,7 +274,8 @@ opened; it always shows at minimum the current state.
|
|||||||
| `cert_invalid` (security) | security warning | Security warning; specific reason | — |
|
| `cert_invalid` (security) | security warning | Security warning; specific reason | — |
|
||||||
| `account_error / rejected` | error | Account inactive | — |
|
| `account_error / rejected` | error | Account inactive | — |
|
||||||
| `account_error / unreachable` | error | Cannot reach LOCQR server | — |
|
| `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_1` | animated | Connecting… | Abort |
|
||||||
| `phase_2` | animated | QR code | Abort |
|
| `phase_2` | animated | QR code | Abort |
|
||||||
| `phase_3` | animated | PIN; confirm or reject | Confirm, 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 |
|
| Compiled-in pubkeys | Source constants | Build time |
|
||||||
| Per-tab state machine state | In-memory (worker) | Tab lifetime |
|
| Per-tab state machine state | In-memory (worker) | Tab lifetime |
|
||||||
| Site verification result | `chrome.storage.session` | Browser session |
|
| 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 |
|
| auto_refresh_remaining counter | In-memory | Phase 2 only |
|
||||||
| Ephemeral keypairs (X25519, ML-KEM-768) | In-memory | Phase 1–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 |
|
| 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) |
|
| GYBBR identity keys | `chrome.storage.session` | Browser session (see below) |
|
||||||
|
|
||||||
Private key material is never written to `chrome.storage.local`, `localStorage`,
|
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 + heading + short body, centered, shared across all four; only the
|
||||||
badge color/glyph, heading, body, and action differ.
|
badge color/glyph, heading, body, and action differ.
|
||||||
|
|
||||||
- **`idle`** — accent-blue tinted badge, checkmark glyph (matches the `ready`
|
- **`idle`** — two variants depending on the site's advisory
|
||||||
toolbar icon). Heading "Ready to sign in," body naming the site
|
`sessionActive` hint (`claude.md`, `interfaces.md`'s `session_status`
|
||||||
(`"{origin} supports secure sign-in with LOCQR."`). One primary action:
|
message); same badge treatment either way, copy and action differ:
|
||||||
**Sign in**.
|
- 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
|
- **`phase_1`** and **`phase_4`** share one template: accent-blue tinted
|
||||||
badge containing the rotating-ring spinner (real CSS rotation, not a
|
badge containing the rotating-ring spinner (real CSS rotation, not a
|
||||||
static frame; honors `prefers-reduced-motion` by holding still instead of
|
static frame; honors `prefers-reduced-motion` by holding still instead of
|
||||||
|
|||||||
@ -8,6 +8,7 @@
|
|||||||
|
|
||||||
import { queryDomainStatus, reportSecurityError } from './api.js';
|
import { queryDomainStatus, reportSecurityError } from './api.js';
|
||||||
import { abortRun, cleanupRun, confirmPin, dismissRunError, startRun } from './run.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 { registerSdkPort, unregisterSdkPort } from './sdk-ports.js';
|
||||||
import { clearTabState, getTabState, setTabState } from './state.js';
|
import { clearTabState, getTabState, setTabState } from './state.js';
|
||||||
import { verifyRegistrationCert } from './verify.js';
|
import { verifyRegistrationCert } from './verify.js';
|
||||||
@ -91,10 +92,30 @@ async function handleInit(port: chrome.runtime.Port, tabId: number, originUrl: s
|
|||||||
return;
|
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 });
|
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 {
|
function respond(port: chrome.runtime.Port, message: VerificationMessage): void {
|
||||||
try {
|
try {
|
||||||
port.postMessage(message);
|
port.postMessage(message);
|
||||||
@ -140,6 +161,9 @@ chrome.runtime.onConnectExternal.addListener((port) => {
|
|||||||
registerSdkPort(tabId, port);
|
registerSdkPort(tabId, port);
|
||||||
port.onDisconnect.addListener(() => unregisterSdkPort(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' });
|
void setTabState(tabId, { kind: 'unverified' });
|
||||||
|
|
||||||
port.onMessage.addListener((raw: unknown) => {
|
port.onMessage.addListener((raw: unknown) => {
|
||||||
@ -150,6 +174,8 @@ chrome.runtime.onConnectExternal.addListener((port) => {
|
|||||||
void handleRequestCredential(tabId, windowId);
|
void handleRequestCredential(tabId, windowId);
|
||||||
} else if (message.type === 'abort') {
|
} else if (message.type === 'abort') {
|
||||||
void handleAbort(tabId);
|
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> {
|
async function handlePopupAction(message: PopupActionMessage): Promise<void> {
|
||||||
const { tabId, action } = message;
|
const { tabId, action } = message;
|
||||||
const state = await getTabState(tabId);
|
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) {
|
switch (action) {
|
||||||
case 'start_run':
|
case 'start_run':
|
||||||
|
|||||||
@ -16,6 +16,7 @@ export interface RunSecrets {
|
|||||||
qrTtlSeconds: number;
|
qrTtlSeconds: number;
|
||||||
maxAutoRefresh: number;
|
maxAutoRefresh: number;
|
||||||
pinTtlSeconds: number;
|
pinTtlSeconds: number;
|
||||||
|
relayWaitTtlSeconds: number;
|
||||||
autoRefreshRemaining: number; // claude.md: "the extension tracks auto_refresh_remaining"
|
autoRefreshRemaining: number; // claude.md: "the extension tracks auto_refresh_remaining"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -18,6 +18,7 @@ import {
|
|||||||
} from './crypto.js';
|
} from './crypto.js';
|
||||||
import { closeRelay, openRelay } from './relay.js';
|
import { closeRelay, openRelay } from './relay.js';
|
||||||
import { clearRunSecrets, getRunSecrets, setRunSecrets, type RunSecrets } from './run-secrets.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 { sendToSdk } from './sdk-ports.js';
|
||||||
import { getTabState, setTabState } from './state.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.
|
// scheduling always clears any prior alarm of the same kind first.
|
||||||
const QR_ALARM_PREFIX = 'locqr-qr-ttl';
|
const QR_ALARM_PREFIX = 'locqr-qr-ttl';
|
||||||
const PIN_ALARM_PREFIX = 'locqr-pin-ttl';
|
const PIN_ALARM_PREFIX = 'locqr-pin-ttl';
|
||||||
|
const RELAY_WAIT_ALARM_PREFIX = 'locqr-relay-wait';
|
||||||
|
|
||||||
function qrAlarmName(tabId: number): string {
|
function qrAlarmName(tabId: number): string {
|
||||||
return `${QR_ALARM_PREFIX}:${tabId}`;
|
return `${QR_ALARM_PREFIX}:${tabId}`;
|
||||||
@ -42,6 +44,9 @@ function qrAlarmName(tabId: number): string {
|
|||||||
function pinAlarmName(tabId: number): string {
|
function pinAlarmName(tabId: number): string {
|
||||||
return `${PIN_ALARM_PREFIX}:${tabId}`;
|
return `${PIN_ALARM_PREFIX}:${tabId}`;
|
||||||
}
|
}
|
||||||
|
function relayWaitAlarmName(tabId: number): string {
|
||||||
|
return `${RELAY_WAIT_ALARM_PREFIX}:${tabId}`;
|
||||||
|
}
|
||||||
|
|
||||||
function scheduleQrTtlAlarm(tabId: number, qrTtlSeconds: number): void {
|
function scheduleQrTtlAlarm(tabId: number, qrTtlSeconds: number): void {
|
||||||
const name = qrAlarmName(tabId);
|
const name = qrAlarmName(tabId);
|
||||||
@ -53,9 +58,15 @@ function schedulePinTtlAlarm(tabId: number, pinTtlSeconds: number): void {
|
|||||||
chrome.alarms.clear(name);
|
chrome.alarms.clear(name);
|
||||||
chrome.alarms.create(name, { delayInMinutes: pinTtlSeconds / 60 });
|
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 {
|
function clearRunAlarms(tabId: number): void {
|
||||||
chrome.alarms.clear(qrAlarmName(tabId));
|
chrome.alarms.clear(qrAlarmName(tabId));
|
||||||
chrome.alarms.clear(pinAlarmName(tabId));
|
chrome.alarms.clear(pinAlarmName(tabId));
|
||||||
|
chrome.alarms.clear(relayWaitAlarmName(tabId));
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function startRun(tabId: number, origin: string, features: string[]): Promise<void> {
|
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
|
* 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
|
* 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({
|
const uploadResult = await uploadRunBundle({
|
||||||
runId,
|
runId,
|
||||||
origin,
|
origin: await resolveFullOrigin(tabId, origin),
|
||||||
x25519_pubkey: base64UrlEncode(x25519Keys.publicKey),
|
x25519_pubkey: base64UrlEncode(x25519Keys.publicKey),
|
||||||
kem_pubkey: base64UrlEncode(kemKeys.publicKey),
|
kem_pubkey: base64UrlEncode(kemKeys.publicKey),
|
||||||
});
|
});
|
||||||
if (!uploadResult.ok) return false;
|
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 existing = getRunSecrets(tabId);
|
||||||
const secrets: RunSecrets = {
|
const secrets: RunSecrets = {
|
||||||
runId,
|
runId,
|
||||||
@ -114,6 +156,7 @@ async function uploadBundleAndDisplayQr(tabId: number, origin: string, features:
|
|||||||
qrTtlSeconds,
|
qrTtlSeconds,
|
||||||
maxAutoRefresh,
|
maxAutoRefresh,
|
||||||
pinTtlSeconds,
|
pinTtlSeconds,
|
||||||
|
relayWaitTtlSeconds,
|
||||||
autoRefreshRemaining: existing?.autoRefreshRemaining ?? maxAutoRefresh,
|
autoRefreshRemaining: existing?.autoRefreshRemaining ?? maxAutoRefresh,
|
||||||
};
|
};
|
||||||
setRunSecrets(tabId, secrets);
|
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');
|
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) => {
|
chrome.alarms.onAlarm.addListener((alarm) => {
|
||||||
const [prefix, tabIdStr] = alarm.name.split(':');
|
const [prefix, tabIdStr] = alarm.name.split(':');
|
||||||
const tabId = Number(tabIdStr);
|
const tabId = Number(tabIdStr);
|
||||||
if (prefix === QR_ALARM_PREFIX) void handleQrTtlExpiry(tabId);
|
if (prefix === QR_ALARM_PREFIX) void handleQrTtlExpiry(tabId);
|
||||||
else if (prefix === PIN_ALARM_PREFIX) void handlePinTtlExpiry(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> {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
chrome.alarms.clear(relayWaitAlarmName(tabId));
|
||||||
closeRelay(tabId);
|
closeRelay(tabId);
|
||||||
clearRunSecrets(tabId);
|
clearRunSecrets(tabId);
|
||||||
await setTabState(tabId, { kind: 'delivered', origin, features });
|
await setTabState(tabId, { kind: 'delivered', origin, features });
|
||||||
sendToSdk(tabId, { type: 'run_delivered', credential });
|
sendToSdk(tabId, { type: 'run_delivered', credential });
|
||||||
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
void setTabState(tabId, { kind: 'idle', origin, features });
|
void setTabState(tabId, { kind: 'idle', origin, features, sessionActive: getSessionActive(tabId) });
|
||||||
}, DELIVERED_DISPLAY_MS);
|
}, DELIVERED_DISPLAY_MS);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -229,12 +281,14 @@ export async function confirmPin(tabId: number, origin: string, features: string
|
|||||||
chrome.alarms.clear(pinAlarmName(tabId));
|
chrome.alarms.clear(pinAlarmName(tabId));
|
||||||
await setTabState(tabId, { kind: 'phase_4', origin, features });
|
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
|
// Unlocks the companion, which is gated on this arriving before it will
|
||||||
// proceed past its own local pin_display (interfaces.md, "PIN
|
// proceed past its own local pin_display (interfaces.md, "PIN
|
||||||
// confirmation"). Fire-and-forget: this side's own phase_4 transition
|
// 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.
|
// above is already the real local gate and doesn't depend on this call.
|
||||||
const runId = getRunSecrets(tabId)?.runId;
|
if (secrets) void postPinConfirmation(secrets.runId);
|
||||||
if (runId) void postPinConfirmation(runId);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Popup action: user tapped Abort at any run phase, or the SDK sent `abort`. */
|
/** 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. */
|
/** Popup action: user dismissed a run_error screen — clears back to idle. */
|
||||||
export async function dismissRunError(tabId: number, origin: string, features: string[]): Promise<void> {
|
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 {
|
export function cleanupRun(tabId: number): void {
|
||||||
closeRelay(tabId);
|
closeRelay(tabId);
|
||||||
clearRunSecrets(tabId);
|
clearRunSecrets(tabId);
|
||||||
clearRunAlarms(tabId);
|
clearRunAlarms(tabId);
|
||||||
|
clearSessionActive(tabId);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function failRun(
|
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,14 +93,26 @@ function contentFor(state: TabState, origin: string): Content {
|
|||||||
body: origin ? `${origin} hasn't set up sign-in with LOCQR.` : "This site hasn't set up sign-in with LOCQR.",
|
body: origin ? `${origin} hasn't set up sign-in with LOCQR.` : "This site hasn't set up sign-in with LOCQR.",
|
||||||
};
|
};
|
||||||
case 'idle':
|
case 'idle':
|
||||||
return {
|
// sessionActive is an advisory hint from the site's own SDK
|
||||||
badgeClass: 'accent-tint',
|
// call (types.ts's SessionStatusMessage) — it only changes the
|
||||||
iconColor: 'accent',
|
// copy/badge here, never anything about how a run itself works.
|
||||||
glyph: 'check',
|
return state.sessionActive
|
||||||
heading: 'Ready to sign in',
|
? {
|
||||||
body: `${state.origin} supports secure sign-in with LOCQR.`,
|
badgeClass: 'green-tint',
|
||||||
actions: [{ label: 'Sign in', kind: 'start_run', style: 'primary' }],
|
iconColor: 'green',
|
||||||
};
|
glyph: 'check',
|
||||||
|
heading: 'Signed in',
|
||||||
|
body: `You're already signed in to ${state.origin}.`,
|
||||||
|
actions: [{ label: 'Sign in again', kind: 'start_run', style: 'primary' }],
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
badgeClass: 'accent-tint',
|
||||||
|
iconColor: 'accent',
|
||||||
|
glyph: 'check',
|
||||||
|
heading: 'Ready to sign in',
|
||||||
|
body: `${state.origin} supports secure sign-in with LOCQR.`,
|
||||||
|
actions: [{ label: 'Sign in', kind: 'start_run', style: 'primary' }],
|
||||||
|
};
|
||||||
case 'account_error':
|
case 'account_error':
|
||||||
return state.reason === 'unreachable'
|
return state.reason === 'unreachable'
|
||||||
? {
|
? {
|
||||||
@ -353,4 +365,12 @@ chrome.storage.onChanged.addListener((_changes, areaName) => {
|
|||||||
if (areaName === 'session') void refresh();
|
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();
|
void refresh();
|
||||||
|
|||||||
@ -26,7 +26,18 @@ export interface AbortMessage {
|
|||||||
type: 'abort';
|
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 ---
|
// --- Extension -> SDK ---
|
||||||
|
|
||||||
@ -106,6 +117,7 @@ export interface RunBundleUploadResponse {
|
|||||||
qr_ttl: number;
|
qr_ttl: number;
|
||||||
max_auto_refresh: number;
|
max_auto_refresh: number;
|
||||||
pin_ttl: number;
|
pin_ttl: number;
|
||||||
|
relay_wait_ttl: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Per-tab state (extension/claude.md's state model) ---
|
// --- Per-tab state (extension/claude.md's state model) ---
|
||||||
@ -149,7 +161,7 @@ export type TabState =
|
|||||||
| { kind: 'not_registered' }
|
| { kind: 'not_registered' }
|
||||||
| { kind: 'cert_invalid'; reason: CertInvalidReason; security: boolean }
|
| { kind: 'cert_invalid'; reason: CertInvalidReason; security: boolean }
|
||||||
| { kind: 'account_error'; reason: AccountErrorReason }
|
| { 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_1'; origin: string; features: string[] }
|
||||||
| { kind: 'phase_2'; origin: string; features: string[]; qrContent: string; qrTtlSeconds: number; phaseStartedAt: number }
|
| { 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 }
|
| { kind: 'phase_3'; origin: string; features: string[]; pin: string; pinTtlSeconds: number; phaseStartedAt: number }
|
||||||
|
|||||||
@ -156,10 +156,15 @@ Response:
|
|||||||
"signed_token": "<base64url signed token envelope>",
|
"signed_token": "<base64url signed token envelope>",
|
||||||
"qr_ttl": 90,
|
"qr_ttl": 90,
|
||||||
"max_auto_refresh": 3,
|
"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.
|
All TTL values are seconds. Clients must not apply local defaults or overrides.
|
||||||
|
|
||||||
---
|
---
|
||||||
@ -234,14 +239,16 @@ is sufficient.
|
|||||||
## PIN confirmation
|
## PIN confirmation
|
||||||
|
|
||||||
Extension → Backend (POST) and Companion → Backend (GET, polled), addressed
|
Extension → Backend (POST) and Companion → Backend (GET, polled), addressed
|
||||||
to `runId` — a real two-way handshake, not the companion proceeding on its
|
to `runId`. Single-sided: the extension's Confirm click is the only human
|
||||||
own schedule. The companion is only allowed to advance to credential
|
action that gates advancement. The companion has no local confirmation
|
||||||
selection once *both* its own local user has tapped Continue (`mobile/claude.md`'s
|
step of its own — it polls automatically as soon as it derives and displays
|
||||||
`pin_display → awaiting_confirmation`) *and* this confirmation has arrived;
|
the PIN (`mobile/claude.md`'s `pin_display`), and advances the moment this
|
||||||
neither side's local action alone is sufficient. This mirrors Bluetooth
|
resolves confirmed. The PIN being independently derived and shown on both
|
||||||
Numeric Comparison pairing, which communicates each side's confirmation to
|
devices is what makes this safe: the person visually compares the two
|
||||||
the other as part of completing the handshake, rather than trusting the two
|
values before clicking Confirm on the extension, so the check still
|
||||||
devices to act on the same timeline independently.
|
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`**
|
**`POST /run/relay/:runId/confirm`**
|
||||||
|
|
||||||
@ -254,9 +261,9 @@ times out.
|
|||||||
|
|
||||||
**`GET /run/relay/:runId/confirm`**
|
**`GET /run/relay/:runId/confirm`**
|
||||||
|
|
||||||
Polled by the companion after its own local Continue tap, until it returns
|
Polled by the companion automatically from the moment it enters
|
||||||
confirmed or a bounded wait elapses (`mobile/claude.md`'s
|
`pin_display`, until it returns confirmed or a bounded wait elapses
|
||||||
`error/confirmation_timeout`).
|
(`mobile/claude.md`'s `error/confirmation_timeout`).
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{ "confirmed": true }
|
{ "confirmed": true }
|
||||||
@ -323,11 +330,19 @@ the SDK is the translation layer between them.
|
|||||||
### SDK → Extension
|
### SDK → Extension
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
{ type: "init", cert: string } // sent immediately on Port open
|
{ type: "init", cert: string } // sent immediately on Port open
|
||||||
{ type: "request_credential" } // triggers Phase 1
|
{ type: "request_credential" } // triggers Phase 1
|
||||||
{ type: "abort" } // user-initiated cancel
|
{ 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
|
### Extension → SDK
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
|
|||||||
@ -90,21 +90,21 @@ deferred UX decision.
|
|||||||
bundle fetch, alpha_hash recomputation.
|
bundle fetch, alpha_hash recomputation.
|
||||||
- `key_exchange` — verification passed; performing X25519 key agreement and
|
- `key_exchange` — verification passed; performing X25519 key agreement and
|
||||||
ML-KEM-768 encapsulation; sending `kem_ciphertext` to backend relay.
|
ML-KEM-768 encapsulation; sending `kem_ciphertext` to backend relay.
|
||||||
- `pin_display` — run key derived; PIN displayed to user. User reads the
|
- `pin_display` — run key derived; PIN displayed to user for comparison
|
||||||
PIN and taps Continue on the companion once they've compared it.
|
against the extension's independently-derived PIN — that visual
|
||||||
- `awaiting_confirmation` — user has tapped Continue; polling
|
comparison is the actual anti-MITM check, not a gate the companion enforces
|
||||||
`GET /run/relay/:runId/confirm` (interfaces.md, "PIN confirmation") for
|
itself. No local action is required or offered here: the companion polls
|
||||||
the extension's own confirmation, which only exists once its user has
|
`GET /run/relay/:runId/confirm` (interfaces.md, "PIN confirmation")
|
||||||
separately clicked Confirm there. Advancing requires both: this device's
|
automatically the instant the PIN is derived, and advances the moment the
|
||||||
local tap and the other device's remote signal, neither one alone.
|
extension's user clicks Confirm there — that single click is the only
|
||||||
Superseded design note: an earlier version of this document had the
|
human action gating the run. Design history: this document previously
|
||||||
companion proceed the instant its own user tapped Continue, with "the
|
described a two-way handshake (companion's own tap *and* the extension's
|
||||||
companion does not receive a signal from the extension." That turned out
|
signal, neither sufficient alone), reasoning from Bluetooth-style Numeric
|
||||||
to be weaker than necessary for no real benefit — Bluetooth-style
|
Comparison pairing. That was reverted — no benefit was found in a second,
|
||||||
Numeric Comparison pairing has always communicated the confirmation
|
companion-side tap once the PIN is already independently displayed and
|
||||||
between devices as part of completing the handshake, and there was no
|
visually checked; it was pure friction on a device that has nothing
|
||||||
good reason for this protocol to do less. See interfaces.md's "PIN
|
further to decide. See interfaces.md's "PIN confirmation" section for the
|
||||||
confirmation" section for the wire-level detail.
|
wire-level detail.
|
||||||
- `credential_select` — credentials for the current site presented; vault
|
- `credential_select` — credentials for the current site presented; vault
|
||||||
already unlocked from biometric at session initiation. If exactly one
|
already unlocked from biometric at session initiation. If exactly one
|
||||||
credential is stored for the site it may be pre-selected. If multiple are
|
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)
|
→ error/relay_error (network: cannot send kem_ciphertext)
|
||||||
|
|
||||||
pin_display
|
pin_display
|
||||||
→ awaiting_confirmation (user taps Continue)
|
→ credential_select (extension's confirmation received — polled
|
||||||
→ idle (user cancels; run key discarded)
|
automatically, no local action)
|
||||||
|
|
||||||
awaiting_confirmation
|
|
||||||
→ credential_select (extension's confirmation received)
|
|
||||||
→ error/confirmation_timeout (bounded wait elapses with no confirmation —
|
→ error/confirmation_timeout (bounded wait elapses with no confirmation —
|
||||||
network class, retry offered)
|
network class, retry offered)
|
||||||
→ idle (user cancels; run key discarded)
|
→ 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).
|
- Fetch key bundle by run ID (at-most-once; bundle destroyed on fetch).
|
||||||
- Send `kem_ciphertext` to relay addressed to run ID (Phase 2).
|
- Send `kem_ciphertext` to relay addressed to run ID (Phase 2).
|
||||||
- Poll for the extension's PIN confirmation, addressed to run ID
|
- 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).
|
- Send encrypted credential ciphertext to relay addressed to run ID (Phase 4).
|
||||||
- Report security-class errors to `/security/report`.
|
- 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)`
|
### `locqr.on(event, handler)` / `locqr.off(event, handler)`
|
||||||
|
|
||||||
Registers and removes handlers for extension-initiated events.
|
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 {
|
on<E extends LocqrEvent>(event: E, handler: EventHandler<E>): void {
|
||||||
if (!this.handlers.has(event)) this.handlers.set(event, new Set());
|
if (!this.handlers.has(event)) this.handlers.set(event, new Set());
|
||||||
this.handlers.get(event)!.add(handler as EventHandler);
|
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
|
* 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
|
* 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
|
* 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
|
* for); only a real webpage does. So this stays scoped to exactly the
|
||||||
* endpoint a webpage calls directly in this slice — not the whole API —
|
* endpoints a webpage calls directly — not the whole API — specifically so
|
||||||
* specifically so it can never again regress an extension-only endpoint by
|
* it can never again regress an extension-only endpoint by accident the
|
||||||
* accident the way the blanket {@code /**} mapping just did.
|
* 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
|
@Configuration
|
||||||
public class CorsConfig implements WebMvcConfigurer {
|
public class CorsConfig implements WebMvcConfigurer {
|
||||||
@ -31,9 +48,31 @@ public class CorsConfig implements WebMvcConfigurer {
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void addCorsMappings(CorsRegistry registry) {
|
public void addCorsMappings(CorsRegistry registry) {
|
||||||
|
String[] origins = properties.allowedOrigins().toArray(new String[0]);
|
||||||
|
|
||||||
registry.addMapping("/domain/registration")
|
registry.addMapping("/domain/registration")
|
||||||
.allowedOrigins(properties.allowedOrigins().toArray(new String[0]))
|
.allowedOrigins(origins)
|
||||||
.allowedMethods("GET")
|
.allowedMethods("GET")
|
||||||
.allowedHeaders("*");
|
.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,
|
Run run,
|
||||||
Relay relay
|
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) {}
|
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.Bean;
|
||||||
import org.springframework.context.annotation.Configuration;
|
import org.springframework.context.annotation.Configuration;
|
||||||
import org.springframework.core.Ordered;
|
import org.springframework.core.Ordered;
|
||||||
|
import org.springframework.web.cors.CorsConfiguration;
|
||||||
import org.springframework.web.servlet.HandlerMapping;
|
import org.springframework.web.servlet.HandlerMapping;
|
||||||
import org.springframework.web.servlet.handler.SimpleUrlHandlerMapping;
|
import org.springframework.web.servlet.handler.SimpleUrlHandlerMapping;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Registers {@link RelayHttpRequestHandler} as the sole owner of
|
* Registers {@link RelayHttpRequestHandler} as the sole owner of
|
||||||
* {@code /run/relay/*} — see that class's Javadoc for why it isn't split
|
* {@code /run/relay/*} — see that class's Javadoc for why it isn't split
|
||||||
* across {@code @RestController} + {@code WebSocketConfigurer} instead.
|
* 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
|
@Configuration
|
||||||
public class RelayEndpointConfig {
|
public class RelayEndpointConfig {
|
||||||
|
|
||||||
|
private final LocqrProperties properties;
|
||||||
|
|
||||||
|
public RelayEndpointConfig(LocqrProperties properties) {
|
||||||
|
this.properties = properties;
|
||||||
|
}
|
||||||
|
|
||||||
@Bean
|
@Bean
|
||||||
public HandlerMapping relayHandlerMapping(RelayHttpRequestHandler handler) {
|
public HandlerMapping relayHandlerMapping(RelayHttpRequestHandler handler) {
|
||||||
SimpleUrlHandlerMapping mapping = new SimpleUrlHandlerMapping();
|
SimpleUrlHandlerMapping mapping = new SimpleUrlHandlerMapping();
|
||||||
mapping.setOrder(Ordered.HIGHEST_PRECEDENCE);
|
mapping.setOrder(Ordered.HIGHEST_PRECEDENCE);
|
||||||
mapping.setUrlMap(Map.of("/run/relay/*", handler));
|
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;
|
return mapping;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -11,11 +11,10 @@ import org.springframework.web.bind.annotation.RestController;
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* PIN-confirmation handshake (interfaces.md, "PIN confirmation") — the
|
* PIN-confirmation handshake (interfaces.md, "PIN confirmation") — the
|
||||||
* extension posts here when its user clicks Confirm; the companion polls
|
* extension posts here the moment its user clicks Confirm; the companion
|
||||||
* here after its own local pin_display gate before it's allowed to proceed
|
* polls here automatically from the instant it enters pin_display. The
|
||||||
* to credential_select. Neither side trusts the other's timing without
|
* extension's click is the only human action gating advancement — the
|
||||||
* this: the companion no longer just proceeds on its own schedule
|
* companion has no local confirmation step of its own (mobile/claude.md).
|
||||||
* (mobile/claude.md, updated).
|
|
||||||
*/
|
*/
|
||||||
@RestController
|
@RestController
|
||||||
public class PinConfirmationController {
|
public class PinConfirmationController {
|
||||||
|
|||||||
@ -47,7 +47,8 @@ public class RunController {
|
|||||||
request.runId(), request.origin(), request.x25519Pubkey(), request.kemPubkey(), expiresAt));
|
request.runId(), request.origin(), request.x25519Pubkey(), request.kemPubkey(), expiresAt));
|
||||||
|
|
||||||
return new RunBundleUploadResponse(
|
return new RunBundleUploadResponse(
|
||||||
signedToken, runParams.qrTtlSeconds(), runParams.maxAutoRefresh(), runParams.pinTtlSeconds());
|
signedToken, runParams.qrTtlSeconds(), runParams.maxAutoRefresh(), runParams.pinTtlSeconds(),
|
||||||
|
runParams.relayWaitTtlSeconds());
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("/run/bundle/{runId}")
|
@GetMapping("/run/bundle/{runId}")
|
||||||
|
|||||||
@ -6,5 +6,6 @@ public record RunBundleUploadResponse(
|
|||||||
@JsonProperty("signed_token") String signedToken,
|
@JsonProperty("signed_token") String signedToken,
|
||||||
@JsonProperty("qr_ttl") int qrTtl,
|
@JsonProperty("qr_ttl") int qrTtl,
|
||||||
@JsonProperty("max_auto_refresh") int maxAutoRefresh,
|
@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."
|
* discarded and the run must be restarted."
|
||||||
*
|
*
|
||||||
* <p>Also tracks the PIN-confirmation handshake (interfaces.md, "PIN
|
* <p>Also tracks the PIN-confirmation handshake (interfaces.md, "PIN
|
||||||
* confirmation"): the extension posts a confirmation here when its user
|
* confirmation"): the extension posts a confirmation here the moment its
|
||||||
* clicks Confirm, and the companion polls for it before it's allowed to
|
* user clicks Confirm — the only human action that gates the run, the
|
||||||
* proceed past its own local pin_display gate. Same ephemeral-state
|
* companion has no local confirmation step of its own — and the companion
|
||||||
* lifecycle and TTL as the message buffer above, so it's tracked alongside
|
* polls for it automatically from the moment it displays the PIN. Same
|
||||||
* it rather than in a separate component.
|
* ephemeral-state lifecycle and TTL as the message buffer above, so it's
|
||||||
|
* tracked alongside it rather than in a separate component.
|
||||||
*/
|
*/
|
||||||
@Component
|
@Component
|
||||||
public class RelaySessionRegistry {
|
public class RelaySessionRegistry {
|
||||||
|
|||||||
@ -16,13 +16,19 @@ locqr:
|
|||||||
test-domain: ${TEST_DOMAIN:test.locqr.dev}
|
test-domain: ${TEST_DOMAIN:test.locqr.dev}
|
||||||
ed25519-secret-key-path: ${ED25519_SECRET_KEY_PATH:../dev/keys/ed25519-secret.b64}
|
ed25519-secret-key-path: ${ED25519_SECRET_KEY_PATH:../dev/keys/ed25519-secret.b64}
|
||||||
registration-cert-path: ${REGISTRATION_CERT_PATH:../dev/certs/test-registration.b64}
|
registration-cert-path: ${REGISTRATION_CERT_PATH:../dev/certs/test-registration.b64}
|
||||||
# Webpage-context clients only (test website, later the companion web
|
# Webpage-context clients (test website, companion web stub) plus the dev
|
||||||
# stub) — the extension's service worker bypasses CORS via manifest
|
# extension's own origin. The extension's host_permissions bypasses CORS
|
||||||
# host_permissions instead. Ports match dev.md's documented defaults.
|
# enforcement on its *own* side, but not the server's — CorsConfig has to
|
||||||
allowed-origins: ${ALLOWED_ORIGINS:https://test.locqr.dev:5173,https://test.locqr.dev:5174}
|
# 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:
|
run:
|
||||||
qr-ttl-seconds: 90
|
qr-ttl-seconds: 90
|
||||||
max-auto-refresh: 3
|
max-auto-refresh: 3
|
||||||
pin-ttl-seconds: 120
|
pin-ttl-seconds: 120
|
||||||
|
relay-wait-ttl-seconds: 60
|
||||||
relay:
|
relay:
|
||||||
buffer-ttl-seconds: 8
|
buffer-ttl-seconds: 8
|
||||||
|
|||||||
@ -92,6 +92,7 @@ class RunFlowIntegrationTest {
|
|||||||
assertThat(body.qrTtl()).isEqualTo(90);
|
assertThat(body.qrTtl()).isEqualTo(90);
|
||||||
assertThat(body.maxAutoRefresh()).isEqualTo(3);
|
assertThat(body.maxAutoRefresh()).isEqualTo(3);
|
||||||
assertThat(body.pinTtl()).isEqualTo(120);
|
assertThat(body.pinTtl()).isEqualTo(120);
|
||||||
|
assertThat(body.relayWaitTtl()).isEqualTo(60);
|
||||||
assertThat(body.signedToken()).isNotBlank();
|
assertThat(body.signedToken()).isNotBlank();
|
||||||
|
|
||||||
RunBundleFetchResponse firstFetch = client.get().uri("/run/bundle/{runId}", runId)
|
RunBundleFetchResponse firstFetch = client.get().uri("/run/bundle/{runId}", runId)
|
||||||
|
|||||||
@ -91,7 +91,7 @@ class SigningServiceTest {
|
|||||||
keyFile.toString(),
|
keyFile.toString(),
|
||||||
"/nonexistent",
|
"/nonexistent",
|
||||||
java.util.List.of(),
|
java.util.List.of(),
|
||||||
new LocqrProperties.Run(90, 3, 120),
|
new LocqrProperties.Run(90, 3, 120, 60),
|
||||||
new LocqrProperties.Relay(8));
|
new LocqrProperties.Relay(8));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -130,7 +130,7 @@ class RelaySessionRegistryTest {
|
|||||||
private RelaySessionRegistry registry(int bufferTtlSeconds) {
|
private RelaySessionRegistry registry(int bufferTtlSeconds) {
|
||||||
RelaySessionRegistry registry = new RelaySessionRegistry(new LocqrProperties(
|
RelaySessionRegistry registry = new RelaySessionRegistry(new LocqrProperties(
|
||||||
"test.locqr.dev", "unused", "unused", java.util.List.of(),
|
"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)));
|
new LocqrProperties.Relay(bufferTtlSeconds)));
|
||||||
created.add(registry);
|
created.add(registry);
|
||||||
return registry;
|
return registry;
|
||||||
|
|||||||
@ -47,7 +47,7 @@ class RegistrationCertificateStoreTest {
|
|||||||
"unused",
|
"unused",
|
||||||
certPath,
|
certPath,
|
||||||
java.util.List.of(),
|
java.util.List.of(),
|
||||||
new LocqrProperties.Run(90, 3, 120),
|
new LocqrProperties.Run(90, 3, 120, 60),
|
||||||
new LocqrProperties.Relay(8));
|
new LocqrProperties.Relay(8));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -24,6 +24,7 @@
|
|||||||
<input id="username" type="text" placeholder="Username" readonly />
|
<input id="username" type="text" placeholder="Username" readonly />
|
||||||
<input id="password" type="password" placeholder="Password" readonly />
|
<input id="password" type="password" placeholder="Password" readonly />
|
||||||
<button id="sign-in-btn" type="button" disabled>Sign in with LOCQR</button>
|
<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>
|
</form>
|
||||||
|
|
||||||
<h2 style="font-size: 14px;">Event log</h2>
|
<h2 style="font-size: 14px;">Event log</h2>
|
||||||
@ -51,6 +52,19 @@
|
|||||||
// in development"). Stable as long as key.pem doesn't change.
|
// in development"). Stable as long as key.pem doesn't change.
|
||||||
const DEV_EXTENSION_ID = 'daijigbjegngkjckedcbhhdjkampjgia';
|
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() {
|
async function main() {
|
||||||
log('page:loaded');
|
log('page:loaded');
|
||||||
|
|
||||||
@ -74,7 +88,20 @@
|
|||||||
|
|
||||||
locqr.on('ready', (payload) => log('event:ready', payload));
|
locqr.on('ready', (payload) => log('event:ready', payload));
|
||||||
locqr.on('run:started', (payload) => log('event:run:started', 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.on('run:error', (payload) => log('event:run:error', payload));
|
||||||
|
|
||||||
locqr.init({ cert, extensionId: DEV_EXTENSION_ID });
|
locqr.init({ cert, extensionId: DEV_EXTENSION_ID });
|
||||||
@ -84,6 +111,11 @@
|
|||||||
statusEl.textContent = `Ready — features: ${result.features.join(', ')}`;
|
statusEl.textContent = `Ready — features: ${result.features.join(', ')}`;
|
||||||
log('ready:resolved', result);
|
log('ready:resolved', result);
|
||||||
signInBtn.disabled = false;
|
signInBtn.disabled = false;
|
||||||
|
|
||||||
|
const active = hasSimulatedSession();
|
||||||
|
logOutBtn.style.display = active ? '' : 'none';
|
||||||
|
locqr.reportSession({ active });
|
||||||
|
log('reportSession', { active });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
statusEl.textContent = `Not ready — ${error.code}${error.reason ? ' / ' + error.reason : ''}`;
|
statusEl.textContent = `Not ready — ${error.code}${error.reason ? ' / ' + error.reason : ''}`;
|
||||||
log('ready:rejected', error);
|
log('ready:rejected', error);
|
||||||
@ -91,18 +123,28 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
const signInBtn = document.getElementById('sign-in-btn');
|
const signInBtn = document.getElementById('sign-in-btn');
|
||||||
|
const logOutBtn = document.getElementById('log-out-btn');
|
||||||
const usernameEl = document.getElementById('username');
|
const usernameEl = document.getElementById('username');
|
||||||
const passwordEl = document.getElementById('password');
|
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.addEventListener('click', async () => {
|
||||||
signInBtn.disabled = true;
|
signInBtn.disabled = true;
|
||||||
usernameEl.value = '';
|
usernameEl.value = '';
|
||||||
passwordEl.value = '';
|
passwordEl.value = '';
|
||||||
log('requestCredential:called');
|
log('requestCredential:called');
|
||||||
try {
|
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();
|
const credential = await locqr.requestCredential();
|
||||||
usernameEl.value = credential.username;
|
|
||||||
passwordEl.value = credential.password;
|
|
||||||
log('requestCredential:resolved', { username: credential.username });
|
log('requestCredential:resolved', { username: credential.username });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
statusEl.textContent = `Sign-in didn't complete — ${error.code}${error.reason ? ' / ' + error.reason : ''}`;
|
statusEl.textContent = `Sign-in didn't complete — ${error.code}${error.reason ? ' / ' + error.reason : ''}`;
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user