239 lines
10 KiB
JavaScript
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;
|
|
});
|