2026-07-29 13:54:34 +02:00
|
|
|
// 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();
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-01 02:06:48 +02:00
|
|
|
// 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;
|
2026-07-29 13:54:34 +02:00
|
|
|
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",
|
|
|
|
|
);
|
2026-08-01 02:06:48 +02:00
|
|
|
} else if (!sameSet(permitted, tlds)) {
|
2026-07-29 13:54:34 +02:00
|
|
|
console.error(
|
2026-08-01 02:06:48 +02:00
|
|
|
`\n*** ca/root-ca.crt is constrained to [${permitted.join(", ")}] but the requested ` +
|
|
|
|
|
`TLD list is [${tlds.join(", ")}].\n` +
|
2026-07-29 13:54:34 +02:00
|
|
|
" 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
|
2026-08-01 02:06:48 +02:00
|
|
|
// 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
|
2026-07-29 13:54:34 +02:00
|
|
|
// leaves with SEC_ERROR_BAD_SIGNATURE.
|
|
|
|
|
{ name: "subjectKeyIdentifier" },
|
2026-08-01 02:06:48 +02:00
|
|
|
nameConstraintsExt(tlds),
|
2026-07-29 13:54:34 +02:00
|
|
|
], 10);
|
|
|
|
|
cert.sign(keys.privateKey, forge.md.sha256.create());
|
|
|
|
|
writeFileSync(KEY_FILE, forge.pki.privateKeyToPem(keys.privateKey));
|
|
|
|
|
writeFileSync(CERT_FILE, forge.pki.certificateToPem(cert));
|
2026-08-01 02:06:48 +02:00
|
|
|
console.error(`created new BNS Local Root CA in ca/ -- constrained to: ${tlds.join(", ")}`);
|
2026-07-29 13:54:34 +02:00
|
|
|
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();
|
|
|
|
|
}
|