DC-147: named root CA with critical name constraints, leaf compliance assertions, install-page hardening, encrypted backup + tests
This commit is contained in:
4 files changed
+605
-8
No files matched your search
@@ -0,0 +1,272 @@
|
||||
/**
|
||||
* 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:<tld>), 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: <existing CN> }.
|
||||
*/
|
||||
|
||||
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:<privateTld>) 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: [".<tld>"] }).
|
||||
*
|
||||
* REFUSES if root.crt already exists in `dir` unless
|
||||
* { force: true, confirmName: <the existing CN exactly> } — 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: <existing CN> } 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 <tld>.
|
||||
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
|
||||
};
|
||||
Reference in new issue
Block a user