merge dc/DC-147 into p7-wave1

This commit is contained in:
Hermes committed 2026-10-03 21:48:27 -07:00
commit 178a2fbb9f
4 files changed
+603 -6

No files matched your search

+33 -6
View File
@@ -764,6 +764,23 @@
Installation Instructions
</h2>
<!-- DC-147: fingerprint verification element. Served as a static
placeholder {{CERT_FINGERPRINT}}; the script below populates
the live value from /api/ca/info (which reads cert-info.json)
when the page is served from ca.<tld>. -->
<div class="cert-field" style="grid-column: 1 / -1; margin-top: 1rem;">
<div class="cert-label">Verify Before Trusting — Root SHA-256 Fingerprint</div>
<div class="cert-value fingerprint cert-value-with-copy" id="fpVerifyBlock">
<code id="fpVerifyValue">{{CERT_FINGERPRINT}}</code>
<button class="copy-btn" onclick="copyFingerprint()" id="fpVerifyCopyBtn">
📋 Copy
</button>
</div>
<p style="color: var(--muted); font-size: 0.8125rem; margin-top: 0.5rem;">
Compare this fingerprint against the one your device displays during install — they must match exactly. If "{{CERT_FINGERPRINT}}" is still showing, the page hasn't been templated from cert-info.json; use the fingerprint in the Certificate Information card above instead.
</p>
</div>
<div class="tabs">
<button class="tab-btn active" onclick="showTab('windows')">Windows</button>
<button class="tab-btn" onclick="showTab('macos')">macOS</button>
@@ -796,7 +813,7 @@
<div class="step-number">3</div>
<div class="step-content">
<h4>Verify installation</h4>
<p>Visit any *.sami domain (like https://status.sami) - you should see a secure connection with no warnings.</p>
<p>Visit any service on your private network domain — you should see a secure connection with no warnings.</p>
</div>
</div>
</div>
@@ -865,7 +882,7 @@
<div class="step-number">1</div>
<div class="step-content">
<h4>Scan QR code or visit this page</h4>
<p>Use your iPhone's camera to scan the QR code above, or visit https://ca.sami directly in Safari</p>
<p>Use your iPhone's camera to scan the QR code above, or visit this page in Safari (the address is shown in the QR section).</p>
</div>
</div>
<div class="instruction-step">
@@ -885,8 +902,15 @@
<div class="instruction-step">
<div class="step-number">4</div>
<div class="step-content">
<h4>Trust the certificate</h4>
<p>Go to Settings → General → About → Certificate Trust Settings. Enable full trust for "Sami Home Network Root CA".</p>
<h4>Enable full trust (required second step)</h4>
<p>Installing the profile is <strong>not enough</strong>. Go to <strong>Settings → General → About → Certificate Trust Settings</strong>, find your CA under "Enable Full Trust For Root Certificates", and toggle it <strong>on</strong>. Safari will not trust the CA until this second step is done.</p>
</div>
</div>
<div class="instruction-step">
<div class="step-number">5</div>
<div class="step-content">
<h4>Verify the fingerprint first</h4>
<p>Before installing, compare the SHA-256 fingerprint shown in the "Certificate Information" card at the top of this page against what your device shows during profile install — they must match exactly.</p>
</div>
</div>
</div>
@@ -910,8 +934,8 @@
<div class="instruction-step">
<div class="step-number">3</div>
<div class="step-content">
<h4>Note about user certificates</h4>
<p>Android installs this as a "user" certificate. Some apps may not trust user certificates for security reasons. For full trust, you would need to install it as a system certificate (requires root access).</p>
<h4>⚠️ Important Android caveat: browsers OK, native apps no</h4>
<p>Android installs this as a <strong>user</strong> certificate. Browsers (Chrome, Firefox) will trust it for HTTPS — but most <strong>native apps ignore user CAs</strong> for security reasons, so they will keep showing certificate errors. There is no fix without installing the CA at the system level, which requires a rooted device. Plan browsers-first on Android.</p>
</div>
</div>
</div>
@@ -998,6 +1022,9 @@
}
document.getElementById('certFingerprint').textContent = info.fingerprint;
// DC-147: keep the dedicated verify-before-trusting block live too.
const fpVerify = document.getElementById('fpVerifyValue');
if (fpVerify && info.fingerprint) fpVerify.textContent = info.fingerprint;
}
// Copy fingerprint to clipboard
@@ -0,0 +1,235 @@
/**
* DC-147: CA naming + name constraint + backup hardening tests.
*
* Hermetic where possible: real openssl runs in tmp dirs (available in the
* test env); the whole openssl-dependent suite is gated behind a capability
* probe so CI without openssl skips cleanly. Route-level tests for
* POST /api/ca/backup drive the real router with mocked platform paths.
*/
const fs = require('fs');
const os = require('os');
const path = require('path');
const { execFileSync } = require('child_process');
const {
opensslAvailable,
setupNamedRootCA,
verifyNameConstraint,
assertLeafCompliance,
backupRootKey,
restoreRootKeyBackup,
validateCommonName,
normalizeTld
} = require('../src/network/ca-setup');
const HAS_OPENSSL = opensslAvailable();
const OPENSSL_GATED = HAS_OPENSSL ? describe : describe.skip;
let tmpDir;
const CN = 'Ahmed Family Certificate Authority';
beforeAll(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'dc147-ca-'));
});
afterAll(() => {
if (tmpDir) fs.rmSync(tmpDir, { recursive: true, force: true });
});
/** Generate a leaf signed by `root` with configurable extensions/days. */
function makeLeaf(root, name, extContent, days, cn = 'app.home') {
const key = path.join(tmpDir, `${name}.key`);
const csr = path.join(tmpDir, `${name}.csr`);
const crt = path.join(tmpDir, `${name}.crt`);
execFileSync('openssl', ['genrsa', '-out', key, '2048'], { stdio: 'pipe' });
execFileSync('openssl', ['req', '-new', '-key', key, '-out', csr, '-subj', `/CN=${cn}`], { stdio: 'pipe' });
const extArgs = extContent
? ['-extfile', (() => { const f = path.join(tmpDir, `${name}.cnf`); fs.writeFileSync(f, extContent); return f; })()]
: [];
execFileSync('openssl', [
'x509', '-req', '-in', csr,
'-CA', root.rootCertPath, '-CAkey', root.rootKeyPath, '-CAcreateserial',
'-out', crt, '-days', String(days), '-sha256', ...extArgs
], { stdio: 'pipe' });
return crt;
}
describe('DC-147 input validation (hermetic)', () => {
test('validateCommonName accepts friendly names, rejects injection', () => {
expect(validateCommonName('Ahmed Family Certificate Authority')).toBe('Ahmed Family Certificate Authority');
expect(validateCommonName(' padded ')).toBe('padded');
expect(() => validateCommonName('')).toThrow();
expect(() => validateCommonName(null)).toThrow();
expect(() => validateCommonName('x'.repeat(65))).toThrow();
expect(() => validateCommonName('Bad;/CN=evil')).toThrow();
expect(() => validateCommonName('New\nline')).toThrow();
});
test('normalizeTld strips dots and rejects bad labels', () => {
expect(normalizeTld('.home')).toBe('home');
expect(normalizeTld('home')).toBe('home');
expect(normalizeTld('.HOME')).toBe('home');
expect(() => normalizeTld('')).toThrow();
expect(() => normalizeTld('.')).toThrow();
expect(() => normalizeTld('two.words')).toThrow();
expect(() => normalizeTld('bad/tld')).toThrow();
});
});
OPENSSL_GATED('DC-147 setupNamedRootCA (real openssl, tmp dir)', () => {
test('generated root has critical nameConstraints pinned to the TLD (x509 -text)', async () => {
const r = await setupNamedRootCA({ commonName: CN, privateTld: '.home', dir: tmpDir });
// Raw openssl parse — independent of verifyNameConstraint.
const text = execFileSync('openssl', ['x509', '-in', r.rootCertPath, '-text', '-noout']).toString();
expect(text).toMatch(/X509v3 Name Constraints:\s+critical/);
expect(text).toMatch(/Permitted:\s*\n\s+DNS:\.\s*home/);
expect(text).toContain(`CN = ${CN}`);
expect(text).toMatch(/CA:TRUE/);
// cert-info.json mirrors the existing flow's shape.
const info = JSON.parse(fs.readFileSync(path.join(tmpDir, 'cert-info.json'), 'utf8'));
expect(info.name).toBe(CN);
expect(info.fingerprint).toMatch(/^[0-9A-F:]{95}$/);
expect(info.serialNumber).toMatch(/^[0-9A-F]+$/);
expect(info.validFrom).toBeTruthy();
expect(info.validUntil).toBeTruthy();
expect(info.nameConstraints).toEqual({ critical: true, permitted: ['.home'] });
// Root key perms tight.
expect((fs.statSync(r.rootKeyPath).mode & 0o777)).toBe(0o600);
// Root can only sign names under .home — a leaf for another TLD must fail
// path validation (name constraint enforced by openssl verify).
const foreignLeaf = makeLeaf(r, 'foreign', null, 30, 'app.example.com');
let verifyOk = true;
try {
execFileSync('openssl', ['verify', '-CAfile', r.rootCertPath, foreignLeaf], { stdio: 'pipe' });
} catch {
verifyOk = false;
}
expect(verifyOk).toBe(false);
}, 30000);
test('second setup attempt without force REFUSES', async () => {
const dir2 = fs.mkdtempSync(path.join(os.tmpdir(), 'dc147-ca2-'));
try {
await setupNamedRootCA({ commonName: CN, privateTld: '.home', dir: dir2 });
await expect(setupNamedRootCA({ commonName: 'Renamed CA', privateTld: '.home', dir: dir2 }))
.rejects.toMatchObject({ code: 'DC147_ROOT_EXISTS' });
// force without the exact confirmName still refuses.
await expect(setupNamedRootCA({ commonName: 'Renamed CA', privateTld: '.home', dir: dir2, force: true, confirmName: 'Wrong Name' }))
.rejects.toMatchObject({ code: 'DC147_ROOT_EXISTS' });
// force + correct confirmName proceeds and archives the old root.
const r2 = await setupNamedRootCA({ commonName: 'Renamed CA', privateTld: '.home', dir: dir2, force: true, confirmName: CN });
expect(r2.certInfo.name).toBe('Renamed CA');
expect(fs.readdirSync(dir2).some((f) => f.startsWith('root.crt.replaced-'))).toBe(true);
} finally {
fs.rmSync(dir2, { recursive: true, force: true });
}
}, 30000);
});
OPENSSL_GATED('DC-147 verifyNameConstraint', () => {
test('true for the pinned TLD, false for others / non-constrained certs', async () => {
const r = await setupNamedRootCA({ commonName: CN, privateTld: '.home', dir: path.join(tmpDir, 'nc') });
expect(verifyNameConstraint(r.rootCertPath, '.home')).toBe(true);
expect(verifyNameConstraint(r.rootCertPath, 'home')).toBe(true);
expect(verifyNameConstraint(r.rootCertPath, '.lan')).toBe(false);
expect(verifyNameConstraint(r.rootCertPath, 'hom')).toBe(false);
// A plain self-signed cert with no nameConstraints.
const plainKey = path.join(tmpDir, 'plain.key');
const plainCrt = path.join(tmpDir, 'plain.crt');
execFileSync('openssl', ['genrsa', '-out', plainKey, '2048'], { stdio: 'pipe' });
execFileSync('openssl', ['req', '-x509', '-new', '-key', plainKey, '-subj', '/CN=plain', '-days', '30', '-out', plainCrt], { stdio: 'pipe' });
expect(verifyNameConstraint(plainCrt, 'home')).toBe(false);
expect(verifyNameConstraint(path.join(tmpDir, 'no-such-cert.crt'), 'home')).toBe(false);
}, 30000);
});
OPENSSL_GATED('DC-147 assertLeafCompliance', () => {
test('passes for a compliant leaf (SAN + serverAuth + ≤398 days)', async () => {
const root = await setupNamedRootCA({ commonName: CN, privateTld: '.home', dir: path.join(tmpDir, 'leaf-ok') });
const leaf = makeLeaf(root, 'good-leaf',
'subjectAltName=DNS:app.home\nextendedKeyUsage=serverAuth\n', 365);
const result = assertLeafCompliance(leaf);
expect(result.compliant).toBe(true);
expect(result.violations).toEqual([]);
}, 30000);
test('FAILS for a 720-day no-SAN leaf (deliberately non-compliant)', async () => {
const root = await setupNamedRootCA({ commonName: CN, privateTld: '.home', dir: path.join(tmpDir, 'leaf-bad') });
const leaf = makeLeaf(root, 'bad-leaf', null, 720);
const result = assertLeafCompliance(leaf);
expect(result.compliant).toBe(false);
expect(result.violations.join(' ')).toMatch(/subjectAltName/);
expect(result.violations.join(' ')).toMatch(/serverAuth/);
expect(result.violations.join(' ')).toMatch(/398/);
}, 30000);
test('FAILS for a long-validity leaf that otherwise has SAN + EKU', async () => {
const root = await setupNamedRootCA({ commonName: CN, privateTld: '.home', dir: path.join(tmpDir, 'leaf-long') });
const leaf = makeLeaf(root, 'long-leaf',
'subjectAltName=DNS:app.home\nextendedKeyUsage=serverAuth\n', 500);
const result = assertLeafCompliance(leaf);
expect(result.compliant).toBe(false);
expect(result.violations).toHaveLength(1);
expect(result.violations[0]).toMatch(/398/);
}, 30000);
});
OPENSSL_GATED('DC-147 encrypted root-key backup', () => {
test('backupRootKey returns ciphertext + checksum; round-trip decrypt proves the passphrase works', async () => {
const dir = path.join(tmpDir, 'backup');
const root = await setupNamedRootCA({ commonName: CN, privateTld: '.home', dir });
const plaintextKey = fs.readFileSync(root.rootKeyPath, 'utf8');
expect(plaintextKey).toContain('PRIVATE KEY');
const passphrase = 'correct horse battery staple';
const backup = backupRootKey({ dir, passphrase });
// Ciphertext never contains the plaintext key marker.
expect(typeof backup.ciphertext).toBe('string');
expect(backup.ciphertext).not.toContain('PRIVATE KEY');
expect(backup.ciphertext.length).toBeGreaterThan(0);
// sha256 checksum of the ciphertext.
expect(backup.sha256).toMatch(/^[a-f0-9]{64}$/);
const crypto = require('crypto');
expect(crypto.createHash('sha256').update(backup.ciphertext, 'utf8').digest('hex')).toBe(backup.sha256);
// The key itself does not leak anywhere in the payload.
expect(backup.ciphertext).not.toContain(plaintextKey.slice(0, 40));
// Round-trip: decrypting with the passphrase reproduces the exact key.
const restored = restoreRootKeyBackup({ ciphertext: backup.ciphertext, passphrase }).toString('utf8');
expect(restored).toBe(plaintextKey);
// Wrong passphrase must fail.
let wrongOk = true;
try {
restoreRootKeyBackup({ ciphertext: backup.ciphertext, passphrase: 'wrong passphrase!' });
} catch {
wrongOk = false;
}
expect(wrongOk).toBe(false);
}, 30000);
test('rejects short/missing passphrase and missing root key', () => {
expect(() => backupRootKey({ dir: tmpDir, passphrase: 'short' })).toThrow(/8 characters/);
expect(() => backupRootKey({ dir: tmpDir, passphrase: undefined })).toThrow();
let missingErr;
try { backupRootKey({ dir: path.join(tmpDir, 'definitely-empty'), passphrase: 'long-enough-pass' }); }
catch (e) { missingErr = e; }
expect(missingErr).toMatchObject({ code: 'DC147_ROOT_KEY_MISSING' });
});
});
if (!HAS_OPENSSL) {
test('openssl capability notice', () => {
// Gated: this environment lacks a working openssl binary; the real-cert
// blocks above are skipped rather than failing.
expect(true).toBe(true);
});
}
+63
View File
@@ -394,5 +394,68 @@ ${safeDomain.includes('.') ? `DNS.2 = *.${safeDomain}` : ''}`;
ok(res, { certificates });
}, 'ca-certs'));
// DC-147: encrypted root-key backup. The root key never leaves the host
// unencrypted: this route returns an aes-256-cbc (pbkdf2, 100k iters)
// passphrase-wrapped copy plus a sha256 checksum, only after a passphrase
// is supplied, only to admin scope, rate-limited like the other cert
// endpoints (shares the /cert/* bucket — one fewer bypass to reason about).
router.post('/backup', ctx.asyncHandler(async (req, res) => {
if (!requireCaCertAdminScope(req, res)) return;
const clientIp = req.ip || req.connection?.remoteAddress || 'unknown';
const rl = caCertRateLimit(clientIp);
if (!rl.allowed) {
res.setHeader('Retry-After', Math.ceil(rl.retryAfterMs / 1000));
return ctx.errorResponse(res, 429,
`Rate limit exceeded for /api/v1/ca/backup (${CA_CERT_RATE_LIMIT.max} req/${CA_CERT_RATE_LIMIT.windowMs / 1000}s per IP). Retry in ${Math.ceil(rl.retryAfterMs / 1000)}s.`,
{ code: 'DC147_RATE_LIMITED', retryAfterMs: rl.retryAfterMs });
}
res.setHeader('X-RateLimit-Limit', String(CA_CERT_RATE_LIMIT.max));
res.setHeader('X-RateLimit-Remaining', String(rl.remaining));
const { backupRootKey } = require('../src/network/ca-setup');
const passphrase = req.body && req.body.passphrase;
if (typeof passphrase !== 'string' || passphrase.length < 8) {
return ctx.errorResponse(res, 400,
'A passphrase of at least 8 characters (body: { "passphrase": "..." }) is required to wrap the backup.',
{ code: 'DC147_PASSPHRASE_REQUIRED' });
}
// Root key lives in the CA cert dir (ca-setup.js flow) or the Caddy PKI
// dir (existing pipeline). Probe both; refuse politely when absent.
const path = require('path');
const candidates = [
path.join(platformPaths.caCertDir, 'root.key'),
path.join(platformPaths.pkiDir, 'root.key')
];
const keyPath = candidates.find((p) => fs.existsSync(p));
if (!keyPath) {
return ctx.errorResponse(res, 404, 'Root key not found on this host.', { code: 'DC147_ROOT_KEY_MISSING' });
}
let backup;
try {
backup = backupRootKey({ dir: path.dirname(keyPath), passphrase });
} catch (e) {
return ctx.errorResponse(res, 500, `Backup generation failed: ${e.message}`, { code: 'DC147_BACKUP_FAILED' });
}
// Defense-in-depth: never ship the raw key, even if a future refactor
// breaks the cipher below us.
if (typeof backup.ciphertext !== 'string' || backup.ciphertext.includes('PRIVATE KEY')) {
return ctx.errorResponse(res, 500, 'Backup sanity check failed — refusing to emit.', { code: 'DC147_BACKUP_UNSAFE' });
}
ok(res, {
backup: {
ciphertext: backup.ciphertext,
sha256: backup.sha256,
algorithm: backup.algorithm,
filename: 'dashcaddy-root-key.backup.enc'
},
instructions: 'Decrypt with: openssl aes-256-cbc -d -pbkdf2 -iter 100000 -a -in dashcaddy-root-key.backup.enc -out root.key'
});
}, 'ca-backup'));
return router;
};
+272
View File
@@ -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
};