// BNS local certificate authority. // The chain binds name -> owner; this CA binds name -> TLS key on machines that // install its root ("make our own https"). // // The root is NAME-CONSTRAINED (RFC 5280 §4.2.1.10) to the TLDs in tlds.js and // excluded from all IP addresses. That matters because install.ps1 puts this // root in the machine trust store: an unconstrained root would mean whoever can // read ca/root-ca.key could mint a trusted certificate for *any* domain — the // user's bank included. With constraints, the worst case is limited to names // this project already governs. // // ca/root-ca.key is still a secret worth protecting (it can forge any name under // the permitted TLDs), it is generated per machine, and it must never ship. import forge from "node-forge"; import { readFileSync, writeFileSync, existsSync, mkdirSync } from "fs"; import { fileURLToPath } from "url"; import { PERMITTED_TLDS, isPermittedHost } from "./tlds.js"; const CA_DIR = fileURLToPath(new URL("../../ca/", import.meta.url)); const KEY_FILE = CA_DIR + "root-ca.key"; const CERT_FILE = CA_DIR + "root-ca.crt"; const NAME_CONSTRAINTS_OID = "2.5.29.30"; function makeCert(publicKey, subjectAttrs, issuerAttrs, extensions, years) { const cert = forge.pki.createCertificate(); cert.publicKey = publicKey; cert.serialNumber = "00" + forge.util.bytesToHex(forge.random.getBytesSync(8)); cert.validity.notBefore = new Date(); cert.validity.notAfter = new Date(); cert.validity.notAfter.setFullYear(cert.validity.notBefore.getFullYear() + years); cert.setSubject(subjectAttrs); cert.setIssuer(issuerAttrs); cert.setExtensions(extensions); return cert; } // NameConstraints ::= SEQUENCE { // permittedSubtrees [0] GeneralSubtrees OPTIONAL, // excludedSubtrees [1] GeneralSubtrees OPTIONAL } // GeneralSubtree ::= SEQUENCE { base GeneralName, ... } // GeneralName: dNSName is [2] IA5String, iPAddress is [7] OCTET STRING. // // node-forge has no nameConstraints support, so this is hand-built ASN.1 handed // to forge as a raw extension. function nameConstraintsExt(tlds) { const { asn1 } = forge; const CS = asn1.Class.CONTEXT_SPECIFIC; const seq = (v) => asn1.create(asn1.Class.UNIVERSAL, asn1.Type.SEQUENCE, true, v); const dnsSubtree = (tld) => seq([asn1.create(CS, 2, false, tld)]); // In a constraint an iPAddress base is address||mask. All-zero address with an // all-zero mask is "every address", so excluding it bars every IP-address SAN // for both IPv4 (4+4 bytes) and IPv6 (16+16). const ipSubtree = (len) => seq([asn1.create(CS, 7, false, "\0".repeat(len))]); const nc = seq([ asn1.create(CS, 0, true, tlds.map(dnsSubtree)), asn1.create(CS, 1, true, [ipSubtree(8), ipSubtree(32)]), ]); // RFC 5280: "Conforming CAs MUST mark this extension as critical." return { id: NAME_CONSTRAINTS_OID, critical: true, value: asn1.toDer(nc).getBytes() }; } // Pull the permitted dNSName bases back out of an existing root, so we can tell // whether it still matches the configured TLD list. Returns null if the cert // carries no name-constraints extension at all (i.e. a pre-constraint root). function readPermittedDns(cert) { const ext = (cert.extensions ?? []).find((e) => e.id === NAME_CONSTRAINTS_OID); if (!ext) return null; try { const der = typeof ext.value === "string" ? ext.value : forge.asn1.toDer(ext.value).getBytes(); const nc = forge.asn1.fromDer(der); const permitted = nc.value.find((v) => v.type === 0 && v.tagClass === forge.asn1.Class.CONTEXT_SPECIFIC); if (!permitted) return []; return permitted.value .map((sub) => sub.value?.[0]) .filter((gn) => gn && gn.type === 2) .map((gn) => gn.value); } catch { return []; } } function sameSet(a, b) { return a.length === b.length && [...a].sort().join() === [...b].sort().join(); } // The permitted-TLD list for the root CA's nameConstraints extension. // Callers can pass the chain-derived list (fetchBcnrTlds) to keep the CA in // step with the on-chain root TLD certificate; otherwise falls back to the // shipped PERMITTED_TLDS. Doc reference: START-HERE.md Section 1. export function loadOrCreateRootCA(permittedTlds) { const tlds = Array.isArray(permittedTlds) && permittedTlds.length ? permittedTlds.map((t) => String(t).toLowerCase()) : PERMITTED_TLDS; mkdirSync(CA_DIR, { recursive: true }); if (existsSync(KEY_FILE) && existsSync(CERT_FILE)) { const cert = forge.pki.certificateFromPem(readFileSync(CERT_FILE, "utf8")); const permitted = readPermittedDns(cert); if (permitted === null) { console.error( "\n*** SECURITY: ca/root-ca.crt has NO name constraints ***\n" + " This root was generated before constraints were added. While it is in the\n" + " machine trust store, anyone who can read ca/root-ca.key can mint a trusted\n" + " certificate for ANY domain on this machine, not just BNS names.\n" + " Rotate it: setup\\uninstall.ps1 -> delete ca\\ -> setup\\install.ps1\n", ); } else if (!sameSet(permitted, tlds)) { console.error( `\n*** ca/root-ca.crt is constrained to [${permitted.join(", ")}] but the requested ` + `TLD list is [${tlds.join(", ")}].\n` + " Certificates for the newly-configured TLDs will be REJECTED by clients until the\n" + " root is regenerated and re-imported (uninstall -> delete ca\\ -> install).\n", ); } return { key: forge.pki.privateKeyFromPem(readFileSync(KEY_FILE, "utf8")), cert }; } const keys = forge.pki.rsa.generateKeyPair(2048); const attrs = [ { name: "commonName", value: "BNS Local Root CA" }, { name: "organizationName", value: "BNS (.bch on Bitcoin Cash)" }, ]; const cert = makeCert(keys.publicKey, attrs, attrs, [ { name: "basicConstraints", cA: true, critical: true }, { name: "keyUsage", keyCertSign: true, cRLSign: true, critical: true }, // Distinguishes THIS root from any prior "BNS Local Root CA" that a rotate // left orphaned in Firefox's NSS store. Without an SKI, Firefox's chain // builder picks a candidate root by Subject DN alone -- and every regenerated // root has the same DN -- so it can pick the wrong pubkey and reject valid // leaves with SEC_ERROR_BAD_SIGNATURE. { name: "subjectKeyIdentifier" }, nameConstraintsExt(tlds), ], 10); cert.sign(keys.privateKey, forge.md.sha256.create()); writeFileSync(KEY_FILE, forge.pki.privateKeyToPem(keys.privateKey)); writeFileSync(CERT_FILE, forge.pki.certificateToPem(cert)); console.error(`created new BNS Local Root CA in ca/ -- constrained to: ${tlds.join(", ")}`); return { key: keys.privateKey, cert }; } // One leaf keypair per process, reused for every name this daemon serves. // Generating RSA-2048 per hostname is what made on-the-fly issuance a denial-of- // service vector (see bnsd.js SNI handling). All leaves are served by this same // process anyway, so a shared key concedes nothing that was separated before. let leafKeys = null; function leafKeyPair() { if (!leafKeys) leafKeys = forge.pki.rsa.generateKeyPair(2048); return leafKeys; } // Issue a leaf certificate for a BNS hostname, signed by the local root. // Throws for hostnames outside the permitted TLDs: the root's own constraints // would make such a certificate useless anyway, and refusing here keeps callers // from burning work on names that can never validate. export function issueCert(hostname) { if (!isPermittedHost(hostname)) { throw new Error( `refusing to issue a certificate for "${hostname}" — not under a permitted TLD ` + `(${PERMITTED_TLDS.join(", ")}). See src/lib/tlds.js.`, ); } const ca = loadOrCreateRootCA(); const keys = leafKeyPair(); // Derive the CA's SubjectKeyIdentifier so the leaf can point at *this // specific* root, not any other "CN=BNS Local Root CA" a prior install // may have left orphaned in Firefox's NSS store. Uses node-forge's own // generator so this matches the value written into the root's SKI // extension byte-for-byte (RFC 3280 §4.2.1.2 method 1 over RSAPublicKey). const caSki = ca.cert.generateSubjectKeyIdentifier().getBytes(); const cert = makeCert( keys.publicKey, [{ name: "commonName", value: hostname }], ca.cert.subject.attributes, [ { name: "basicConstraints", cA: false, critical: true }, { name: "keyUsage", digitalSignature: true, keyEncipherment: true, critical: true }, { name: "extKeyUsage", serverAuth: true }, { name: "subjectAltName", altNames: [{ type: 2, value: hostname }] }, { name: "authorityKeyIdentifier", keyIdentifier: caSki }, ], 2, ); cert.sign(ca.key, forge.md.sha256.create()); return { keyPem: forge.pki.privateKeyToPem(keys.privateKey), certPem: forge.pki.certificateToPem(cert), fingerprint: certFingerprint(forge.pki.certificateToPem(cert)), }; } // SHA-256 over the DER encoding, hex — what a "tls" chain record pins. export function certFingerprint(certPem) { const der = forge.asn1 .toDer(forge.pki.certificateToAsn1(forge.pki.certificateFromPem(certPem))) .getBytes(); const md = forge.md.sha256.create(); md.update(der); return md.digest().toHex(); }