locqr/scripts/setup-dev.js
2026-08-16 22:00:08 +02:00

303 lines
12 KiB
JavaScript

#!/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;
});