Files
qinglong/scripts/ql3-worker-credential-management-ca-rollover-evidence.cjs
T

1841 lines
56 KiB
JavaScript

#!/usr/bin/env node
'use strict';
const {
createHash,
createPrivateKey,
X509Certificate,
} = require('node:crypto');
const fs = require('node:fs');
const net = require('node:net');
const path = require('node:path');
const { createRequire } = require('node:module');
const { spawnSync } = require('node:child_process');
const { TextDecoder } = require('node:util');
const {
assertionIdentity,
validateWorkerCredentialManagementLiveCeremony,
} = require('./ql3-worker-credential-management-live-ceremony.cjs');
const {
validateWorkerCredentialManagementDurableAuditEvidence,
} = require('./ql3-worker-credential-management-durable-audit-evidence.cjs');
const {
collectKubernetesSnapshot,
REVIEWED_AUTHORITY,
} = require('./ql3-worker-credential-management-pki-rotation-evidence.cjs');
const FIXTURE =
'qinglong/worker-credential-management-ca-rollover-evidence@v1';
const STATE_FIXTURE =
'qinglong/worker-credential-management-ca-rollover-state@v1';
const CEREMONY_FIXTURE =
'qinglong/worker-credential-management-live-ceremony@v1';
const DURABLE_FIXTURE =
'qinglong/worker-credential-management-durable-audit-evidence@v1';
const CA_ANNOTATION =
'qinglong.io/worker-credential-management-client-ca-sha256';
const CRL_ANNOTATION =
'qinglong.io/worker-credential-management-client-crl-sha256';
const TYPE = 'ql3-worker-credential-management+jwt';
const PURPOSE = 'worker-credential-management';
const AUDIENCE = 'qinglong3-worker-credential-management';
const NAMESPACE = 'qinglong3-system';
const DEPLOYMENT = 'ql3-worker-credential-management';
const MAX_FILE_BYTES = 1024 * 1024;
const SHA256_PATTERN = /^sha256:[a-f0-9]{64}$/;
const CONTROL_PATTERN = /[\u0000-\u001f\u007f]/;
const BANNED_KEYS = new Set([
'assertion',
'authorization',
'bearer',
'connectionstring',
'dsn',
'kubeconfig',
'password',
'privatekey',
'secret',
'tlskey',
'token',
]);
const ROOT = path.resolve(__dirname, '..');
const clusterRequire = createRequire(
path.join(ROOT, 'packages/ql3-cluster-admin/package.json'),
);
class WorkerCredentialManagementCaRolloverEvidenceError extends Error {
constructor(message) {
super(`Worker management CA rollover evidence failed: ${message}`);
this.name = 'WorkerCredentialManagementCaRolloverEvidenceError';
}
}
function fail(message) {
throw new WorkerCredentialManagementCaRolloverEvidenceError(message);
}
function exactObject(value, keys, label) {
if (
!value ||
typeof value !== 'object' ||
Array.isArray(value) ||
JSON.stringify(Object.keys(value).sort()) !==
JSON.stringify([...keys].sort())
) {
fail(`${label} shape is invalid`);
}
return value;
}
function exactKeys(value, keys) {
return (
value !== null &&
typeof value === 'object' &&
!Array.isArray(value) &&
JSON.stringify(Object.keys(value).sort()) ===
JSON.stringify([...keys].sort())
);
}
function digest(domain, value) {
return `sha256:${createHash('sha256')
.update(domain)
.update('\0')
.update(String(value))
.digest('hex')}`;
}
function rawDigest(value) {
return `sha256:${createHash('sha256').update(value).digest('hex')}`;
}
function canonicalFile(filePath, label, options = {}) {
if (
typeof filePath !== 'string' ||
!path.isAbsolute(filePath) ||
filePath.length > 4096 ||
CONTROL_PATTERN.test(filePath)
) {
fail(`${label} path is invalid`);
}
let stat;
try {
stat = fs.lstatSync(filePath);
} catch {
fail(`${label} is unavailable`);
}
const privateFile = options.private !== false;
const uid = typeof process.getuid === 'function' ? process.getuid() : null;
if (
!stat.isFile() ||
stat.isSymbolicLink() ||
stat.size < 1 ||
stat.size > (options.maximum ?? MAX_FILE_BYTES) ||
fs.realpathSync(filePath) !== filePath ||
(privateFile && (uid === null || stat.uid !== uid)) ||
(stat.mode & (privateFile ? 0o077 : 0o022)) !== 0
) {
fail(`${label} must be one canonical bounded file`);
}
return filePath;
}
function readBuffer(filePath, label, options = {}) {
canonicalFile(filePath, label, options);
const before = fs.lstatSync(filePath);
let descriptor = -1;
let bytes;
try {
descriptor = fs.openSync(
filePath,
fs.constants.O_RDONLY |
(fs.constants.O_CLOEXEC ?? 0) |
(fs.constants.O_NOFOLLOW ?? 0),
);
const opened = fs.fstatSync(descriptor);
if (
opened.dev !== before.dev ||
opened.ino !== before.ino ||
opened.uid !== before.uid ||
opened.mode !== before.mode ||
opened.size !== before.size
) {
fail(`${label} changed before it was opened`);
}
bytes = Buffer.alloc(opened.size);
let offset = 0;
while (offset < bytes.length) {
const count = fs.readSync(
descriptor,
bytes,
offset,
bytes.length - offset,
offset,
);
if (count < 1) fail(`${label} could not be read completely`);
offset += count;
}
const after = fs.fstatSync(descriptor);
if (
after.dev !== opened.dev ||
after.ino !== opened.ino ||
after.uid !== opened.uid ||
after.mode !== opened.mode ||
after.size !== opened.size
) {
fail(`${label} changed while it was read`);
}
return bytes;
} catch (error) {
bytes?.fill(0);
if (error instanceof WorkerCredentialManagementCaRolloverEvidenceError) {
throw error;
}
fail(`${label} could not be read safely`);
} finally {
if (descriptor >= 0) fs.closeSync(descriptor);
}
}
function jsonFromBytes(bytes, label) {
try {
return JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(bytes));
} catch {
fail(`${label} must contain UTF-8 JSON`);
}
}
function readJson(filePath, label) {
const bytes = readBuffer(filePath, label);
try {
return Object.freeze({ bytes, value: jsonFromBytes(bytes, label) });
} catch (error) {
bytes.fill(0);
throw error;
}
}
function unusedOutput(filePath) {
if (
typeof filePath !== 'string' ||
!path.isAbsolute(filePath) ||
fs.existsSync(filePath) ||
fs.realpathSync(path.dirname(filePath)) !== path.dirname(filePath)
) {
fail('output path must be unused in one canonical directory');
}
}
function writeNoReplace(filePath, value) {
unusedOutput(filePath);
const descriptor = fs.openSync(
filePath,
fs.constants.O_WRONLY |
fs.constants.O_CREAT |
fs.constants.O_EXCL |
(fs.constants.O_CLOEXEC ?? 0) |
(fs.constants.O_NOFOLLOW ?? 0),
0o600,
);
try {
fs.writeFileSync(descriptor, `${JSON.stringify(value, null, 2)}\n`);
fs.fsyncSync(descriptor);
} finally {
fs.closeSync(descriptor);
}
}
function externalIssuer(value) {
if (typeof value !== 'string' || value.length > 512) return false;
let parsed;
try {
parsed = new URL(value);
} catch {
return false;
}
const hostname = parsed.hostname.toLowerCase();
return (
parsed.protocol === 'https:' &&
parsed.username === '' &&
parsed.password === '' &&
parsed.search === '' &&
parsed.hash === '' &&
parsed.toString() === value &&
net.isIP(hostname) === 0 &&
hostname !== 'localhost' &&
!hostname.endsWith('.localhost') &&
!hostname.endsWith('.local') &&
!hostname.endsWith('.test') &&
!hostname.endsWith('.invalid') &&
!hostname.endsWith('.example')
);
}
function isIsoTime(value) {
return (
typeof value === 'string' &&
/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,9})?Z$/.test(value) &&
Number.isFinite(Date.parse(value))
);
}
function containsSensitiveMaterial(value, key = '') {
if (BANNED_KEYS.has(key.toLowerCase())) return true;
if (typeof value === 'string') {
return (
/-----BEGIN (?:RSA |EC |OPENSSH )?PRIVATE KEY-----/.test(value) ||
/postgres(?:ql)?:\/\/[^/\s]+:[^@\s]+@/i.test(value) ||
/\beyJ[A-Za-z0-9_-]{16,}\.[A-Za-z0-9_-]{16,}\.[A-Za-z0-9_-]{16,}\b/.test(
value,
)
);
}
if (Array.isArray(value)) {
return value.some((entry) => containsSensitiveMaterial(entry));
}
if (value && typeof value === 'object') {
return Object.entries(value).some(([childKey, child]) =>
containsSensitiveMaterial(child, childKey),
);
}
return false;
}
function exactPemBlocks(bytes, label, description) {
let value;
try {
value = new TextDecoder('utf-8', { fatal: true }).decode(bytes);
} catch {
fail(`${description} must be strict UTF-8`);
}
const pattern = new RegExp(
`-----BEGIN ${label}-----[\\s\\S]*?-----END ${label}-----`,
'g',
);
const matches = value.match(pattern);
if (!matches || matches.length < 1 || matches.length > 16) {
fail(`${description} must contain 1 to 16 PEM blocks`);
}
if (value.replace(pattern, '').trim() !== '') {
fail(`${description} contains unsupported data`);
}
return matches.map((match) => Buffer.from(`${match}\n`, 'utf8'));
}
function openssl(args, input, label) {
const result = spawnSync('openssl', args, {
input,
encoding: 'utf8',
timeout: 10_000,
maxBuffer: 64 * 1024,
env: { PATH: process.env.PATH, LANG: 'C', LC_ALL: 'C' },
});
if (result.status !== 0 || result.signal !== null) {
fail(`${label} is not accepted by OpenSSL`);
}
return result.stdout.trim();
}
function defaultInspectAuthoritySubject(bytes) {
const output = openssl(
['x509', '-noout', '-subject', '-nameopt', 'RFC2253'],
bytes,
'client certificate authority',
);
const subject = /^subject=(.+)$/.exec(output)?.[1];
if (!subject) fail('client certificate authority subject is invalid');
return subject;
}
function defaultInspectCrl(bytes) {
const output = openssl(
[
'crl',
'-noout',
'-issuer',
'-nameopt',
'RFC2253',
'-fingerprint',
'-sha256',
'-lastupdate',
'-nextupdate',
'-crlnumber',
],
bytes,
'client certificate revocation list',
);
const lines = output.split('\n');
const issuer = lines.find((line) => line.startsWith('issuer='))?.slice(7);
const fingerprint = /^SHA256 Fingerprint=([A-F0-9:]{95})$/i.exec(
lines.find((line) => line.startsWith('SHA256 Fingerprint=')) ?? '',
)?.[1];
const lastUpdate = lines
.find((line) => line.startsWith('lastUpdate='))
?.slice(11);
const nextUpdate = lines
.find((line) => line.startsWith('nextUpdate='))
?.slice(11);
const number = /^crlNumber=(?:0x)?([A-F0-9]+)$/i.exec(
lines.find((line) => line.startsWith('crlNumber=')) ?? '',
)?.[1];
const lastUpdateMs = Date.parse(lastUpdate ?? '');
const nextUpdateMs = Date.parse(nextUpdate ?? '');
if (
!issuer ||
!fingerprint ||
!number ||
!Number.isFinite(lastUpdateMs) ||
!Number.isFinite(nextUpdateMs) ||
nextUpdateMs <= lastUpdateMs
) {
fail('client certificate revocation list metadata is invalid');
}
return Object.freeze({
issuer,
sha256: `sha256:${fingerprint.replaceAll(':', '').toLowerCase()}`,
number: number.toLowerCase().replace(/^0+/, '') || '0',
lastUpdateMs,
nextUpdateMs,
});
}
function inspectAuthorities(bytes, nowMs, inspectAuthoritySubject) {
const blocks = exactPemBlocks(
bytes,
'CERTIFICATE',
'client certificate authority bundle',
);
const fingerprints = new Set();
const subjects = new Set();
const authorities = [];
try {
for (const block of blocks) {
let certificate;
try {
certificate = new X509Certificate(block);
} catch {
fail('client certificate authority is invalid');
}
if (
!certificate.ca ||
Date.parse(certificate.validFrom) > nowMs ||
Date.parse(certificate.validTo) <= nowMs
) {
fail('client certificate authority is not active CA material');
}
const fingerprintSha256 = `sha256:${certificate.fingerprint256
.replaceAll(':', '')
.toLowerCase()}`;
const subject = inspectAuthoritySubject(block);
if (
fingerprints.has(fingerprintSha256) ||
subjects.has(subject)
) {
fail('client certificate authority bundle contains a duplicate');
}
fingerprints.add(fingerprintSha256);
subjects.add(subject);
authorities.push(
Object.freeze({ certificate, fingerprintSha256, subject }),
);
}
return Object.freeze(authorities);
} finally {
for (const block of blocks) block.fill(0);
}
}
function inspectClientConfiguration(
configFile,
nowMs,
inspectAuthoritySubject = defaultInspectAuthoritySubject,
) {
const document = readJson(configFile, 'management client config');
try {
const config = exactObject(
document.value,
[
'schemaVersion',
'endpoint',
'servername',
'caFile',
'clientCertificateFile',
'clientPrivateKeyFile',
'requestTimeoutMs',
],
'management client config',
);
if (
config.schemaVersion !== 1 ||
typeof config.endpoint !== 'string' ||
typeof config.servername !== 'string' ||
typeof config.caFile !== 'string' ||
typeof config.clientCertificateFile !== 'string' ||
typeof config.clientPrivateKeyFile !== 'string' ||
!Number.isSafeInteger(config.requestTimeoutMs)
) {
fail('management client config is invalid');
}
let endpoint;
try {
endpoint = new URL(config.endpoint);
} catch {
fail('management client endpoint is invalid');
}
if (
endpoint.protocol !== 'https:' ||
endpoint.hostname !== config.servername ||
endpoint.pathname !== '/api/v3/worker-credentials/management' ||
endpoint.username !== '' ||
endpoint.password !== '' ||
endpoint.search !== '' ||
endpoint.hash !== '' ||
net.isIP(endpoint.hostname) !== 0
) {
fail('management client endpoint authority is invalid');
}
const serverCa = readBuffer(config.caFile, 'management server CA', {
private: false,
maximum: 256 * 1024,
});
const certificateBytes = readBuffer(
config.clientCertificateFile,
'management client certificate',
{ private: false, maximum: 256 * 1024 },
);
const privateKeyBytes = readBuffer(
config.clientPrivateKeyFile,
'management client private key',
{ maximum: 256 * 1024 },
);
try {
const serverAuthorities = inspectAuthorities(
serverCa,
nowMs,
inspectAuthoritySubject,
);
const certificate = new X509Certificate(certificateBytes);
const privateKey = createPrivateKey(privateKeyBytes);
if (
certificate.ca ||
!certificate.checkPrivateKey(privateKey) ||
!certificate.keyUsage?.includes('1.3.6.1.5.5.7.3.2') ||
Date.parse(certificate.validFrom) > nowMs ||
Date.parse(certificate.validTo) <= nowMs
) {
fail('management client identity is invalid');
}
return Object.freeze({
endpointSha256: digest(
'qinglong3.worker-management.endpoint.v1',
endpoint.toString(),
),
servernameSha256: digest(
'qinglong3.worker-management.servername.v1',
config.servername,
),
serverTrustBundleSha256: rawDigest(serverCa),
serverAuthoritySha256: Object.freeze(
serverAuthorities.map(({ fingerprintSha256 }) => fingerprintSha256),
),
clientCertificateSha256: `sha256:${certificate.fingerprint256
.replaceAll(':', '')
.toLowerCase()}`,
certificate,
});
} catch (error) {
if (error instanceof WorkerCredentialManagementCaRolloverEvidenceError) {
throw error;
}
fail('management client certificate material is invalid');
} finally {
serverCa.fill(0);
certificateBytes.fill(0);
privateKeyBytes.fill(0);
}
} finally {
document.bytes.fill(0);
}
}
function inspectTrustBundles(
caFile,
crlFile,
nowMs,
inspectAuthoritySubject,
inspectCrl,
) {
const caBytes = readBuffer(caFile, 'client CA bundle', {
private: false,
maximum: 256 * 1024,
});
const crlBytes = readBuffer(crlFile, 'client CRL bundle', {
private: false,
maximum: 256 * 1024,
});
let crlBlocks = [];
try {
const authorities = inspectAuthorities(
caBytes,
nowMs,
inspectAuthoritySubject,
);
crlBlocks = exactPemBlocks(
crlBytes,
'X509 CRL',
'client certificate revocation list bundle',
);
const crls = crlBlocks.map((block) => inspectCrl(block));
const authoritySubjects = authorities.map(({ subject }) => subject).sort();
const crlIssuers = crls.map(({ issuer }) => issuer).sort();
if (
crls.length !== authorities.length ||
JSON.stringify(crlIssuers) !== JSON.stringify(authoritySubjects) ||
new Set(crls.map(({ sha256 }) => sha256)).size !== crls.length ||
crls.some(
({ sha256, number, lastUpdateMs, nextUpdateMs }) =>
!SHA256_PATTERN.test(sha256) ||
!/^[a-f0-9]{1,64}$/.test(number) ||
!Number.isSafeInteger(lastUpdateMs) ||
!Number.isSafeInteger(nextUpdateMs) ||
lastUpdateMs > nowMs + 5 * 60_000 ||
nextUpdateMs <= nowMs ||
nextUpdateMs <= lastUpdateMs,
)
) {
fail('CRL issuer coverage is not exact and current');
}
return Object.freeze({
caBundleSha256: rawDigest(caBytes),
crlBundleSha256: rawDigest(crlBytes),
authorities,
caFingerprintSha256: Object.freeze(
authorities.map(({ fingerprintSha256 }) => fingerprintSha256).sort(),
),
crlIssuerSha256: Object.freeze(
crls
.map(({ issuer }) =>
digest('qinglong3.worker-management.client-ca-subject.v1', issuer),
)
.sort(),
),
});
} finally {
caBytes.fill(0);
crlBytes.fill(0);
for (const block of crlBlocks) block.fill(0);
}
}
function issuerAuthority(profile, trust) {
const matches = trust.authorities.filter(
({ certificate }) =>
profile.certificate.checkIssued(certificate) &&
profile.certificate.verify(certificate.publicKey),
);
if (matches.length > 1) fail('client certificate issuer is ambiguous');
return matches[0]?.fingerprintSha256 ?? null;
}
function sameClientTransport(oldProfile, newProfile) {
if (
oldProfile.endpointSha256 !== newProfile.endpointSha256 ||
oldProfile.servernameSha256 !== newProfile.servernameSha256 ||
oldProfile.serverTrustBundleSha256 !==
newProfile.serverTrustBundleSha256 ||
JSON.stringify(oldProfile.serverAuthoritySha256) !==
JSON.stringify(newProfile.serverAuthoritySha256) ||
oldProfile.clientCertificateSha256 ===
newProfile.clientCertificateSha256
) {
fail('old and new clients must use one server trust and distinct identities');
}
}
function identityFromFile(filePath, nowMs) {
const bytes = readBuffer(filePath, 'identity assertion');
try {
if (bytes.some((byte) => byte > 0x7f)) {
fail('identity assertion encoding is invalid');
}
return assertionIdentity(bytes.toString('ascii'), nowMs);
} finally {
bytes.fill(0);
}
}
function commandFromFile(filePath, normalize) {
const document = readJson(filePath, 'management inspect command');
try {
const command = normalize(document.value);
if (command?.operation !== 'worker-credential.inspect') {
fail('evidence command must be worker-credential.inspect');
}
return Object.freeze({
sha256: digest(
'qinglong3.worker-management.ca-rollover-command.v1',
JSON.stringify(command),
),
});
} finally {
document.bytes.fill(0);
}
}
function normalizeKubernetesSnapshot(value, caSha256, crlSha256) {
if (
!value ||
typeof value !== 'object' ||
!SHA256_PATTERN.test(value.clusterServerSha256) ||
!SHA256_PATTERN.test(value.collectorSubjectSha256) ||
!Array.isArray(value.authorization) ||
value.authorization.length !== REVIEWED_AUTHORITY.length ||
value.authorization.some(
(entry, index) =>
entry?.verb !== REVIEWED_AUTHORITY[index].verb ||
entry?.resource !== REVIEWED_AUTHORITY[index].resource ||
entry?.allowed !== REVIEWED_AUTHORITY[index].allowed ||
entry?.observed !== REVIEWED_AUTHORITY[index].allowed,
)
) {
fail('Kubernetes evidence collector authority is not exact read-only');
}
const deployment = value.deployment;
const metadata = deployment?.metadata;
const spec = deployment?.spec;
const status = deployment?.status;
if (
deployment?.apiVersion !== 'apps/v1' ||
deployment?.kind !== 'Deployment' ||
metadata?.name !== DEPLOYMENT ||
metadata?.namespace !== NAMESPACE ||
typeof metadata.uid !== 'string' ||
typeof metadata.resourceVersion !== 'string' ||
!Number.isSafeInteger(metadata.generation) ||
metadata.generation < 1 ||
spec?.replicas !== 2 ||
spec?.strategy?.type !== 'RollingUpdate' ||
spec?.strategy?.rollingUpdate?.maxUnavailable !== 0 ||
spec?.template?.metadata?.annotations?.[CA_ANNOTATION] !== caSha256 ||
spec?.template?.metadata?.annotations?.[CRL_ANNOTATION] !== crlSha256 ||
status?.observedGeneration !== metadata.generation ||
status?.replicas !== 2 ||
status?.updatedReplicas !== 2 ||
status?.readyReplicas !== 2 ||
status?.availableReplicas !== 2 ||
(status?.unavailableReplicas ?? 0) !== 0
) {
fail('Kubernetes Deployment is not one converged CA/CRL-bound rollout');
}
if (
value.pods?.apiVersion !== 'v1' ||
value.pods?.kind !== 'List' ||
!Array.isArray(value.pods.items) ||
value.pods.items.length < 2 ||
value.pods.items.length > 4
) {
fail('Kubernetes Pod snapshot is invalid');
}
const current = value.pods.items.filter(
(pod) => pod?.metadata?.deletionTimestamp === undefined,
);
const ready = (conditions) =>
Array.isArray(conditions) &&
conditions.some(
(condition) =>
condition?.type === 'Ready' && condition?.status === 'True',
);
if (
current.length !== 2 ||
current.some(
(pod) =>
pod.metadata?.namespace !== NAMESPACE ||
typeof pod.metadata?.uid !== 'string' ||
pod.metadata?.labels?.['app.kubernetes.io/name'] !== DEPLOYMENT ||
pod.metadata?.labels?.['app.kubernetes.io/component'] !==
'worker-credential-management' ||
typeof pod.metadata?.labels?.['pod-template-hash'] !== 'string' ||
pod.spec?.serviceAccountName !== DEPLOYMENT ||
pod.spec?.automountServiceAccountToken !== false ||
typeof pod.spec?.nodeName !== 'string' ||
pod.status?.phase !== 'Running' ||
!ready(pod.status?.conditions) ||
!pod.status?.containerStatuses?.some(
(container) =>
container.name === 'management' && container.ready === true,
),
) ||
new Set(current.map((pod) => pod.spec.nodeName)).size !== 2 ||
new Set(current.map((pod) => pod.metadata.labels['pod-template-hash']))
.size !== 1
) {
fail('Kubernetes manager Pods are not two Ready tokenless replicas');
}
return Object.freeze({
clusterServerSha256: value.clusterServerSha256,
collectorSubjectSha256: value.collectorSubjectSha256,
deploymentUidSha256: digest(
'qinglong3.worker-management.deployment-uid.v1',
metadata.uid,
),
deploymentResourceVersionSha256: digest(
'qinglong3.worker-management.deployment-resource-version.v1',
metadata.resourceVersion,
),
deploymentGeneration: metadata.generation,
caAnnotationSha256: caSha256,
crlAnnotationSha256: crlSha256,
podUidSha256: Object.freeze(
current
.map((pod) =>
digest('qinglong3.worker-management.pod-uid.v1', pod.metadata.uid),
)
.sort(),
),
podNodeSha256: Object.freeze(
current
.map((pod) =>
digest('qinglong3.worker-management.node.v1', pod.spec.nodeName),
)
.sort(),
),
exactReadOnlyCollectorAuthority: true,
});
}
function expectedAccess(phase) {
return Object.freeze(
phase === 'old'
? { old: 200, next: 401 }
: phase === 'overlap'
? { old: 200, next: 200 }
: { old: 401, next: 200 },
);
}
async function invokeClient(execute, options, configFile, expected) {
try {
await execute({
configFile,
commandFile: options.commandFile,
assertionFile: options.assertionFile,
});
if (expected !== 200) fail('retired client certificate was accepted');
return Object.freeze({ statusCode: 200, responseCode: null });
} catch (error) {
if (
expected === 401 &&
error?.statusCode === 401 &&
error?.responseCode === 'client_certificate_required'
) {
return Object.freeze({
statusCode: 401,
responseCode: 'client_certificate_required',
});
}
if (error instanceof WorkerCredentialManagementCaRolloverEvidenceError) {
throw error;
}
fail('management client observation did not match the required status');
}
}
function defaultDependencies() {
const { executeClusterWorkerCredentialManagementClient } = clusterRequire(
'@qinglong/cluster-admin/worker-credential-management-client',
);
const { normalizeClusterWorkerCredentialManagementCommand } = clusterRequire(
'@qinglong/cluster-admin/worker-credential-management-transport',
);
return Object.freeze({
now: Date.now,
execute: executeClusterWorkerCredentialManagementClient,
normalize: normalizeClusterWorkerCredentialManagementCommand,
inspectClient: inspectClientConfiguration,
inspectTrust: inspectTrustBundles,
inspectAuthoritySubject: defaultInspectAuthoritySubject,
inspectCrl: defaultInspectCrl,
collectKubernetes: collectKubernetesSnapshot,
});
}
function reviewedDependencies(overrides = {}) {
const defaults = overrides.useDefaults === false ? {} : defaultDependencies();
const dependencies = { ...defaults, ...overrides };
delete dependencies.useDefaults;
exactObject(
dependencies,
[
'now',
'execute',
'normalize',
'inspectClient',
'inspectTrust',
'inspectAuthoritySubject',
'inspectCrl',
'collectKubernetes',
],
'evidence dependencies',
);
if (Object.values(dependencies).some((entry) => typeof entry !== 'function')) {
fail('evidence dependencies are invalid');
}
return Object.freeze(dependencies);
}
function validDigestArray(value, length) {
return (
Array.isArray(value) &&
value.length === length &&
value.every((entry) => SHA256_PATTERN.test(entry)) &&
new Set(value).size === value.length &&
JSON.stringify(value) === JSON.stringify([...value].sort())
);
}
function validDigestSequence(value, length) {
return (
Array.isArray(value) &&
value.length === length &&
value.every((entry) => SHA256_PATTERN.test(entry)) &&
new Set(value).size === value.length
);
}
function validKubernetes(value) {
return (
exactKeys(value, [
'clusterServerSha256',
'collectorSubjectSha256',
'deploymentUidSha256',
'deploymentResourceVersionSha256',
'deploymentGeneration',
'caAnnotationSha256',
'crlAnnotationSha256',
'podUidSha256',
'podNodeSha256',
'exactReadOnlyCollectorAuthority',
]) &&
[
value.clusterServerSha256,
value.collectorSubjectSha256,
value.deploymentUidSha256,
value.deploymentResourceVersionSha256,
value.caAnnotationSha256,
value.crlAnnotationSha256,
].every((entry) => SHA256_PATTERN.test(entry)) &&
Number.isSafeInteger(value.deploymentGeneration) &&
value.deploymentGeneration >= 1 &&
validDigestArray(value.podUidSha256, 2) &&
validDigestArray(value.podNodeSha256, 2) &&
value.exactReadOnlyCollectorAuthority === true
);
}
function validateStageState(state) {
const findings = [];
const add = (code) => findings.push(Object.freeze({ code }));
if (
!exactKeys(state, [
'schemaVersion',
'fixture',
'phase',
'observedAt',
'previousStateSha256',
'identity',
'transport',
'trust',
'kubernetes',
'access',
'gates',
]) ||
state?.schemaVersion !== 1 ||
state?.fixture !== STATE_FIXTURE ||
!['old', 'overlap'].includes(state?.phase) ||
!isIsoTime(state?.observedAt) ||
(state?.phase === 'old'
? state?.previousStateSha256 !== null
: !SHA256_PATTERN.test(state?.previousStateSha256))
) {
add('QL3_WORKER_MANAGEMENT_CA_ROLLOVER_STATE_SHAPE');
}
if (
!exactKeys(state?.identity, [
'providerKind',
'issuer',
'audience',
'type',
'purpose',
'subjectSha256',
]) ||
state?.identity?.providerKind !== 'external_oidc' ||
!externalIssuer(state?.identity?.issuer) ||
state?.identity?.audience !== AUDIENCE ||
state?.identity?.type !== TYPE ||
state?.identity?.purpose !== PURPOSE ||
!SHA256_PATTERN.test(state?.identity?.subjectSha256)
) {
add('QL3_WORKER_MANAGEMENT_CA_ROLLOVER_STATE_IDENTITY');
}
if (
!exactKeys(state?.transport, [
'endpointSha256',
'servernameSha256',
'serverTrustBundleSha256',
'serverAuthoritySha256',
'commandSha256',
'oldClientCertificateSha256',
'newClientCertificateSha256',
]) ||
![
state?.transport?.endpointSha256,
state?.transport?.servernameSha256,
state?.transport?.serverTrustBundleSha256,
state?.transport?.commandSha256,
state?.transport?.oldClientCertificateSha256,
state?.transport?.newClientCertificateSha256,
].every((entry) => SHA256_PATTERN.test(entry)) ||
!Array.isArray(state?.transport?.serverAuthoritySha256) ||
state.transport.serverAuthoritySha256.length < 1 ||
state.transport.serverAuthoritySha256.length > 16 ||
state.transport.serverAuthoritySha256.some(
(entry) => !SHA256_PATTERN.test(entry),
) ||
state.transport.oldClientCertificateSha256 ===
state.transport.newClientCertificateSha256
) {
add('QL3_WORKER_MANAGEMENT_CA_ROLLOVER_STATE_TRANSPORT');
}
const expectedAuthorities = state?.phase === 'old' ? 1 : 2;
if (
!exactKeys(state?.trust, [
'caBundleSha256',
'crlBundleSha256',
'caFingerprintSha256',
'crlIssuerSha256',
'oldIssuerCaSha256',
'newIssuerCaSha256',
]) ||
!SHA256_PATTERN.test(state?.trust?.caBundleSha256) ||
!SHA256_PATTERN.test(state?.trust?.crlBundleSha256) ||
!validDigestArray(
state?.trust?.caFingerprintSha256,
expectedAuthorities,
) ||
!validDigestArray(state?.trust?.crlIssuerSha256, expectedAuthorities) ||
!SHA256_PATTERN.test(state?.trust?.oldIssuerCaSha256) ||
(state?.phase === 'old'
? state?.trust?.newIssuerCaSha256 !== null
: !SHA256_PATTERN.test(state?.trust?.newIssuerCaSha256)) ||
!state?.trust?.caFingerprintSha256.includes(
state?.trust?.oldIssuerCaSha256,
) ||
(state?.phase === 'overlap' &&
(!state.trust.caFingerprintSha256.includes(
state.trust.newIssuerCaSha256,
) ||
state.trust.oldIssuerCaSha256 === state.trust.newIssuerCaSha256))
) {
add('QL3_WORKER_MANAGEMENT_CA_ROLLOVER_STATE_TRUST');
}
if (!validKubernetes(state?.kubernetes)) {
add('QL3_WORKER_MANAGEMENT_CA_ROLLOVER_STATE_KUBERNETES');
}
const expected = expectedAccess(state?.phase);
if (
!exactKeys(state?.access, [
'oldCertificateStatus',
'oldCertificateCode',
'newCertificateStatus',
'newCertificateCode',
]) ||
state?.access?.oldCertificateStatus !== expected.old ||
state?.access?.newCertificateStatus !== expected.next ||
state?.access?.oldCertificateCode !==
(expected.old === 401 ? 'client_certificate_required' : null) ||
state?.access?.newCertificateCode !==
(expected.next === 401 ? 'client_certificate_required' : null)
) {
add('QL3_WORKER_MANAGEMENT_CA_ROLLOVER_STATE_ACCESS');
}
if (
!exactKeys(state?.gates, [
'trustSetExact',
'crlIssuerCoverageExact',
'deploymentReady',
'previousGenerationReplaced',
'readOnlyCollectorAuthority',
'expectedClientAccess',
'passed',
]) ||
Object.values(state?.gates ?? {}).some((entry) => entry !== true)
) {
add('QL3_WORKER_MANAGEMENT_CA_ROLLOVER_STATE_GATES');
}
if (containsSensitiveMaterial(state)) {
add('QL3_WORKER_MANAGEMENT_CA_ROLLOVER_STATE_SECRET_EXPOSURE');
}
return Object.freeze({
compatible: findings.length === 0,
findings: Object.freeze(findings),
});
}
function sameStageAuthority(previous, current) {
if (
previous.identity.issuer !== current.identity.issuer ||
previous.identity.subjectSha256 !== current.identity.subjectSha256 ||
JSON.stringify(previous.transport) !== JSON.stringify(current.transport) ||
previous.kubernetes.clusterServerSha256 !==
current.kubernetes.clusterServerSha256 ||
previous.kubernetes.collectorSubjectSha256 !==
current.kubernetes.collectorSubjectSha256 ||
previous.kubernetes.deploymentUidSha256 !==
current.kubernetes.deploymentUidSha256
) {
fail('rollover phase authority does not match the previous state');
}
if (
current.kubernetes.deploymentGeneration <=
previous.kubernetes.deploymentGeneration ||
current.kubernetes.deploymentResourceVersionSha256 ===
previous.kubernetes.deploymentResourceVersionSha256 ||
previous.kubernetes.podUidSha256.some((entry) =>
current.kubernetes.podUidSha256.includes(entry),
)
) {
fail('rollover did not replace the complete previous generation');
}
}
function phaseTrust(phase, oldProfile, newProfile, trust, previous) {
const oldIssuer = issuerAuthority(oldProfile, trust);
const newIssuer = issuerAuthority(newProfile, trust);
if (phase === 'old') {
if (trust.authorities.length !== 1 || oldIssuer === null || newIssuer !== null) {
fail('old phase trust set is not exact');
}
return Object.freeze({ oldIssuer, newIssuer: null });
}
if (phase === 'overlap') {
if (
trust.authorities.length !== 2 ||
oldIssuer === null ||
newIssuer === null ||
oldIssuer === newIssuer ||
oldIssuer !== previous.trust.oldIssuerCaSha256
) {
fail('overlap phase trust set is not the exact old plus new union');
}
return Object.freeze({ oldIssuer, newIssuer });
}
if (
trust.authorities.length !== 1 ||
oldIssuer !== null ||
newIssuer === null ||
newIssuer !== previous.trust.newIssuerCaSha256
) {
fail('new phase trust set did not safely retire the old CA');
}
return Object.freeze({
oldIssuer: previous.trust.oldIssuerCaSha256,
newIssuer,
});
}
async function observePhase(phase, options, runtime, previous) {
const nowMs = runtime.now();
if (!Number.isSafeInteger(nowMs) || nowMs < 0) fail('clock is invalid');
const identity = identityFromFile(options.assertionFile, nowMs);
const command = commandFromFile(options.commandFile, runtime.normalize);
const oldProfile = runtime.inspectClient(
options.oldConfigFile,
nowMs,
runtime.inspectAuthoritySubject,
);
const newProfile = runtime.inspectClient(
options.newConfigFile,
nowMs,
runtime.inspectAuthoritySubject,
);
sameClientTransport(oldProfile, newProfile);
const trust = runtime.inspectTrust(
options.caBundleFile,
options.crlBundleFile,
nowMs,
runtime.inspectAuthoritySubject,
runtime.inspectCrl,
);
const issuers = phaseTrust(phase, oldProfile, newProfile, trust, previous);
const kubernetes = normalizeKubernetesSnapshot(
await runtime.collectKubernetes(
options.kubernetesFile,
trust.crlBundleSha256,
),
trust.caBundleSha256,
trust.crlBundleSha256,
);
const expected = expectedAccess(phase);
const oldAccess = await invokeClient(
runtime.execute,
options,
options.oldConfigFile,
expected.old,
);
const newAccess = await invokeClient(
runtime.execute,
options,
options.newConfigFile,
expected.next,
);
const state = Object.freeze({
schemaVersion: 1,
fixture: STATE_FIXTURE,
phase,
observedAt: new Date(nowMs).toISOString(),
previousStateSha256:
previous === null ? null : options.previousStateSha256,
identity: Object.freeze({
providerKind: 'external_oidc',
issuer: identity.issuer,
audience: AUDIENCE,
type: TYPE,
purpose: PURPOSE,
subjectSha256: digest(
'qinglong3.worker-management.subject.v1',
identity.subject,
),
}),
transport: Object.freeze({
endpointSha256: oldProfile.endpointSha256,
servernameSha256: oldProfile.servernameSha256,
serverTrustBundleSha256: oldProfile.serverTrustBundleSha256,
serverAuthoritySha256: oldProfile.serverAuthoritySha256,
commandSha256: command.sha256,
oldClientCertificateSha256: oldProfile.clientCertificateSha256,
newClientCertificateSha256: newProfile.clientCertificateSha256,
}),
trust: Object.freeze({
caBundleSha256: trust.caBundleSha256,
crlBundleSha256: trust.crlBundleSha256,
caFingerprintSha256: trust.caFingerprintSha256,
crlIssuerSha256: trust.crlIssuerSha256,
oldIssuerCaSha256: issuers.oldIssuer,
newIssuerCaSha256: issuers.newIssuer,
}),
kubernetes,
access: Object.freeze({
oldCertificateStatus: oldAccess.statusCode,
oldCertificateCode: oldAccess.responseCode,
newCertificateStatus: newAccess.statusCode,
newCertificateCode: newAccess.responseCode,
}),
gates: Object.freeze({
trustSetExact: true,
crlIssuerCoverageExact: true,
deploymentReady: true,
previousGenerationReplaced: true,
readOnlyCollectorAuthority: true,
expectedClientAccess: true,
passed: true,
}),
});
if (phase !== 'old') sameStageAuthority(previous, state);
return Object.freeze({ state, identity });
}
function readStage(filePath, expectedPhase) {
const document = readJson(filePath, `${expectedPhase} rollover state`);
const audit = validateStageState(document.value);
if (!audit.compatible || document.value.phase !== expectedPhase) {
document.bytes.fill(0);
fail(`${expectedPhase} rollover state is incompatible`);
}
return document;
}
function commonOptionKeys() {
return [
'oldConfigFile',
'newConfigFile',
'assertionFile',
'commandFile',
'kubernetesFile',
'caBundleFile',
'crlBundleFile',
'outputFile',
];
}
async function runOldEvidence(options, dependencies = {}) {
exactObject(options, commonOptionKeys(), 'old evidence options');
unusedOutput(options.outputFile);
const runtime = reviewedDependencies(dependencies);
const { state } = await observePhase('old', options, runtime, null);
const audit = validateStageState(state);
if (!audit.compatible) fail('assembled old state failed audit');
writeNoReplace(options.outputFile, state);
return state;
}
async function runOverlapEvidence(options, dependencies = {}) {
exactObject(
options,
['previousFile', ...commonOptionKeys()],
'overlap evidence options',
);
unusedOutput(options.outputFile);
const previousDocument = readStage(options.previousFile, 'old');
try {
const runtime = reviewedDependencies(dependencies);
const phaseOptions = {
...options,
previousStateSha256: rawDigest(previousDocument.bytes),
};
delete phaseOptions.previousFile;
const { state } = await observePhase(
'overlap',
phaseOptions,
runtime,
previousDocument.value,
);
const audit = validateStageState(state);
if (!audit.compatible) fail('assembled overlap state failed audit');
writeNoReplace(options.outputFile, state);
return state;
} finally {
previousDocument.bytes.fill(0);
}
}
function assertSourceReports(ceremonyDocument, durableDocument) {
const ceremonyAudit = validateWorkerCredentialManagementLiveCeremony(
ceremonyDocument.value,
);
const durableAudit = validateWorkerCredentialManagementDurableAuditEvidence(
durableDocument.value,
);
if (!ceremonyAudit.compatible || !durableAudit.compatible) {
fail('ceremony or durable audit source report is incompatible');
}
if (
durableDocument.value.source.ceremonyReportSha256 !==
rawDigest(ceremonyDocument.bytes) ||
durableDocument.value.durableState.requesterSubjectSha256 !==
ceremonyDocument.value.identity.requesterSubjectSha256 ||
durableDocument.value.durableState.reviewerSubjectSha256 !==
ceremonyDocument.value.identity.reviewerSubjectSha256 ||
durableDocument.value.durableState.planDigest !==
ceremonyDocument.value.ceremony.planDigest ||
durableDocument.value.durableState.previewDigest !==
ceremonyDocument.value.ceremony.previewDigest
) {
fail('durable audit report is not bound to the ceremony report');
}
}
function validateWorkerCredentialManagementCaRolloverEvidence(report) {
const findings = [];
const add = (code) => findings.push(Object.freeze({ code }));
if (
!exactKeys(report, [
'schemaVersion',
'fixture',
'observedAt',
'source',
'identity',
'transport',
'trustTransition',
'access',
'kubernetes',
'gates',
]) ||
report?.schemaVersion !== 1 ||
report?.fixture !== FIXTURE ||
!isIsoTime(report?.observedAt)
) {
add('QL3_WORKER_MANAGEMENT_CA_ROLLOVER_EVIDENCE_SHAPE');
}
if (
!exactKeys(report?.source, [
'oldStateSha256',
'overlapStateSha256',
'ceremonyReportSha256',
'durableAuditReportSha256',
'ceremonyFixture',
'durableAuditFixture',
]) ||
![
report?.source?.oldStateSha256,
report?.source?.overlapStateSha256,
report?.source?.ceremonyReportSha256,
report?.source?.durableAuditReportSha256,
].every((entry) => SHA256_PATTERN.test(entry)) ||
report?.source?.ceremonyFixture !== CEREMONY_FIXTURE ||
report?.source?.durableAuditFixture !== DURABLE_FIXTURE
) {
add('QL3_WORKER_MANAGEMENT_CA_ROLLOVER_EVIDENCE_SOURCE');
}
if (
!exactKeys(report?.identity, [
'providerKind',
'issuer',
'audience',
'type',
'purpose',
'subjectSha256',
'ceremonyIdentityBound',
'durableAuditBound',
]) ||
report?.identity?.providerKind !== 'external_oidc' ||
!externalIssuer(report?.identity?.issuer) ||
report?.identity?.audience !== AUDIENCE ||
report?.identity?.type !== TYPE ||
report?.identity?.purpose !== PURPOSE ||
!SHA256_PATTERN.test(report?.identity?.subjectSha256) ||
report?.identity?.ceremonyIdentityBound !== true ||
report?.identity?.durableAuditBound !== true
) {
add('QL3_WORKER_MANAGEMENT_CA_ROLLOVER_EVIDENCE_IDENTITY');
}
if (
!exactKeys(report?.transport, [
'endpointSha256',
'servernameSha256',
'serverTrustBundleSha256',
'commandSha256',
'oldClientCertificateSha256',
'newClientCertificateSha256',
]) ||
Object.values(report?.transport ?? {}).some(
(entry) => !SHA256_PATTERN.test(entry),
) ||
report?.transport?.oldClientCertificateSha256 ===
report?.transport?.newClientCertificateSha256
) {
add('QL3_WORKER_MANAGEMENT_CA_ROLLOVER_EVIDENCE_TRANSPORT');
}
const trust = report?.trustTransition;
if (
!exactKeys(trust, [
'oldCaBundleSha256',
'overlapCaBundleSha256',
'newCaBundleSha256',
'oldCrlBundleSha256',
'overlapCrlBundleSha256',
'newCrlBundleSha256',
'oldIssuerCaSha256',
'newIssuerCaSha256',
'oldSet',
'overlapSet',
'newSet',
'crlIssuerCoverageExact',
]) ||
![
trust?.oldCaBundleSha256,
trust?.overlapCaBundleSha256,
trust?.newCaBundleSha256,
trust?.oldCrlBundleSha256,
trust?.overlapCrlBundleSha256,
trust?.newCrlBundleSha256,
trust?.oldIssuerCaSha256,
trust?.newIssuerCaSha256,
].every((entry) => SHA256_PATTERN.test(entry)) ||
trust?.oldIssuerCaSha256 === trust?.newIssuerCaSha256 ||
!validDigestArray(trust?.oldSet, 1) ||
!validDigestArray(trust?.overlapSet, 2) ||
!validDigestArray(trust?.newSet, 1) ||
trust.oldSet[0] !== trust.oldIssuerCaSha256 ||
trust.newSet[0] !== trust.newIssuerCaSha256 ||
!trust.overlapSet.includes(trust.oldIssuerCaSha256) ||
!trust.overlapSet.includes(trust.newIssuerCaSha256) ||
trust?.crlIssuerCoverageExact !== true
) {
add('QL3_WORKER_MANAGEMENT_CA_ROLLOVER_EVIDENCE_TRUST');
}
if (
!exactKeys(report?.access, [
'oldCertificateStatus',
'newCertificateStatus',
]) ||
JSON.stringify(report?.access?.oldCertificateStatus) !==
JSON.stringify([200, 200, 401]) ||
JSON.stringify(report?.access?.newCertificateStatus) !==
JSON.stringify([401, 200, 200])
) {
add('QL3_WORKER_MANAGEMENT_CA_ROLLOVER_EVIDENCE_ACCESS');
}
const kubernetes = report?.kubernetes;
if (
!exactKeys(kubernetes, [
'clusterServerSha256',
'collectorSubjectSha256',
'deploymentUidSha256',
'generations',
'resourceVersionSha256',
'podUidSha256',
'allGenerationsFullyReplaced',
'twoReadyReplicasOnDistinctNodes',
'exactReadOnlyCollectorAuthority',
]) ||
![
kubernetes?.clusterServerSha256,
kubernetes?.collectorSubjectSha256,
kubernetes?.deploymentUidSha256,
].every((entry) => SHA256_PATTERN.test(entry)) ||
!Array.isArray(kubernetes?.generations) ||
kubernetes.generations.length !== 3 ||
!kubernetes.generations.every(Number.isSafeInteger) ||
!(
kubernetes.generations[0] < kubernetes.generations[1] &&
kubernetes.generations[1] < kubernetes.generations[2]
) ||
!validDigestSequence(kubernetes?.resourceVersionSha256, 3) ||
!Array.isArray(kubernetes?.podUidSha256) ||
kubernetes.podUidSha256.length !== 3 ||
kubernetes.podUidSha256.some((set) => !validDigestArray(set, 2)) ||
new Set(kubernetes.podUidSha256.flat()).size !== 6 ||
kubernetes?.allGenerationsFullyReplaced !== true ||
kubernetes?.twoReadyReplicasOnDistinctNodes !== true ||
kubernetes?.exactReadOnlyCollectorAuthority !== true
) {
add('QL3_WORKER_MANAGEMENT_CA_ROLLOVER_EVIDENCE_KUBERNETES');
}
if (
!exactKeys(report?.gates, [
'sourceReportsBound',
'externalIdentityBound',
'serverTrustSeparatedFromClientIssuer',
'oldOnlyObserved',
'exactOverlapObserved',
'safeRetirementObserved',
'crlCoverageObservedEveryPhase',
'allPodGenerationsReplaced',
'readOnlyCollectorAuthority',
'passed',
]) ||
Object.values(report?.gates ?? {}).some((entry) => entry !== true)
) {
add('QL3_WORKER_MANAGEMENT_CA_ROLLOVER_EVIDENCE_GATES');
}
if (containsSensitiveMaterial(report)) {
add('QL3_WORKER_MANAGEMENT_CA_ROLLOVER_EVIDENCE_SECRET_EXPOSURE');
}
return Object.freeze({
compatible: findings.length === 0,
findings: Object.freeze(findings),
});
}
async function runNewEvidence(options, dependencies = {}) {
exactObject(
options,
[
'oldFile',
'previousFile',
...commonOptionKeys(),
'ceremonyReportFile',
'durableAuditReportFile',
],
'new evidence options',
);
unusedOutput(options.outputFile);
const oldDocument = readStage(options.oldFile, 'old');
const overlapDocument = readStage(options.previousFile, 'overlap');
const ceremonyDocument = readJson(
options.ceremonyReportFile,
'ceremony report',
);
const durableDocument = readJson(
options.durableAuditReportFile,
'durable audit report',
);
try {
if (
overlapDocument.value.previousStateSha256 !==
rawDigest(oldDocument.bytes)
) {
fail('overlap state is not chained to the old state');
}
assertSourceReports(ceremonyDocument, durableDocument);
const runtime = reviewedDependencies(dependencies);
const phaseOptions = {
...options,
previousStateSha256: rawDigest(overlapDocument.bytes),
};
delete phaseOptions.oldFile;
delete phaseOptions.previousFile;
delete phaseOptions.ceremonyReportFile;
delete phaseOptions.durableAuditReportFile;
const { state: current, identity } = await observePhase(
'new',
phaseOptions,
runtime,
overlapDocument.value,
);
sameStageAuthority(oldDocument.value, overlapDocument.value);
if (
oldDocument.value.trust.oldIssuerCaSha256 !==
overlapDocument.value.trust.oldIssuerCaSha256 ||
current.trust.oldIssuerCaSha256 !==
overlapDocument.value.trust.oldIssuerCaSha256 ||
current.trust.newIssuerCaSha256 !==
overlapDocument.value.trust.newIssuerCaSha256
) {
fail('client CA identities drifted across rollover phases');
}
const subjectSha256 = digest(
'qinglong3.worker-management.subject.v1',
identity.subject,
);
const ceremonySubjects = [
ceremonyDocument.value.identity.requesterSubjectSha256,
ceremonyDocument.value.identity.reviewerSubjectSha256,
];
if (
current.identity.subjectSha256 !== subjectSha256 ||
identity.issuer !== ceremonyDocument.value.identity.issuer ||
!ceremonySubjects.includes(subjectSha256) ||
![
durableDocument.value.durableState.requesterSubjectSha256,
durableDocument.value.durableState.reviewerSubjectSha256,
].includes(subjectSha256)
) {
fail('new phase identity is not bound to the reviewed ceremony');
}
const report = Object.freeze({
schemaVersion: 1,
fixture: FIXTURE,
observedAt: current.observedAt,
source: Object.freeze({
oldStateSha256: rawDigest(oldDocument.bytes),
overlapStateSha256: rawDigest(overlapDocument.bytes),
ceremonyReportSha256: rawDigest(ceremonyDocument.bytes),
durableAuditReportSha256: rawDigest(durableDocument.bytes),
ceremonyFixture: CEREMONY_FIXTURE,
durableAuditFixture: DURABLE_FIXTURE,
}),
identity: Object.freeze({
...current.identity,
ceremonyIdentityBound: true,
durableAuditBound: true,
}),
transport: Object.freeze({
endpointSha256: current.transport.endpointSha256,
servernameSha256: current.transport.servernameSha256,
serverTrustBundleSha256:
current.transport.serverTrustBundleSha256,
commandSha256: current.transport.commandSha256,
oldClientCertificateSha256:
current.transport.oldClientCertificateSha256,
newClientCertificateSha256:
current.transport.newClientCertificateSha256,
}),
trustTransition: Object.freeze({
oldCaBundleSha256: oldDocument.value.trust.caBundleSha256,
overlapCaBundleSha256:
overlapDocument.value.trust.caBundleSha256,
newCaBundleSha256: current.trust.caBundleSha256,
oldCrlBundleSha256: oldDocument.value.trust.crlBundleSha256,
overlapCrlBundleSha256:
overlapDocument.value.trust.crlBundleSha256,
newCrlBundleSha256: current.trust.crlBundleSha256,
oldIssuerCaSha256: current.trust.oldIssuerCaSha256,
newIssuerCaSha256: current.trust.newIssuerCaSha256,
oldSet: oldDocument.value.trust.caFingerprintSha256,
overlapSet: overlapDocument.value.trust.caFingerprintSha256,
newSet: current.trust.caFingerprintSha256,
crlIssuerCoverageExact: true,
}),
access: Object.freeze({
oldCertificateStatus: Object.freeze([
oldDocument.value.access.oldCertificateStatus,
overlapDocument.value.access.oldCertificateStatus,
current.access.oldCertificateStatus,
]),
newCertificateStatus: Object.freeze([
oldDocument.value.access.newCertificateStatus,
overlapDocument.value.access.newCertificateStatus,
current.access.newCertificateStatus,
]),
}),
kubernetes: Object.freeze({
clusterServerSha256: current.kubernetes.clusterServerSha256,
collectorSubjectSha256:
current.kubernetes.collectorSubjectSha256,
deploymentUidSha256: current.kubernetes.deploymentUidSha256,
generations: Object.freeze([
oldDocument.value.kubernetes.deploymentGeneration,
overlapDocument.value.kubernetes.deploymentGeneration,
current.kubernetes.deploymentGeneration,
]),
resourceVersionSha256: Object.freeze([
oldDocument.value.kubernetes.deploymentResourceVersionSha256,
overlapDocument.value.kubernetes
.deploymentResourceVersionSha256,
current.kubernetes.deploymentResourceVersionSha256,
]),
podUidSha256: Object.freeze([
oldDocument.value.kubernetes.podUidSha256,
overlapDocument.value.kubernetes.podUidSha256,
current.kubernetes.podUidSha256,
]),
allGenerationsFullyReplaced: true,
twoReadyReplicasOnDistinctNodes: true,
exactReadOnlyCollectorAuthority: true,
}),
gates: Object.freeze({
sourceReportsBound: true,
externalIdentityBound: true,
serverTrustSeparatedFromClientIssuer: true,
oldOnlyObserved: true,
exactOverlapObserved: true,
safeRetirementObserved: true,
crlCoverageObservedEveryPhase: true,
allPodGenerationsReplaced: true,
readOnlyCollectorAuthority: true,
passed: true,
}),
});
const audit = validateWorkerCredentialManagementCaRolloverEvidence(report);
if (!audit.compatible) {
fail(
`assembled report failed audit: ${audit.findings
.map(({ code }) => code)
.join(',')}`,
);
}
writeNoReplace(options.outputFile, report);
return report;
} finally {
oldDocument.bytes.fill(0);
overlapDocument.bytes.fill(0);
ceremonyDocument.bytes.fill(0);
durableDocument.bytes.fill(0);
}
}
function parseArguments(argv) {
const values = {};
for (const argument of argv) {
if (argument === '--') continue;
const match = /^--([a-z-]+)=(.+)$/.exec(argument);
if (!match || Object.hasOwn(values, match[1])) fail('arguments are invalid');
values[match[1]] = match[2];
}
if (!['old', 'overlap', 'new'].includes(values.phase)) {
fail('phase must be old, overlap or new');
}
const common = [
'phase',
'old-config',
'new-config',
'assertion',
'command',
'kubernetes',
'client-ca-bundle',
'client-crl-bundle',
'output',
];
const expected =
values.phase === 'old'
? common
: values.phase === 'overlap'
? [...common, 'previous']
: [
...common,
'old',
'previous',
'ceremony-report',
'durable-audit-report',
];
if (
JSON.stringify(Object.keys(values).sort()) !==
JSON.stringify(expected.sort())
) {
fail('arguments are invalid');
}
const shared = {
oldConfigFile: values['old-config'],
newConfigFile: values['new-config'],
assertionFile: values.assertion,
commandFile: values.command,
kubernetesFile: values.kubernetes,
caBundleFile: values['client-ca-bundle'],
crlBundleFile: values['client-crl-bundle'],
outputFile: values.output,
};
return Object.freeze(
values.phase === 'old'
? { phase: 'old', options: Object.freeze(shared) }
: values.phase === 'overlap'
? {
phase: 'overlap',
options: Object.freeze({
previousFile: values.previous,
...shared,
}),
}
: {
phase: 'new',
options: Object.freeze({
oldFile: values.old,
previousFile: values.previous,
...shared,
ceremonyReportFile: values['ceremony-report'],
durableAuditReportFile: values['durable-audit-report'],
}),
},
);
}
async function runCli(argv) {
if (
process.env.QL3_WORKER_CREDENTIAL_MANAGEMENT_CA_ROLLOVER_EVIDENCE !== '1'
) {
fail('explicit CA rollover evidence opt-in is required');
}
const parsed = parseArguments(argv);
if (parsed.phase === 'old') await runOldEvidence(parsed.options);
else if (parsed.phase === 'overlap') {
await runOverlapEvidence(parsed.options);
} else await runNewEvidence(parsed.options);
process.stdout.write(
`${JSON.stringify({
schemaVersion: 1,
fixture: FIXTURE,
phase: parsed.phase,
compatible: true,
})}\n`,
);
}
if (require.main === module) {
runCli(process.argv.slice(2)).catch((error) => {
process.stderr.write(
`${error instanceof Error ? error.message : 'CA rollover evidence failed'}\n`,
);
process.exitCode = 1;
});
}
module.exports = {
CA_ANNOTATION,
CRL_ANNOTATION,
FIXTURE,
STATE_FIXTURE,
WorkerCredentialManagementCaRolloverEvidenceError,
defaultInspectAuthoritySubject,
defaultInspectCrl,
inspectClientConfiguration,
inspectTrustBundles,
normalizeKubernetesSnapshot,
parseArguments,
runNewEvidence,
runOldEvidence,
runOverlapEvidence,
validateStageState,
validateWorkerCredentialManagementCaRolloverEvidence,
};