diff --git a/ca/index.html b/ca/index.html index da70577..5bfb6f0 100644 --- a/ca/index.html +++ b/ca/index.html @@ -764,6 +764,23 @@ Installation Instructions + +
+
Verify Before Trusting โ€” Root SHA-256 Fingerprint
+
+ {{CERT_FINGERPRINT}} + +
+

+ 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. +

+
+
@@ -796,7 +813,7 @@
3

Verify installation

-

Visit any *.sami domain (like https://status.sami) - you should see a secure connection with no warnings.

+

Visit any service on your private network domain โ€” you should see a secure connection with no warnings.

@@ -865,7 +882,7 @@
1

Scan QR code or visit this page

-

Use your iPhone's camera to scan the QR code above, or visit https://ca.sami directly in Safari

+

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).

@@ -885,8 +902,15 @@
4
-

Trust the certificate

-

Go to Settings โ†’ General โ†’ About โ†’ Certificate Trust Settings. Enable full trust for "Sami Home Network Root CA".

+

Enable full trust (required second step)

+

Installing the profile is not enough. Go to Settings โ†’ General โ†’ About โ†’ Certificate Trust Settings, find your CA under "Enable Full Trust For Root Certificates", and toggle it on. Safari will not trust the CA until this second step is done.

+
+
+
+
5
+
+

Verify the fingerprint first

+

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.

@@ -910,8 +934,8 @@
3
-

Note about user certificates

-

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).

+

โš ๏ธ Important Android caveat: browsers OK, native apps no

+

Android installs this as a user certificate. Browsers (Chrome, Firefox) will trust it for HTTPS โ€” but most native apps ignore user CAs 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.

@@ -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 diff --git a/dashcaddy-api/__tests__/network-ca-hardening.test.js b/dashcaddy-api/__tests__/network-ca-hardening.test.js new file mode 100644 index 0000000..1ae1685 --- /dev/null +++ b/dashcaddy-api/__tests__/network-ca-hardening.test.js @@ -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); + }); +} diff --git a/dashcaddy-api/routes/ca.js b/dashcaddy-api/routes/ca.js index 2c165bc..0e3e573 100644 --- a/dashcaddy-api/routes/ca.js +++ b/dashcaddy-api/routes/ca.js @@ -392,7 +392,70 @@ ${safeDomain.includes('.') ? `DNS.2 = *.${safeDomain}` : ''}`; }))).filter(Boolean); ok(res, { certificates }); - }, 'ca-certs')); + }, 'ca-certs')); - return router; + // 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; }; diff --git a/dashcaddy-api/src/network/ca-setup.js b/dashcaddy-api/src/network/ca-setup.js new file mode 100644 index 0000000..1da623b --- /dev/null +++ b/dashcaddy-api/src/network/ca-setup.js @@ -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:), 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 +};