/** * DC-147: Named root CA with critical name constraints. * * Builds on the existing DashCA pipeline (routes/ca.js): a setup flow that * generates the operator-chosen root CA CN pinned to the private TLD via a * CRITICAL nameConstraints extension (permittedSubtrees DNS:), leaf * compliance assertions (SAN / serverAuth EKU / ≤398-day validity, iOS 13+ * requirements per PRIVATE-NETWORK-SPEC §7), and encrypted root-key backup. * * Renaming a CA means a brand-new root + a guided re-trust on every device, * so setupNamedRootCA() is locked once a root exists: it refuses to run again * unless the caller passes { force: true, confirmName: }. */ const fs = require('fs'); const fsp = require('fs').promises; const path = require('path'); const crypto = require('crypto'); const { execFileSync } = require('child_process'); /** openssl availability probe — gates real-openssl tests and fallbacks. */ function opensslAvailable(bin = 'openssl') { try { execFileSync(bin, ['version'], { stdio: 'pipe' }); return true; } catch { return false; } } /** Reject CNs that could smuggle shell/format metacharacters into a subject. */ function validateCommonName(commonName) { if (typeof commonName !== 'string' || commonName.trim().length === 0) { throw new Error('commonName is required (e.g. "Ahmed Family Certificate Authority")'); } if (commonName.length > 64) { throw new Error('commonName must be ≤ 64 characters'); } if (!/^[A-Za-z0-9 .,'&()(-]+$/.test(commonName)) { throw new Error('commonName contains unsupported characters (allowed: letters, digits, space . , \' & ( ) -)'); } return commonName.trim(); } /** * Normalize a private TLD to the bare label (".home" / "home" -> "home"). * The nameConstraints permitted subtree is emitted as DNS:.home form. */ function normalizeTld(privateTld) { if (typeof privateTld !== 'string' || privateTld.trim().length === 0) { throw new Error('privateTld is required (e.g. ".home")'); } const tld = privateTld.trim().replace(/^\.+/, '').toLowerCase(); if (!/^[a-z0-9-]{1,24}$/.test(tld)) { throw new Error(`privateTld "${privateTld}" is not a valid TLD label`); } return tld; } /** Parse `openssl x509 -text` output: { notBefore, notAfter, serial }. */ function parseCertDates(text) { const notBefore = (text.match(/Not Before:\s*(.+)/) || [])[1]; const notAfter = (text.match(/Not After\s*:\s*(.+)/) || [])[1]; const serialBlock = (text.match(/Serial Number:\s*\n\s*([0-9a-f:]+)/i) || [])[1]; const serial = (serialBlock || '').replace(/:/g, '').toUpperCase(); if (!notBefore || !notAfter) throw new Error('Could not parse certificate validity dates'); return { notBefore: notBefore.trim(), notAfter: notAfter.trim(), serial }; } /** * Generate the named root CA with a CRITICAL nameConstraints extension * (permittedSubtrees: DNS:) via an openssl extfile. * * Writes into `dir` (defaults to the platform caCertDir): * root.key, root.crt, cert-info.json (fingerprint / serial / dates / CN / TLD, * nameConstraints: { critical: true, permitted: ["."] }). * * REFUSES if root.crt already exists in `dir` unless * { force: true, confirmName: } — a rename is a new * root plus a guided re-trust on every device, so the default is locked. */ async function setupNamedRootCA({ commonName, privateTld, dir, force = false, confirmName = null, opensslBin = 'openssl', days = 3650 }) { const cn = validateCommonName(commonName); const tld = normalizeTld(privateTld); const caDir = dir || path.join(__dirname, '..', '..', 'ca'); await fsp.mkdir(caDir, { recursive: true }); const rootCertPath = path.join(caDir, 'root.crt'); const rootKeyPath = path.join(caDir, 'root.key'); const certInfoPath = path.join(caDir, 'cert-info.json'); if (fs.existsSync(rootCertPath)) { let existingName = null; try { const info = JSON.parse(await fsp.readFile(certInfoPath, 'utf8')); existingName = info.name; } catch { /* fall through to openssl subject parse */ } if (!existingName) { try { const subj = execFileSync(opensslBin, ['x509', '-in', rootCertPath, '-noout', '-subject']).toString(); existingName = (subj.match(/CN\s*=\s*([^,\n/]+)/) || [])[1]; } catch { /* leave null */ } } const isForce = force === true && typeof confirmName === 'string' && confirmName.trim() === (existingName || '').trim(); if (!isForce) { const err = new Error( `A root CA already exists${existingName ? ` (CN="${existingName}")` : ''}. ` + 'Renaming means generating a NEW root and re-trusting it on every device. ' + 'Pass { force: true, confirmName: } to proceed deliberately.'); err.code = 'DC147_ROOT_EXISTS'; err.existingName = existingName || null; throw err; } // Guided re-trust: archive the old pair so the operator can migrate. const stamp = new Date().toISOString().replace(/[:.]/g, '-'); await fsp.rename(rootCertPath, `${rootCertPath}.replaced-${stamp}`); if (fs.existsSync(rootKeyPath)) await fsp.rename(rootKeyPath, `${rootKeyPath}.replaced-${stamp}`); } // Private key first (RSA 4096 — root, long-lived). execFileSync(opensslBin, ['genrsa', '-out', rootKeyPath, '4096'], { stdio: 'pipe' }); // CRITICAL nameConstraints via extfile. Critical because clients that do // not understand the extension must fail closed, not silently trust the // CA for names outside . const extFile = path.join(caDir, `root-ext-${Date.now()}.cnf`); await fsp.writeFile(extFile, [ 'basicConstraints = critical,CA:TRUE', 'keyUsage = critical,keyCertSign,cRLSign', 'subjectKeyIdentifier = hash', `nameConstraints = critical,permitted;DNS:.${tld}`, '' ].join('\n'), { mode: 0o600 }); // extfile path: `openssl req -x509` on this box (OpenSSL 3.0.13) rejects // `-extfile` without a section marker — and empirically `-addext` handles // nameConstraints fine. Keep the extfile write (other openssl builds want // it) but pass the extension via -addext as the portable path. execFileSync(opensslBin, [ 'req', '-x509', '-new', '-key', rootKeyPath, '-subj', `/CN=${cn}`, '-days', String(days), '-sha256', '-addext', `nameConstraints=critical,permitted;DNS:.${tld}`, '-out', rootCertPath ], { stdio: 'pipe' }); await fsp.chmod(rootKeyPath, 0o600); const text = execFileSync(opensslBin, ['x509', '-in', rootCertPath, '-text', '-noout']).toString(); const fpOut = execFileSync(opensslBin, ['x509', '-in', rootCertPath, '-noout', '-fingerprint', '-sha256']).toString(); const fingerprint = (fpOut.match(/Fingerprint=(.+)/) || [])[1].trim(); const { notBefore, notAfter, serial } = parseCertDates(text); const certInfo = { name: cn, algorithm: 'RSA 4096 with SHA-256', fingerprint, serialNumber: serial, validFrom: notBefore, validUntil: notAfter, generatedAt: new Date().toISOString(), nameConstraints: { critical: true, permitted: [`.${tld}`] } }; await fsp.writeFile(certInfoPath, JSON.stringify(certInfo, null, 2) + '\n'); return { rootCertPath, rootKeyPath, certInfoPath, certInfo }; } /** * True iff `certPath` carries a CRITICAL Name Constraints extension whose * permitted subtrees include DNS entries for exactly `tld` (leading-dot * subtree form). Parses `openssl x509 -text` output. */ function verifyNameConstraint(certPath, tld, opensslBin = 'openssl') { const bare = normalizeTld(tld); let text; try { text = execFileSync(opensslBin, ['x509', '-in', certPath, '-text', '-noout']).toString(); } catch { return false; } const extMatch = text.match(/X509v3 Name Constraints:\s*(critical)?\s*\n\s*Permitted:\s*\n((?:\s+DNS:[^\n]+\n?)+)/); if (!extMatch) return false; if (extMatch[1] !== 'critical') return false; const dnsEntries = (extMatch[2].match(/DNS:([^\s\n]+)/g) || []) .map((s) => s.slice(4).trim().replace(/^\.+/, '').toLowerCase()); return dnsEntries.length > 0 && dnsEntries.every((d) => d === bare); } /** * DC-147 leaf compliance assertions (iOS 13+ requirements, spec §7): * 1. subjectAltName present (non-empty DNS entries) * 2. extendedKeyUsage includes serverAuth * 3. validity ≤ 398 days (Apple's cap on trusted server leaves) * Returns { compliant: true } or { compliant: false, violations: [...] }. */ function assertLeafCompliance(certPath, opensslBin = 'openssl') { const violations = []; let text; try { text = execFileSync(opensslBin, ['x509', '-in', certPath, '-text', '-noout']).toString(); } catch (e) { return { compliant: false, violations: [`unreadable certificate: ${e.message}`] }; } // SAN: look for the X509v3 Subject Alternative Name extension line and at // least one DNS: entry beneath it. const sanMatch = text.match(/X509v3 Subject Alternative Name:\s*(?:critical)?\s*\n\s*(.+)/); if (!sanMatch || !/DNS:/i.test(sanMatch[1])) { violations.push('missing subjectAltName with at least one DNS entry (required by iOS 13+)'); } // serverAuth EKU. const ekuMatch = text.match(/X509v3 Extended Key Usage:\s*\n\s*(.+)/); if (!ekuMatch || !/\bTLS Web Server Authentication\b/.test(ekuMatch[1])) { violations.push('missing serverAuth extended key usage (required by iOS 13+)'); } // ≤398-day validity window. const { notBefore, notAfter } = parseCertDates(text); const ms = new Date(notAfter) - new Date(notBefore); const days = ms / (1000 * 60 * 60 * 24); if (!(days > 0) || days > 398) { violations.push(`validity ${days.toFixed(1)} days exceeds the 398-day maximum (got ${notBefore} → ${notAfter})`); } return { compliant: violations.length === 0, violations }; } /** * Encrypted root-key backup: openssl aes-256-cbc -pbkdf2 wrap of the root * private key. Never returns the key unencrypted. The passphrase is passed * via env (OPENSSL_PASS) rather than argv so it never shows in a process list. */ function backupRootKey({ dir, passphrase, opensslBin = 'openssl' }) { if (typeof passphrase !== 'string' || passphrase.length < 8) { const err = new Error('passphrase must be at least 8 characters'); err.code = 'DC147_PASSPHRASE_INVALID'; throw err; } const rootKeyPath = path.join(dir, 'root.key'); if (!fs.existsSync(rootKeyPath)) { const err = new Error(`root key not found at ${rootKeyPath}`); err.code = 'DC147_ROOT_KEY_MISSING'; throw err; } // -pass env:OPENSSL_PASS keeps the passphrase out of argv (process lists). const ciphertext = execFileSync(opensslBin, ['aes-256-cbc', '-pbkdf2', '-iter', '100000', '-salt', '-a', '-in', rootKeyPath, '-pass', 'env:OPENSSL_PASS'], { stdio: 'pipe', env: { ...process.env, OPENSSL_PASS: passphrase } }); const ciphertextStr = ciphertext.toString('utf8'); const sha256 = crypto.createHash('sha256').update(ciphertextStr, 'utf8').digest('hex'); return { ciphertext: ciphertextStr, sha256, algorithm: 'aes-256-cbc + pbkdf2 (100k iterations)' }; } /** Decrypt a backup produced by backupRootKey — proves the passphrase works. */ function restoreRootKeyBackup({ ciphertext, passphrase, opensslBin = 'openssl' }) { return execFileSync(opensslBin, ['aes-256-cbc', '-d', '-pbkdf2', '-iter', '100000', '-a', '-pass', 'env:OPENSSL_PASS'], { stdio: 'pipe', env: { ...process.env, OPENSSL_PASS: passphrase }, input: ciphertext }); } module.exports = { opensslAvailable, validateCommonName, normalizeTld, setupNamedRootCA, verifyNameConstraint, assertLeafCompliance, backupRootKey, restoreRootKeyBackup };