locqr/scripts/companion-harness.mjs
2026-08-16 22:00:08 +02:00

239 lines
10 KiB
JavaScript

#!/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 <signed_token> [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 <signed_token>
// node companion-harness.mjs deliver-credential <runId> <runKeyB64> <username> <password>
// 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 <signed_token> [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 <signed_token>');
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 <runId> <runKeyB64> <username> <password>');
}
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;
});