Files
dashcaddy/dashcaddy-api/__tests__/network-ca-hardening.test.js

236 lines
11 KiB
JavaScript

/**
* 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);
});
}