#!/usr/bin/env node // Protocol-level stand-in for the companion app (mobile/claude.md's real // target — this is not a preview of it, per the run-flow plan's explicit // scope note). Drives the companion's real cryptographic and network // responsibilities against the real server and a real running extension, // so the run flow can be verified end-to-end without the companion app // existing yet — same "curl/Puppeteer instead of mocks" approach used // throughout this project. // // Subcommands, in increasing order of manual effort: // // node companion-harness.mjs run [username] [password] // The easy path for manual testing. Does the key exchange, prints the // PIN, then polls interfaces.md's PIN confirmation endpoint — the real // signal, not a human keypress standing in for it — before delivering // a credential (defaults: alice@example.com / hunter2). One paste, no // keypress needed. // // node companion-harness.mjs key-exchange // node companion-harness.mjs deliver-credential // The two steps split apart, for scripted/automated use. // // This is a real two-way handshake now, not the companion proceeding on // its own schedule: the extension posts its confirmation when the user // clicks Confirm, and the companion (real or, here, this harness) is // expected to wait for it before it ever selects/sends a credential — // `run` above does exactly that via waitForPinConfirmation(). Calling // `deliver-credential` directly, without confirmation ever having been // posted, is still possible (nothing here stops you) but the extension // will just discard it — the actual gate lives there, not in this script. import { readFileSync } from 'node:fs'; import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; import { webcrypto } from 'node:crypto'; import { Agent, setGlobalDispatcher } from 'undici'; import { ed25519, x25519 } from '@noble/curves/ed25519.js'; import { ml_kem768 } from '@noble/post-quantum/ml-kem.js'; import canonicalize from 'canonicalize'; const __dirname = dirname(fileURLToPath(import.meta.url)); const API_BASE = process.env.LOCQR_API_BASE ?? 'https://api.locqr.dev:3000'; // dev/keys/ed25519-public.b64 — same pinned dev constant compiled into the // extension (crypto.md's ED25519_PUBLIC_KEY_B64); must match or every // signature check below fails. const ED25519_PUBLIC_KEY_B64 = 'Z9ppr33AB5gznf-mQU2F9Y3E-MEoNcGHtmlh7rzaTh4'; // interfaces.md: 30s clock-skew leeway past expires_at is the only clock // tolerance in the protocol. const EXPIRY_LEEWAY_SECONDS = 30; // Trust the local mkcert CA (dev.md) for the harness's own TLS connections — // Node doesn't consult the system/NSS trust stores the browsers use. setGlobalDispatcher(new Agent({ connect: { ca: readFileSync(join(__dirname, '..', 'dev', 'certs', 'rootCA.pem')) } })); function b64urlDecode(s) { return new Uint8Array(Buffer.from(s, 'base64url')); } function b64urlEncode(bytes) { return Buffer.from(bytes).toString('base64url'); } function concat(...parts) { const total = parts.reduce((n, p) => n + p.length, 0); const out = new Uint8Array(total); let offset = 0; for (const p of parts) { out.set(p, offset); offset += p.length; } return out; } async function hkdfSha256(ikm, salt, info, lengthBytes) { const key = await webcrypto.subtle.importKey('raw', ikm, 'HKDF', false, ['deriveBits']); const bits = await webcrypto.subtle.deriveBits( { name: 'HKDF', hash: 'SHA-256', salt, info: new TextEncoder().encode(info) }, key, lengthBytes * 8, ); return new Uint8Array(bits); } // crypto.md: run_key = HKDF-SHA256(x25519_shared || kem_shared_secret, salt=runId_utf8, info="locqr-run-key-v1", 32). async function deriveRunKey(x25519Shared, kemSharedSecret, runId) { const ikm = concat(x25519Shared, kemSharedSecret); const salt = new TextEncoder().encode(runId); return hkdfSha256(ikm, salt, 'locqr-run-key-v1', 32); } // mobile/claude.md: PIN = HKDF-SHA256(run_key, salt=[], info="locqr-pin-v1", 4) -> big-endian uint32 -> mod 1e6 -> zero-padded. // Must match extension/src/background/crypto.ts's derivePin() exactly, or the two sides' PINs never agree. async function derivePin(runKey) { 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'); } async function sha256(...parts) { const digest = await webcrypto.subtle.digest('SHA-256', concat(...parts)); return new Uint8Array(digest); } function bytesEqual(a, b) { return a.length === b.length && a.every((v, i) => v === b[i]); } async function postRelay(runId, type, payloadB64) { const response = await fetch(`${API_BASE}/run/relay/${runId}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ type, payload_b64: payloadB64 }), }); if (!response.ok) { throw new Error(`POST /run/relay/${runId} (${type}) failed: ${response.status}`); } } async function keyExchange(signedToken) { const envelope = JSON.parse(Buffer.from(signedToken, 'base64url').toString('utf8')); const { v, runId, url, expires_at: expiresAt, alpha_hash: alphaHashB64, sig } = envelope; if (v !== 1) throw new Error(`unsupported token version: ${v}`); // interfaces.md: signed payload is the envelope minus `sig`, JCS-canonicalised. const payload = { v, runId, url, expires_at: expiresAt, alpha_hash: alphaHashB64 }; const signatureValid = ed25519.verify(b64urlDecode(sig), Buffer.from(canonicalize(payload), 'utf8'), b64urlDecode(ED25519_PUBLIC_KEY_B64)); if (!signatureValid) throw new Error('token_signature_invalid'); const nowSeconds = Date.now() / 1000; if (nowSeconds > expiresAt + EXPIRY_LEEWAY_SECONDS) throw new Error('ttl_expired'); const bundleResponse = await fetch(`${API_BASE}/run/bundle/${runId}`); if (!bundleResponse.ok) throw new Error(`bundle_consumed_or_expired (${bundleResponse.status})`); const { x25519_pubkey: x25519PubB64, kem_pubkey: kemPubB64 } = await bundleResponse.json(); const extensionX25519Pub = b64urlDecode(x25519PubB64); const extensionKemPub = b64urlDecode(kemPubB64); const recomputedAlphaHash = await sha256(extensionX25519Pub, extensionKemPub); if (!bytesEqual(recomputedAlphaHash, b64urlDecode(alphaHashB64))) throw new Error('alpha_hash_mismatch'); const companionX25519 = x25519.keygen(); const x25519Shared = x25519.getSharedSecret(companionX25519.secretKey, extensionX25519Pub); const { cipherText: kemCiphertext, sharedSecret: kemSharedSecret } = ml_kem768.encapsulate(extensionKemPub); const runKey = await deriveRunKey(x25519Shared, kemSharedSecret, runId); const pin = await derivePin(runKey); await postRelay(runId, 'kem_ciphertext', b64urlEncode(concat(companionX25519.publicKey, kemCiphertext))); return { runId, pin, runKey: b64urlEncode(runKey) }; } async function deliverCredential(runId, runKeyB64, username, password) { const runKey = b64urlDecode(runKeyB64); const nonce = webcrypto.getRandomValues(new Uint8Array(12)); const plaintext = new TextEncoder().encode(JSON.stringify({ username, password })); const key = await webcrypto.subtle.importKey('raw', runKey, 'AES-GCM', false, ['encrypt']); const ciphertextAndTag = new Uint8Array(await webcrypto.subtle.encrypt({ name: 'AES-GCM', iv: nonce }, key, plaintext)); await postRelay(runId, 'credential', b64urlEncode(concat(nonce, ciphertextAndTag))); } const CONFIRMATION_POLL_INTERVAL_MS = 1000; const CONFIRMATION_POLL_TIMEOUT_MS = 180_000; // generous safety net — the extension's own pin_ttl alarm is the real, authoritative deadline /** * Polls interfaces.md's PIN confirmation endpoint rather than trusting a * human keypress to mean "I've confirmed and the browser has processed * it" — those aren't the same thing (a click can be sent and still not * have landed yet, e.g. a slow service worker wake). This waits for the * real signal instead of a proxy for it. */ async function waitForPinConfirmation(runId) { const deadline = Date.now() + CONFIRMATION_POLL_TIMEOUT_MS; while (Date.now() < deadline) { const response = await fetch(`${API_BASE}/run/relay/${runId}/confirm`); if (response.ok) { const { confirmed } = await response.json(); if (confirmed) return; } await new Promise((resolve) => setTimeout(resolve, CONFIRMATION_POLL_INTERVAL_MS)); } throw new Error('timed out waiting for PIN confirmation from the browser'); } async function runInteractive(signedToken, username, password) { const { runId, pin, runKey } = await keyExchange(signedToken); console.log(`\nPIN: ${pin}`); console.log('Compare that against the PIN shown in the extension popup, then click Confirm there.'); console.log('Waiting for the browser to confirm...'); await waitForPinConfirmation(runId); console.log('Confirmed by the browser.'); await deliverCredential(runId, runKey, username, password); console.log(`Delivered credential (${username} / ${password}). The popup should now show "Signed in".`); } async function main() { const [command, ...args] = process.argv.slice(2); if (command === 'run') { const [signedToken, username = 'alice@example.com', password = 'hunter2'] = args; if (!signedToken) throw new Error('usage: companion-harness.mjs run [username] [password]'); await runInteractive(signedToken, username, password); return; } if (command === 'key-exchange') { const [signedToken] = args; if (!signedToken) throw new Error('usage: companion-harness.mjs key-exchange '); console.log(JSON.stringify(await keyExchange(signedToken))); return; } if (command === 'deliver-credential') { const [runId, runKeyB64, username, password] = args; if (!runId || !runKeyB64 || !username || !password) { throw new Error('usage: companion-harness.mjs deliver-credential '); } await deliverCredential(runId, runKeyB64, username, password); console.log(JSON.stringify({ ok: true })); return; } throw new Error(`unknown command: ${command ?? '(none)'} — expected "run", "key-exchange", or "deliver-credential"`); } main().catch((err) => { console.error(err instanceof Error ? err.message : err); process.exitCode = 1; });