P7 Private Network (DC-140..DC-154 + judge fixes) #1
No files matched your search
+33
-6
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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