merge dc/DC-147 into p7-wave1
This commit is contained in:
4 files changed
+605
-8
No files matched your search
@@ -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;
|
||||
};
|
||||
Reference in new issue
Block a user