Files
qinglong/scripts/ql3-postgres-ca-overlap-audit.cjs

165 lines
4.6 KiB
JavaScript

#!/usr/bin/env node
'use strict';
const crypto = require('node:crypto');
const path = require('node:path');
const CONTRACT = 'qinglong/postgresql-ca-overlap@v1';
const MAX_CERTIFICATES = 16;
const FINGERPRINT_PATTERN = /^(?:[0-9A-F]{2}:){31}[0-9A-F]{2}$/;
function normalizeFingerprints(name, values) {
if (
!Array.isArray(values) ||
values.length < 1 ||
values.length > MAX_CERTIFICATES
) {
throw new Error(
`${name} CA set must contain 1-${MAX_CERTIFICATES} anchors`,
);
}
const normalized = values.map((value) => {
if (typeof value !== 'string' || !FINGERPRINT_PATTERN.test(value)) {
throw new Error(`${name} CA set contains a non-canonical fingerprint`);
}
return value.replaceAll(':', '').toLowerCase();
});
if (new Set(normalized).size !== normalized.length) {
throw new Error(`${name} CA set contains a duplicate anchor`);
}
return new Set(normalized);
}
function setDigest(values) {
return crypto
.createHash('sha256')
.update([...values].sort().join('\n'))
.digest('hex');
}
function difference(left, right) {
return new Set([...left].filter((value) => !right.has(value)));
}
function intersection(left, right) {
return new Set([...left].filter((value) => right.has(value)));
}
function equalSets(left, right) {
return (
left.size === right.size && [...left].every((value) => right.has(value))
);
}
function auditPostgresCaOverlapFingerprints({
oldFingerprints,
overlapFingerprints,
newFingerprints,
}) {
const oldAnchors = normalizeFingerprints('old', oldFingerprints);
const overlapAnchors = normalizeFingerprints('overlap', overlapFingerprints);
const newAnchors = normalizeFingerprints('new', newFingerprints);
const union = new Set([...oldAnchors, ...newAnchors]);
if (!equalSets(overlapAnchors, union)) {
throw new Error(
'overlap CA set must be the exact union of old and new anchors',
);
}
const introduced = difference(newAnchors, oldAnchors);
const retired = difference(oldAnchors, newAnchors);
if (introduced.size < 1 || retired.size < 1) {
throw new Error(
'CA rotation must introduce and retire at least one trust anchor',
);
}
const retained = intersection(oldAnchors, newAnchors);
return Object.freeze({
contract: CONTRACT,
old: Object.freeze({
count: oldAnchors.size,
digest: setDigest(oldAnchors),
}),
overlap: Object.freeze({
count: overlapAnchors.size,
digest: setDigest(overlapAnchors),
}),
new: Object.freeze({
count: newAnchors.size,
digest: setDigest(newAnchors),
}),
retainedCount: retained.size,
introducedCount: introduced.size,
retiredCount: retired.size,
});
}
function parseArguments(argv) {
const options = {};
for (const argument of argv) {
if (argument === '--') {
continue;
} else if (argument.startsWith('--old=')) {
options.oldFile = argument.slice('--old='.length);
} else if (argument.startsWith('--overlap=')) {
options.overlapFile = argument.slice('--overlap='.length);
} else if (argument.startsWith('--new=')) {
options.newFile = argument.slice('--new='.length);
} else {
throw new Error(`unsupported CA overlap audit argument: ${argument}`);
}
}
if (!options.oldFile || !options.overlapFile || !options.newFile) {
throw new Error(
'CA overlap audit requires --old, --overlap and --new bundle paths',
);
}
return options;
}
function auditPostgresCaOverlapFiles(options) {
const runtime = require(path.resolve(
__dirname,
'../packages/ql3-cluster-postgres/dist/entrypoints/runtime.js',
));
if (typeof runtime.inspectPostgresCertificateAuthorityFile !== 'function') {
throw new Error(
'cluster-postgres must be built before auditing CA overlap',
);
}
const inspect = runtime.inspectPostgresCertificateAuthorityFile;
return auditPostgresCaOverlapFingerprints({
oldFingerprints: inspect(options.oldFile).fingerprints256,
overlapFingerprints: inspect(options.overlapFile).fingerprints256,
newFingerprints: inspect(options.newFile).fingerprints256,
});
}
function main() {
const report = auditPostgresCaOverlapFiles(
parseArguments(process.argv.slice(2)),
);
process.stdout.write(`${JSON.stringify({ ok: true, ...report })}\n`);
}
if (require.main === module) {
try {
main();
} catch (error) {
process.stderr.write(
`${JSON.stringify({
ok: false,
error: error instanceof Error ? error.message : String(error),
})}\n`,
);
process.exitCode = 1;
}
}
module.exports = {
auditPostgresCaOverlapFiles,
auditPostgresCaOverlapFingerprints,
};