303 lines
12 KiB
JavaScript
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;
|
|
});
|