mirror of
https://github.com/whyour/qinglong.git
synced 2026-09-21 00:36:13 +08:00
1841 lines
56 KiB
JavaScript
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,
|
|
};
|