#!/usr/bin/env node // LOCQR dev environment bootstrapping — see ../dev.md. // // Scope: dev signing keypairs (Ed25519, ML-DSA-44), the signed test // registration certificate, mkcert local-CA TLS certs, and the extension's // stable dev identity (key.pem). Pinned-constant patching // (extension/src/constants.ts, companion's equivalent) is still deferred — // added once there's a real constants file to patch rather than a // hand-set one. // // Node.js, not shell or Java: crypto.md pins `@oqs/liboqs-js` specifically // for "Node.js setup script" ML-DSA-44 work — this script runs in plain // Node, not a service worker, so the WASM-dynamic-import restriction that // ruled that library out for the extension (crypto.md, "Resolved // 2026-08-16") doesn't apply here. // // Idempotent: every artifact here is checked for existence before being // generated. Re-running this script on an existing environment changes // nothing. To rotate keys, delete the relevant dev/keys or dev/certs file(s) // first — see dev.md, "Rotating dev keys". import { createMLDSA44 } from '@oqs/liboqs-js'; import canonicalize from 'canonicalize'; import { execFileSync } from 'node:child_process'; import { createHash, createPublicKey, generateKeyPairSync } from 'node:crypto'; import { mkdirSync, existsSync, readFileSync, writeFileSync } from 'node:fs'; import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; const ROOT = dirname(dirname(fileURLToPath(import.meta.url))); // repo root, one up from scripts/ const KEYS_DIR = join(ROOT, 'dev', 'keys'); const CERTS_DIR = join(ROOT, 'dev', 'certs'); const EXTENSION_DIR = join(ROOT, 'extension'); const TEST_DOMAIN = 'test.locqr.dev'; const API_DOMAIN = 'api.locqr.dev'; const CERT_LIFETIME_SECONDS = 10 * 365 * 24 * 60 * 60; // 10 years — a dev cert that outlives the dev cycle function readIfPresent(path) { return existsSync(path) ? readFileSync(path, 'utf8').trim() : null; } function writeAndAnnounce(path, contents, label) { writeFileSync(path, contents + '\n'); console.log(` generated ${label} -> ${path}`); } /** * @oqs/liboqs-js validates key/message arguments by strict duck-typing * (`value.constructor.name === 'Uint8Array'`, not `instanceof`) — a Node * `Buffer` fails that check even though it *is* a Uint8Array by * inheritance, since its constructor is named `Buffer`. Every byte array * handed to sign()/verify() has to go through this first. */ function toUint8Array(buffer) { return Uint8Array.from(buffer); } /** * Ed25519 dev keypair for the backend's key-exchange-token signing (crypto.md, * server/claude.md). JWK export gives the raw 32-byte scalar directly as * base64url (RFC 4648 §5, matching interfaces.md's shared conventions) — no * PKCS8 wrapping, no manual point encoding. */ function ensureEd25519Keys() { const secretPath = join(KEYS_DIR, 'ed25519-secret.b64'); const publicPath = join(KEYS_DIR, 'ed25519-public.b64'); const existingSecret = readIfPresent(secretPath); const existingPublic = readIfPresent(publicPath); if (existingSecret && existingPublic) { console.log(' Ed25519 dev keypair already present, skipping.'); return { secretB64: existingSecret, publicB64: existingPublic }; } const { publicKey, privateKey } = generateKeyPairSync('ed25519'); const secretB64 = privateKey.export({ format: 'jwk' }).d; const publicB64 = publicKey.export({ format: 'jwk' }).x; writeAndAnnounce(secretPath, secretB64, 'Ed25519 secret key'); writeAndAnnounce(publicPath, publicB64, 'Ed25519 public key'); console.log(' NEW Ed25519 keypair — remember to commit both files (dev.md).'); return { secretB64, publicB64 }; } /** * ML-DSA-44 dev keypair for signing the domain registration certificate * (crypto.md). Only ever used offline, by this script — the running server * never signs with it live (server/claude.md). */ async function ensureMlDsaKeys() { const secretPath = join(KEYS_DIR, 'ml-dsa-44-secret.b64'); const publicPath = join(KEYS_DIR, 'ml-dsa-44-public.b64'); const existingSecret = readIfPresent(secretPath); const existingPublic = readIfPresent(publicPath); if (existingSecret && existingPublic) { console.log(' ML-DSA-44 dev keypair already present, skipping.'); return { secretB64: existingSecret, publicB64: existingPublic }; } const signer = await createMLDSA44(); let secretB64; let publicB64; try { const { publicKey, secretKey } = signer.generateKeyPair(); secretB64 = Buffer.from(secretKey).toString('base64url'); publicB64 = Buffer.from(publicKey).toString('base64url'); } finally { signer.destroy(); // WASM memory isn't GC'd — see @oqs/liboqs-js README } writeAndAnnounce(secretPath, secretB64, 'ML-DSA-44 secret key'); writeAndAnnounce(publicPath, publicB64, 'ML-DSA-44 public key'); console.log(' NEW ML-DSA-44 keypair — remember to commit both files (dev.md).'); return { secretB64, publicB64 }; } /** * Issues and signs the test registration certificate for TEST_DOMAIN * (interfaces.md, "Registration certificate"). Stable/committed like the * keys — re-running this script does not reissue it, so its issued_at/ * expires_at don't churn on every developer's machine. */ async function ensureTestRegistrationCert(mlDsaKeys) { const certPath = join(CERTS_DIR, 'test-registration.b64'); if (existsSync(certPath)) { console.log(' Test registration certificate already present, skipping.'); return; } const issuedAt = Math.floor(Date.now() / 1000); const payload = { v: 1, domain: TEST_DOMAIN, issued_at: issuedAt, expires_at: issuedAt + CERT_LIFETIME_SECONDS, features: ['login'], }; const canonical = canonicalize(payload); const message = new TextEncoder().encode(canonical); const secretKey = toUint8Array(Buffer.from(mlDsaKeys.secretB64, 'base64url')); const publicKey = toUint8Array(Buffer.from(mlDsaKeys.publicB64, 'base64url')); const signer = await createMLDSA44(); let signature; try { signature = signer.sign(message, secretKey); // Self-check before writing anything to disk: sign() and verify() are // independent code paths in the library: this catches a broken pairing // (e.g. mismatched key files) at generation time, not at first // extension-side verification. const verifies = signer.verify(message, signature, publicKey); if (!verifies) { throw new Error('ML-DSA-44 self-check failed: signature does not verify against its own public key.'); } } finally { signer.destroy(); } const envelope = { ...payload, sig: Buffer.from(signature).toString('base64url') }; const encoded = Buffer.from(JSON.stringify(envelope), 'utf8').toString('base64url'); writeAndAnnounce(certPath, encoded, 'test registration certificate'); console.log(` NEW registration cert for ${TEST_DOMAIN}, valid ${CERT_LIFETIME_SECONDS / (365 * 24 * 60 * 60)} years — remember to commit (dev.md).`); } /** * mkcert local CA — installs it into the system/browser trust stores * (idempotent by mkcert's own design; safe to call every run) and exports * rootCA.pem for other developers to import (dev.md, "Committed artifacts"). */ function ensureMkcertRootCa() { const dest = join(CERTS_DIR, 'rootCA.pem'); execFileSync('mkcert', ['-install'], { stdio: 'inherit' }); if (existsSync(dest)) { console.log(' rootCA.pem already exported, skipping.'); return; } const caRoot = execFileSync('mkcert', ['-CAROOT'], { encoding: 'utf8' }).trim(); writeFileSync(dest, readFileSync(join(caRoot, 'rootCA.pem'))); console.log(` exported mkcert root CA -> ${dest}`); } /** TLS cert+key for one local dev domain, via mkcert. */ function ensureDomainCert(domain) { const certPath = join(CERTS_DIR, `${domain}.pem`); const keyPath = join(CERTS_DIR, `${domain}-key.pem`); if (existsSync(certPath) && existsSync(keyPath)) { console.log(` ${domain} cert already present, skipping.`); return; } execFileSync('mkcert', ['-cert-file', certPath, '-key-file', keyPath, domain], { stdio: 'inherit' }); console.log(` generated ${domain} cert -> ${certPath}`); } /** * dev.md is explicit that this script checks for the required /etc/hosts * entries and prints instructions if missing, but never edits the file * itself — hosts file changes need root and are exactly the kind of system * change a script shouldn't make unannounced. */ function checkHostsFile() { let hosts; try { hosts = readFileSync('/etc/hosts', 'utf8'); } catch { console.log(' Could not read /etc/hosts — add these entries manually:'); console.log(` 127.0.0.1 ${TEST_DOMAIN}`); console.log(` 127.0.0.1 ${API_DOMAIN}`); return; } const missing = [TEST_DOMAIN, API_DOMAIN].filter((d) => !hosts.includes(d)); if (missing.length === 0) { console.log(' /etc/hosts already has the required entries.'); return; } console.log(' /etc/hosts is missing entries for: ' + missing.join(', ')); console.log(' Add these lines yourself (this script does not modify /etc/hosts):'); for (const d of missing) console.log(` 127.0.0.1 ${d}`); } /** Chrome's extension-ID derivation: SHA-256(DER SPKI public key), first 16 * bytes, each nibble mapped to a letter a-p. Unrelated to our Ed25519/ * ML-DSA-44 protocol keys — this is Chrome's own extension identity * mechanism (RSA), used only to pin a stable ID for the unpacked dev build. */ function computeExtensionId(publicKeyDer) { const hash = createHash('sha256').update(publicKeyDer).digest(); let id = ''; for (const byte of hash.subarray(0, 16)) { id += String.fromCharCode(97 + (byte >> 4)); id += String.fromCharCode(97 + (byte & 0x0f)); } return id; } /** * extension/key.pem pins the unpacked extension's ID across machines and * rebuilds (dev.md). The "key" field it produces goes in manifest.json. */ function ensureExtensionKeyPem() { const keyPath = join(EXTENSION_DIR, 'key.pem'); let publicKeyDer; if (existsSync(keyPath)) { console.log(' extension/key.pem already present, skipping.'); publicKeyDer = createPublicKey(readFileSync(keyPath, 'utf8')).export({ type: 'spki', format: 'der' }); } else { mkdirSync(EXTENSION_DIR, { recursive: true }); const { publicKey, privateKey } = generateKeyPairSync('rsa', { modulusLength: 2048, publicKeyEncoding: { type: 'spki', format: 'der' }, privateKeyEncoding: { type: 'pkcs8', format: 'pem' }, }); writeFileSync(keyPath, privateKey); console.log(` generated extension/key.pem -> ${keyPath}`); console.log(' NEW extension identity — remember to commit (dev.md).'); publicKeyDer = publicKey; } console.log(` dev extension ID: ${computeExtensionId(publicKeyDer)}`); console.log(` manifest.json "key": ${publicKeyDer.toString('base64')}`); } async function main() { mkdirSync(KEYS_DIR, { recursive: true }); mkdirSync(CERTS_DIR, { recursive: true }); console.log('Ed25519 signing key (key exchange tokens):'); ensureEd25519Keys(); console.log('ML-DSA-44 signing key (domain registration certs):'); const mlDsaKeys = await ensureMlDsaKeys(); console.log(`Test registration certificate (${TEST_DOMAIN}):`); await ensureTestRegistrationCert(mlDsaKeys); console.log('mkcert local CA:'); ensureMkcertRootCa(); console.log(`TLS certs (${TEST_DOMAIN}, ${API_DOMAIN}):`); ensureDomainCert(TEST_DOMAIN); ensureDomainCert(API_DOMAIN); console.log('/etc/hosts:'); checkHostsFile(); console.log('Extension dev identity:'); ensureExtensionKeyPem(); console.log('\nDone. Not yet covered by this script (see dev.md): pinned-constant'); console.log('patching (extension/src/constants.ts, companion equivalent) —'); console.log('added once those files exist to patch.'); } main().catch((err) => { console.error('setup-dev.js failed:', err); process.exitCode = 1; });