DC-147: named root CA with critical name constraints, leaf compliance assertions, install-page hardening, encrypted backup + tests

This commit is contained in:
Hermes committed 2026-10-03 21:43:11 -07:00
1 parent f7c5ff1d0b
commit d224a24566
4 files changed
+605 -8

No files matched your search

+65 -2
View File
@@ -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;
};