mirror of
https://github.com/whyour/qinglong.git
synced 2026-09-21 01:32:44 +08:00
2176 lines
66 KiB
JavaScript
2176 lines
66 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 FIXTURE =
|
|
'qinglong/worker-credential-management-pki-rotation-evidence@v2';
|
|
const STATE_FIXTURE =
|
|
'qinglong/worker-credential-management-pki-rotation-state@v2';
|
|
const CEREMONY_FIXTURE =
|
|
'qinglong/worker-credential-management-live-ceremony@v1';
|
|
const DURABLE_FIXTURE =
|
|
'qinglong/worker-credential-management-durable-audit-evidence@v1';
|
|
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 LABEL_SELECTOR =
|
|
'app.kubernetes.io/name=ql3-worker-credential-management,' +
|
|
'app.kubernetes.io/component=worker-credential-management';
|
|
const CRL_ANNOTATION =
|
|
'qinglong.io/worker-credential-management-client-crl-sha256';
|
|
const MAX_FILE_BYTES = 1024 * 1024;
|
|
const CONTROL_PATTERN = /[\u0000-\u001f\u007f]/;
|
|
const TOKEN_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:/@-]{0,255}$/;
|
|
const SHA256_PATTERN = /^sha256:[a-f0-9]{64}$/;
|
|
const HEX_PATTERN = /^[a-f0-9]{1,64}$/;
|
|
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 WorkerCredentialManagementPkiRotationEvidenceError extends Error {
|
|
constructor(message) {
|
|
super(
|
|
`Worker credential management PKI rotation evidence failed: ${message}`,
|
|
);
|
|
this.name = 'WorkerCredentialManagementPkiRotationEvidenceError';
|
|
}
|
|
}
|
|
|
|
function fail(message) {
|
|
throw new WorkerCredentialManagementPkiRotationEvidenceError(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 ${
|
|
privateFile ? 'private ' : ''
|
|
}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 WorkerCredentialManagementPkiRotationEvidenceError) {
|
|
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 {
|
|
const text = `${JSON.stringify(value, null, 2)}\n`;
|
|
fs.writeFileSync(descriptor, text, { encoding: 'utf8' });
|
|
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 normalizeHex(value, label) {
|
|
if (typeof value !== 'string') fail(`${label} is invalid`);
|
|
const normalized = value.replace(/^0x/i, '').replace(/^0+/, '') || '0';
|
|
if (!HEX_PATTERN.test(normalized)) fail(`${label} is invalid`);
|
|
return normalized;
|
|
}
|
|
|
|
function parseCrlInspectionOutput(output) {
|
|
const lines = output.trim().split('\n');
|
|
if (lines.length !== 5) fail('CRL metadata is incomplete');
|
|
const fingerprint = /^SHA256 Fingerprint=([A-F0-9:]{95})$/i.exec(
|
|
lines[0],
|
|
)?.[1];
|
|
const issuer = /^issuer=(.+)$/.exec(lines[1])?.[1];
|
|
const lastUpdate = /^lastUpdate=(.+)$/.exec(lines[2])?.[1];
|
|
const nextUpdate = /^nextUpdate=(.+)$/.exec(lines[3])?.[1];
|
|
const number = /^crlNumber=(0x[A-F0-9]+)$/i.exec(lines[4])?.[1];
|
|
const parsedLast = Date.parse(lastUpdate ?? '');
|
|
const parsedNext = Date.parse(nextUpdate ?? '');
|
|
if (
|
|
!fingerprint ||
|
|
!issuer ||
|
|
!Number.isFinite(parsedLast) ||
|
|
!Number.isFinite(parsedNext) ||
|
|
parsedNext <= parsedLast ||
|
|
!number
|
|
) {
|
|
fail('CRL metadata is invalid');
|
|
}
|
|
return Object.freeze({
|
|
sha256: `sha256:${fingerprint.replaceAll(':', '').toLowerCase()}`,
|
|
issuerSha256: digest('qinglong3.worker-management.crl-issuer.v1', issuer),
|
|
number: normalizeHex(number, 'CRL number'),
|
|
lastUpdateMs: parsedLast,
|
|
nextUpdateMs: parsedNext,
|
|
});
|
|
}
|
|
|
|
function defaultInspectCrl(bytes) {
|
|
const result = spawnSync(
|
|
'openssl',
|
|
[
|
|
'crl',
|
|
'-inform',
|
|
'PEM',
|
|
'-noout',
|
|
'-fingerprint',
|
|
'-sha256',
|
|
'-issuer',
|
|
'-nameopt',
|
|
'RFC2253',
|
|
'-lastupdate',
|
|
'-nextupdate',
|
|
'-crlnumber',
|
|
],
|
|
{
|
|
input: bytes,
|
|
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('CRL is not accepted by OpenSSL');
|
|
}
|
|
return parseCrlInspectionOutput(result.stdout);
|
|
}
|
|
|
|
function exactCertificateBlocks(bytes, label) {
|
|
let value;
|
|
try {
|
|
value = new TextDecoder('utf-8', { fatal: true }).decode(bytes);
|
|
} catch {
|
|
fail(`${label} must be strict UTF-8`);
|
|
}
|
|
const pattern =
|
|
/-----BEGIN CERTIFICATE-----[\s\S]*?-----END CERTIFICATE-----/g;
|
|
const matches = value.match(pattern);
|
|
if (!matches || matches.length < 1 || matches.length > 16) {
|
|
fail(`${label} must contain 1 to 16 certificates`);
|
|
}
|
|
if (value.replace(pattern, '').trim() !== '') {
|
|
fail(`${label} contains unsupported data`);
|
|
}
|
|
return matches.map((match) => Buffer.from(`${match}\n`, 'utf8'));
|
|
}
|
|
|
|
function defaultInspectAuthoritySubject(bytes) {
|
|
const result = spawnSync(
|
|
'openssl',
|
|
['x509', '-noout', '-subject', '-nameopt', 'RFC2253'],
|
|
{
|
|
input: bytes,
|
|
encoding: 'utf8',
|
|
timeout: 10_000,
|
|
maxBuffer: 64 * 1024,
|
|
env: {
|
|
PATH: process.env.PATH,
|
|
LANG: 'C',
|
|
LC_ALL: 'C',
|
|
},
|
|
},
|
|
);
|
|
const subject = /^subject=(.+)$/.exec(result.stdout.trim())?.[1];
|
|
if (result.status !== 0 || result.signal !== null || !subject) {
|
|
fail('client issuer CA subject is not accepted by OpenSSL');
|
|
}
|
|
return subject;
|
|
}
|
|
|
|
function inspectAuthorityBundle(bytes, nowMs, label, inspectSubject) {
|
|
const blocks = exactCertificateBlocks(bytes, label);
|
|
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(`${label} contains an invalid certificate`);
|
|
}
|
|
const fingerprintSha256 = `sha256:${certificate.fingerprint256
|
|
.replaceAll(':', '')
|
|
.toLowerCase()}`;
|
|
const subject = inspectSubject(block);
|
|
if (
|
|
certificate.ca !== true ||
|
|
Date.parse(certificate.validFrom) > nowMs ||
|
|
Date.parse(certificate.validTo) <= nowMs ||
|
|
fingerprints.has(fingerprintSha256) ||
|
|
subjects.has(subject)
|
|
) {
|
|
fail(`${label} contains inactive or duplicate CA material`);
|
|
}
|
|
fingerprints.add(fingerprintSha256);
|
|
subjects.add(subject);
|
|
authorities.push(
|
|
Object.freeze({ certificate, fingerprintSha256, subject }),
|
|
);
|
|
}
|
|
return Object.freeze({
|
|
bundleSha256: rawDigest(bytes),
|
|
authorities: Object.freeze(authorities),
|
|
authoritySha256: Object.freeze(
|
|
authorities.map(({ fingerprintSha256 }) => fingerprintSha256).sort(),
|
|
),
|
|
});
|
|
} finally {
|
|
for (const block of blocks) block.fill(0);
|
|
}
|
|
}
|
|
|
|
function inspectClientIssuerAuthority(
|
|
issuerCaFile,
|
|
nowMs,
|
|
inspectSubject = defaultInspectAuthoritySubject,
|
|
) {
|
|
const bytes = readBuffer(issuerCaFile, 'client issuer CA', {
|
|
private: false,
|
|
maximum: 256 * 1024,
|
|
});
|
|
try {
|
|
const bundle = inspectAuthorityBundle(
|
|
bytes,
|
|
nowMs,
|
|
'client issuer CA',
|
|
inspectSubject,
|
|
);
|
|
if (bundle.authorities.length !== 1) {
|
|
fail('client issuer CA must contain exactly one authority');
|
|
}
|
|
const authority = bundle.authorities[0];
|
|
return Object.freeze({
|
|
bundleSha256: bundle.bundleSha256,
|
|
certificateSha256: authority.fingerprintSha256,
|
|
subjectSha256: digest(
|
|
'qinglong3.worker-management.crl-issuer.v1',
|
|
authority.subject,
|
|
),
|
|
certificate: authority.certificate,
|
|
});
|
|
} finally {
|
|
bytes.fill(0);
|
|
}
|
|
}
|
|
|
|
function inspectClientConfiguration(
|
|
configFile,
|
|
nowMs,
|
|
inspectSubject = defaultInspectAuthoritySubject,
|
|
) {
|
|
const configDocument = readJson(configFile, 'management client config');
|
|
try {
|
|
const config = exactObject(
|
|
configDocument.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 caBytes = 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 serverTrust = inspectAuthorityBundle(
|
|
caBytes,
|
|
nowMs,
|
|
'management server CA bundle',
|
|
inspectSubject,
|
|
);
|
|
const certificate = new X509Certificate(certificateBytes);
|
|
const privateKey = createPrivateKey(privateKeyBytes);
|
|
if (
|
|
certificate.ca !== false ||
|
|
certificate.checkPrivateKey(privateKey) !== true ||
|
|
!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 certificate profile is invalid');
|
|
}
|
|
return Object.freeze({
|
|
endpointSha256: digest(
|
|
'qinglong3.worker-management.endpoint.v1',
|
|
endpoint.toString(),
|
|
),
|
|
servernameSha256: digest(
|
|
'qinglong3.worker-management.servername.v1',
|
|
config.servername,
|
|
),
|
|
serverTrustBundleSha256: serverTrust.bundleSha256,
|
|
serverAuthoritySha256: serverTrust.authoritySha256,
|
|
clientCertificateSha256: `sha256:${certificate.fingerprint256
|
|
.replaceAll(':', '')
|
|
.toLowerCase()}`,
|
|
certificate,
|
|
});
|
|
} catch (error) {
|
|
if (error instanceof WorkerCredentialManagementPkiRotationEvidenceError) {
|
|
throw error;
|
|
}
|
|
fail('management client certificate material is invalid');
|
|
} finally {
|
|
caBytes.fill(0);
|
|
certificateBytes.fill(0);
|
|
privateKeyBytes.fill(0);
|
|
}
|
|
} finally {
|
|
configDocument.bytes.fill(0);
|
|
}
|
|
}
|
|
|
|
function decodeCanonicalBase64(value, label, maximum) {
|
|
if (
|
|
typeof value !== 'string' ||
|
|
value.length < 4 ||
|
|
value.length > maximum * 2 ||
|
|
!/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(
|
|
value,
|
|
)
|
|
) {
|
|
fail(`${label} encoding is invalid`);
|
|
}
|
|
const bytes = Buffer.from(value, 'base64');
|
|
if (
|
|
bytes.length < 1 ||
|
|
bytes.length > maximum ||
|
|
bytes.toString('base64') !== value
|
|
) {
|
|
bytes.fill(0);
|
|
fail(`${label} encoding is invalid`);
|
|
}
|
|
return bytes;
|
|
}
|
|
|
|
function parseKubernetesConfig(filePath) {
|
|
const configDocument = readJson(filePath, 'Kubernetes evidence config');
|
|
try {
|
|
const config = exactObject(
|
|
configDocument.value,
|
|
[
|
|
'schemaVersion',
|
|
'kubeconfigFile',
|
|
'context',
|
|
'namespace',
|
|
'deployment',
|
|
'labelSelector',
|
|
'apiTimeoutMs',
|
|
],
|
|
'Kubernetes evidence config',
|
|
);
|
|
if (
|
|
config.schemaVersion !== 1 ||
|
|
typeof config.kubeconfigFile !== 'string' ||
|
|
typeof config.context !== 'string' ||
|
|
!TOKEN_PATTERN.test(config.context) ||
|
|
config.namespace !== NAMESPACE ||
|
|
config.deployment !== DEPLOYMENT ||
|
|
config.labelSelector !== LABEL_SELECTOR ||
|
|
!Number.isSafeInteger(config.apiTimeoutMs) ||
|
|
config.apiTimeoutMs < 1_000 ||
|
|
config.apiTimeoutMs > 30_000
|
|
) {
|
|
fail('Kubernetes evidence config is invalid');
|
|
}
|
|
const kubeconfigDocument = readJson(
|
|
config.kubeconfigFile,
|
|
'Kubernetes evidence kubeconfig',
|
|
);
|
|
try {
|
|
const kubeconfig = exactObject(
|
|
kubeconfigDocument.value,
|
|
[
|
|
'apiVersion',
|
|
'kind',
|
|
'clusters',
|
|
'users',
|
|
'contexts',
|
|
'current-context',
|
|
],
|
|
'Kubernetes evidence kubeconfig',
|
|
);
|
|
if (
|
|
kubeconfig.apiVersion !== 'v1' ||
|
|
kubeconfig.kind !== 'Config' ||
|
|
kubeconfig['current-context'] !== config.context ||
|
|
!Array.isArray(kubeconfig.clusters) ||
|
|
kubeconfig.clusters.length !== 1 ||
|
|
!Array.isArray(kubeconfig.users) ||
|
|
kubeconfig.users.length !== 1 ||
|
|
!Array.isArray(kubeconfig.contexts) ||
|
|
kubeconfig.contexts.length !== 1
|
|
) {
|
|
fail('Kubernetes evidence kubeconfig topology is invalid');
|
|
}
|
|
const clusterEntry = exactObject(
|
|
kubeconfig.clusters[0],
|
|
['name', 'cluster'],
|
|
'Kubernetes cluster entry',
|
|
);
|
|
const userEntry = exactObject(
|
|
kubeconfig.users[0],
|
|
['name', 'user'],
|
|
'Kubernetes user entry',
|
|
);
|
|
const contextEntry = exactObject(
|
|
kubeconfig.contexts[0],
|
|
['name', 'context'],
|
|
'Kubernetes context entry',
|
|
);
|
|
const cluster = exactObject(
|
|
clusterEntry.cluster,
|
|
['server', 'certificate-authority-data'],
|
|
'Kubernetes cluster authority',
|
|
);
|
|
const context = exactObject(
|
|
contextEntry.context,
|
|
['cluster', 'user', 'namespace'],
|
|
'Kubernetes context authority',
|
|
);
|
|
const user = userEntry.user;
|
|
if (!user || typeof user !== 'object' || Array.isArray(user)) {
|
|
fail('Kubernetes user authority is invalid');
|
|
}
|
|
const userKeys = Object.keys(user).sort();
|
|
if (
|
|
JSON.stringify(userKeys) !== JSON.stringify(['token']) &&
|
|
JSON.stringify(userKeys) !==
|
|
JSON.stringify(['client-certificate-data', 'client-key-data'].sort())
|
|
) {
|
|
fail('Kubernetes user authority is not a static identity');
|
|
}
|
|
if (
|
|
typeof clusterEntry.name !== 'string' ||
|
|
!TOKEN_PATTERN.test(clusterEntry.name) ||
|
|
typeof userEntry.name !== 'string' ||
|
|
!TOKEN_PATTERN.test(userEntry.name) ||
|
|
contextEntry.name !== config.context ||
|
|
context.cluster !== clusterEntry.name ||
|
|
context.user !== userEntry.name ||
|
|
context.namespace !== NAMESPACE
|
|
) {
|
|
fail('Kubernetes evidence context is invalid');
|
|
}
|
|
let server;
|
|
try {
|
|
server = new URL(cluster.server);
|
|
} catch {
|
|
fail('Kubernetes API server is invalid');
|
|
}
|
|
if (
|
|
server.protocol !== 'https:' ||
|
|
server.username !== '' ||
|
|
server.password !== '' ||
|
|
(server.pathname !== '' && server.pathname !== '/') ||
|
|
server.search !== '' ||
|
|
server.hash !== '' ||
|
|
server.hostname.length < 1
|
|
) {
|
|
fail('Kubernetes API server authority is invalid');
|
|
}
|
|
const ca = decodeCanonicalBase64(
|
|
cluster['certificate-authority-data'],
|
|
'Kubernetes CA',
|
|
256 * 1024,
|
|
);
|
|
try {
|
|
new X509Certificate(ca);
|
|
} catch {
|
|
fail('Kubernetes CA is invalid');
|
|
} finally {
|
|
ca.fill(0);
|
|
}
|
|
if (Object.hasOwn(user, 'token')) {
|
|
if (
|
|
typeof user.token !== 'string' ||
|
|
user.token.length < 16 ||
|
|
user.token.length > 16 * 1024 ||
|
|
CONTROL_PATTERN.test(user.token)
|
|
) {
|
|
fail('Kubernetes static token is invalid');
|
|
}
|
|
} else {
|
|
const certificate = decodeCanonicalBase64(
|
|
user['client-certificate-data'],
|
|
'Kubernetes client certificate',
|
|
256 * 1024,
|
|
);
|
|
const privateKey = decodeCanonicalBase64(
|
|
user['client-key-data'],
|
|
'Kubernetes client private key',
|
|
256 * 1024,
|
|
);
|
|
try {
|
|
const parsedCertificate = new X509Certificate(certificate);
|
|
const parsedPrivateKey = createPrivateKey(privateKey);
|
|
if (!parsedCertificate.checkPrivateKey(parsedPrivateKey)) {
|
|
fail('Kubernetes client identity does not match');
|
|
}
|
|
} catch (error) {
|
|
if (
|
|
error instanceof WorkerCredentialManagementPkiRotationEvidenceError
|
|
) {
|
|
throw error;
|
|
}
|
|
fail('Kubernetes client identity is invalid');
|
|
} finally {
|
|
certificate.fill(0);
|
|
privateKey.fill(0);
|
|
}
|
|
}
|
|
return Object.freeze({
|
|
kubeconfigFile: config.kubeconfigFile,
|
|
context: config.context,
|
|
namespace: NAMESPACE,
|
|
deployment: DEPLOYMENT,
|
|
labelSelector: LABEL_SELECTOR,
|
|
apiTimeoutMs: config.apiTimeoutMs,
|
|
clusterServerSha256: digest(
|
|
'qinglong3.worker-management.kubernetes-server.v1',
|
|
server.toString(),
|
|
),
|
|
collectorSubjectSha256: digest(
|
|
'qinglong3.worker-management.kubernetes-subject.v1',
|
|
userEntry.name,
|
|
),
|
|
});
|
|
} finally {
|
|
kubeconfigDocument.bytes.fill(0);
|
|
}
|
|
} finally {
|
|
configDocument.bytes.fill(0);
|
|
}
|
|
}
|
|
|
|
function defaultRunKubectl(config, args) {
|
|
const binary = process.env.QL3_KUBECTL_BIN || 'kubectl';
|
|
return spawnSync(
|
|
binary,
|
|
[
|
|
'--kubeconfig',
|
|
config.kubeconfigFile,
|
|
'--context',
|
|
config.context,
|
|
`--request-timeout=${config.apiTimeoutMs}ms`,
|
|
...args,
|
|
],
|
|
{
|
|
encoding: 'utf8',
|
|
timeout: config.apiTimeoutMs + 2_000,
|
|
maxBuffer: 2 * 1024 * 1024,
|
|
env: {
|
|
PATH: process.env.PATH,
|
|
LANG: 'C',
|
|
LC_ALL: 'C',
|
|
},
|
|
},
|
|
);
|
|
}
|
|
|
|
function kubectlOutput(config, args, runKubectl, label) {
|
|
const result = runKubectl(config, args);
|
|
if (
|
|
!result ||
|
|
result.status !== 0 ||
|
|
result.signal !== null ||
|
|
typeof result.stdout !== 'string' ||
|
|
result.stdout.length < 1 ||
|
|
result.stdout.length > 2 * 1024 * 1024
|
|
) {
|
|
fail(`${label} Kubernetes observation failed`);
|
|
}
|
|
return result.stdout.trim();
|
|
}
|
|
|
|
const REVIEWED_AUTHORITY = Object.freeze([
|
|
Object.freeze({
|
|
verb: 'get',
|
|
resource: `deployments.apps/${DEPLOYMENT}`,
|
|
allowed: true,
|
|
}),
|
|
Object.freeze({ verb: 'list', resource: 'pods', allowed: true }),
|
|
Object.freeze({ verb: 'get', resource: 'secrets', allowed: false }),
|
|
Object.freeze({ verb: 'list', resource: 'secrets', allowed: false }),
|
|
Object.freeze({ verb: 'get', resource: 'configmaps', allowed: false }),
|
|
Object.freeze({ verb: 'list', resource: 'deployments.apps', allowed: false }),
|
|
...['create', 'update', 'patch', 'delete'].flatMap((verb) =>
|
|
['deployments.apps', 'secrets', 'pods'].map((resource) =>
|
|
Object.freeze({ verb, resource, allowed: false }),
|
|
),
|
|
),
|
|
Object.freeze({ verb: 'create', resource: 'pods/exec', allowed: false }),
|
|
Object.freeze({
|
|
verb: 'create',
|
|
resource: 'pods/portforward',
|
|
allowed: false,
|
|
}),
|
|
Object.freeze({
|
|
verb: 'create',
|
|
resource: 'serviceaccounts/token',
|
|
allowed: false,
|
|
}),
|
|
]);
|
|
|
|
function collectKubernetesSnapshot(
|
|
kubernetesFile,
|
|
_crlSha256,
|
|
runKubectl = defaultRunKubectl,
|
|
) {
|
|
const config = parseKubernetesConfig(kubernetesFile);
|
|
const authorization = REVIEWED_AUTHORITY.map((check) => {
|
|
const output = kubectlOutput(
|
|
config,
|
|
['--namespace', NAMESPACE, 'auth', 'can-i', check.verb, check.resource],
|
|
runKubectl,
|
|
'authorization',
|
|
);
|
|
if (!['yes', 'no'].includes(output)) {
|
|
fail('Kubernetes authorization response is invalid');
|
|
}
|
|
return Object.freeze({ ...check, observed: output === 'yes' });
|
|
});
|
|
const deployment = jsonFromBytes(
|
|
Buffer.from(
|
|
kubectlOutput(
|
|
config,
|
|
[
|
|
'--namespace',
|
|
NAMESPACE,
|
|
'get',
|
|
'deployment.apps',
|
|
DEPLOYMENT,
|
|
'--output=json',
|
|
],
|
|
runKubectl,
|
|
'Deployment',
|
|
),
|
|
),
|
|
'Deployment response',
|
|
);
|
|
const pods = jsonFromBytes(
|
|
Buffer.from(
|
|
kubectlOutput(
|
|
config,
|
|
[
|
|
'--namespace',
|
|
NAMESPACE,
|
|
'get',
|
|
'pods',
|
|
'--selector',
|
|
LABEL_SELECTOR,
|
|
'--output=json',
|
|
],
|
|
runKubectl,
|
|
'Pod',
|
|
),
|
|
),
|
|
'Pod response',
|
|
);
|
|
return Object.freeze({
|
|
clusterServerSha256: config.clusterServerSha256,
|
|
collectorSubjectSha256: config.collectorSubjectSha256,
|
|
authorization,
|
|
deployment,
|
|
pods,
|
|
});
|
|
}
|
|
|
|
function readyCondition(conditions, type) {
|
|
return (
|
|
Array.isArray(conditions) &&
|
|
conditions.some(
|
|
(condition) => condition?.type === type && condition?.status === 'True',
|
|
)
|
|
);
|
|
}
|
|
|
|
function normalizeKubernetesSnapshot(value, 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?.[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 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,
|
|
);
|
|
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' ||
|
|
!readyCondition(pod.status?.conditions, 'Ready') ||
|
|
pod.status?.containerStatuses?.some(
|
|
(container) =>
|
|
container.name === 'management' && container.ready === true,
|
|
) !== 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,
|
|
observedGeneration: status.observedGeneration,
|
|
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(),
|
|
),
|
|
podTemplateSha256: digest(
|
|
'qinglong3.worker-management.pod-template.v1',
|
|
current[0].metadata.labels['pod-template-hash'],
|
|
),
|
|
replicas: 2,
|
|
distinctNodes: true,
|
|
exactReadOnlyCollectorAuthority: true,
|
|
secretReadDenied: true,
|
|
mutationDenied: true,
|
|
});
|
|
}
|
|
|
|
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 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 validKubernetesEvidence(value) {
|
|
return (
|
|
exactKeys(value, [
|
|
'clusterServerSha256',
|
|
'collectorSubjectSha256',
|
|
'deploymentUidSha256',
|
|
'deploymentResourceVersionSha256',
|
|
'deploymentGeneration',
|
|
'observedGeneration',
|
|
'crlAnnotationSha256',
|
|
'podUidSha256',
|
|
'podNodeSha256',
|
|
'podTemplateSha256',
|
|
'replicas',
|
|
'distinctNodes',
|
|
'exactReadOnlyCollectorAuthority',
|
|
'secretReadDenied',
|
|
'mutationDenied',
|
|
]) &&
|
|
[
|
|
value.clusterServerSha256,
|
|
value.collectorSubjectSha256,
|
|
value.deploymentUidSha256,
|
|
value.deploymentResourceVersionSha256,
|
|
value.crlAnnotationSha256,
|
|
value.podTemplateSha256,
|
|
].every((entry) => SHA256_PATTERN.test(entry)) &&
|
|
Number.isSafeInteger(value.deploymentGeneration) &&
|
|
value.deploymentGeneration >= 1 &&
|
|
value.observedGeneration === value.deploymentGeneration &&
|
|
value.replicas === 2 &&
|
|
value.distinctNodes === true &&
|
|
value.exactReadOnlyCollectorAuthority === true &&
|
|
value.secretReadDenied === true &&
|
|
value.mutationDenied === true &&
|
|
Array.isArray(value.podUidSha256) &&
|
|
value.podUidSha256.length === 2 &&
|
|
value.podUidSha256.every((entry) => SHA256_PATTERN.test(entry)) &&
|
|
new Set(value.podUidSha256).size === 2 &&
|
|
Array.isArray(value.podNodeSha256) &&
|
|
value.podNodeSha256.length === 2 &&
|
|
value.podNodeSha256.every((entry) => SHA256_PATTERN.test(entry)) &&
|
|
new Set(value.podNodeSha256).size === 2
|
|
);
|
|
}
|
|
|
|
function validateBeforeState(state) {
|
|
const findings = [];
|
|
const add = (code) => findings.push(Object.freeze({ code }));
|
|
if (
|
|
!exactKeys(state, [
|
|
'schemaVersion',
|
|
'fixture',
|
|
'observedAt',
|
|
'identity',
|
|
'transport',
|
|
'pki',
|
|
'kubernetes',
|
|
'access',
|
|
'gates',
|
|
]) ||
|
|
state?.schemaVersion !== 2 ||
|
|
state?.fixture !== STATE_FIXTURE ||
|
|
!isIsoTime(state?.observedAt)
|
|
) {
|
|
add('QL3_WORKER_MANAGEMENT_PKI_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_PKI_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),
|
|
) ||
|
|
new Set(state.transport.serverAuthoritySha256).size !==
|
|
state.transport.serverAuthoritySha256.length ||
|
|
JSON.stringify(state.transport.serverAuthoritySha256) !==
|
|
JSON.stringify([...state.transport.serverAuthoritySha256].sort()) ||
|
|
state?.transport?.oldClientCertificateSha256 ===
|
|
state?.transport?.newClientCertificateSha256
|
|
) {
|
|
add('QL3_WORKER_MANAGEMENT_PKI_STATE_TRANSPORT');
|
|
}
|
|
if (
|
|
!exactKeys(state?.pki, [
|
|
'clientIssuerBundleSha256',
|
|
'clientIssuerCaSha256',
|
|
'clientIssuerSubjectSha256',
|
|
'crlSha256',
|
|
'crlIssuerSha256',
|
|
'crlNumber',
|
|
'lastUpdateMs',
|
|
'nextUpdateMs',
|
|
]) ||
|
|
!SHA256_PATTERN.test(state?.pki?.clientIssuerBundleSha256) ||
|
|
!SHA256_PATTERN.test(state?.pki?.clientIssuerCaSha256) ||
|
|
!SHA256_PATTERN.test(state?.pki?.clientIssuerSubjectSha256) ||
|
|
!SHA256_PATTERN.test(state?.pki?.crlSha256) ||
|
|
!SHA256_PATTERN.test(state?.pki?.crlIssuerSha256) ||
|
|
state?.pki?.clientIssuerSubjectSha256 !==
|
|
state?.pki?.crlIssuerSha256 ||
|
|
!HEX_PATTERN.test(state?.pki?.crlNumber) ||
|
|
!Number.isSafeInteger(state?.pki?.lastUpdateMs) ||
|
|
!Number.isSafeInteger(state?.pki?.nextUpdateMs) ||
|
|
state.pki.nextUpdateMs <= state.pki.lastUpdateMs
|
|
) {
|
|
add('QL3_WORKER_MANAGEMENT_PKI_STATE_CRL');
|
|
}
|
|
if (!validKubernetesEvidence(state?.kubernetes)) {
|
|
add('QL3_WORKER_MANAGEMENT_PKI_STATE_KUBERNETES');
|
|
}
|
|
if (
|
|
!exactKeys(state?.access, [
|
|
'oldCertificateStatus',
|
|
'newCertificateStatus',
|
|
]) ||
|
|
state?.access?.oldCertificateStatus !== 200 ||
|
|
state?.access?.newCertificateStatus !== 200
|
|
) {
|
|
add('QL3_WORKER_MANAGEMENT_PKI_STATE_ACCESS');
|
|
}
|
|
if (
|
|
!exactKeys(state?.gates, [
|
|
'externalIdentity',
|
|
'sameEndpointAndServerTrust',
|
|
'serverTrustSeparatedFromClientIssuer',
|
|
'sameClientIssuer',
|
|
'distinctClientCertificates',
|
|
'bothCertificatesInitiallyAccepted',
|
|
'crlBoundDeploymentReady',
|
|
'readOnlyCollectorAuthority',
|
|
'passed',
|
|
]) ||
|
|
Object.values(state?.gates ?? {}).some((entry) => entry !== true)
|
|
) {
|
|
add('QL3_WORKER_MANAGEMENT_PKI_STATE_GATES');
|
|
}
|
|
if (containsSensitiveMaterial(state)) {
|
|
add('QL3_WORKER_MANAGEMENT_PKI_STATE_SECRET_EXPOSURE');
|
|
}
|
|
return Object.freeze({
|
|
compatible: findings.length === 0,
|
|
findings: Object.freeze(findings),
|
|
});
|
|
}
|
|
|
|
function validateWorkerCredentialManagementPkiRotationEvidence(report) {
|
|
const findings = [];
|
|
const add = (code) => findings.push(Object.freeze({ code }));
|
|
if (
|
|
!exactKeys(report, [
|
|
'schemaVersion',
|
|
'fixture',
|
|
'observedAt',
|
|
'source',
|
|
'identity',
|
|
'transport',
|
|
'pki',
|
|
'kubernetes',
|
|
'gates',
|
|
]) ||
|
|
report?.schemaVersion !== 2 ||
|
|
report?.fixture !== FIXTURE ||
|
|
!isIsoTime(report?.observedAt)
|
|
) {
|
|
add('QL3_WORKER_MANAGEMENT_PKI_EVIDENCE_SHAPE');
|
|
}
|
|
if (
|
|
!exactKeys(report?.source, [
|
|
'beforeStateSha256',
|
|
'ceremonyReportSha256',
|
|
'durableAuditReportSha256',
|
|
'ceremonyFixture',
|
|
'durableAuditFixture',
|
|
]) ||
|
|
![
|
|
report?.source?.beforeStateSha256,
|
|
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_PKI_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_PKI_EVIDENCE_IDENTITY');
|
|
}
|
|
const transport = report?.transport;
|
|
if (
|
|
!exactKeys(transport, [
|
|
'endpointSha256',
|
|
'servernameSha256',
|
|
'serverTrustBundleSha256',
|
|
'serverAuthoritySha256',
|
|
'commandSha256',
|
|
'oldClientCertificateSha256',
|
|
'newClientCertificateSha256',
|
|
'beforeOldStatus',
|
|
'beforeNewStatus',
|
|
'afterOldStatus',
|
|
'afterOldCode',
|
|
'afterNewStatus',
|
|
]) ||
|
|
![
|
|
transport?.endpointSha256,
|
|
transport?.servernameSha256,
|
|
transport?.serverTrustBundleSha256,
|
|
transport?.commandSha256,
|
|
transport?.oldClientCertificateSha256,
|
|
transport?.newClientCertificateSha256,
|
|
].every((entry) => SHA256_PATTERN.test(entry)) ||
|
|
!Array.isArray(transport?.serverAuthoritySha256) ||
|
|
transport.serverAuthoritySha256.length < 1 ||
|
|
transport.serverAuthoritySha256.length > 16 ||
|
|
transport.serverAuthoritySha256.some(
|
|
(entry) => !SHA256_PATTERN.test(entry),
|
|
) ||
|
|
new Set(transport.serverAuthoritySha256).size !==
|
|
transport.serverAuthoritySha256.length ||
|
|
JSON.stringify(transport.serverAuthoritySha256) !==
|
|
JSON.stringify([...transport.serverAuthoritySha256].sort()) ||
|
|
transport?.oldClientCertificateSha256 ===
|
|
transport?.newClientCertificateSha256 ||
|
|
transport?.beforeOldStatus !== 200 ||
|
|
transport?.beforeNewStatus !== 200 ||
|
|
transport?.afterOldStatus !== 401 ||
|
|
transport?.afterOldCode !== 'client_certificate_required' ||
|
|
transport?.afterNewStatus !== 200
|
|
) {
|
|
add('QL3_WORKER_MANAGEMENT_PKI_EVIDENCE_TRANSPORT');
|
|
}
|
|
const pki = report?.pki;
|
|
if (
|
|
!exactKeys(pki, [
|
|
'clientIssuerBundleSha256',
|
|
'clientIssuerCaSha256',
|
|
'clientIssuerSubjectSha256',
|
|
'beforeCrlSha256',
|
|
'afterCrlSha256',
|
|
'crlIssuerSha256',
|
|
'beforeCrlNumber',
|
|
'afterCrlNumber',
|
|
'crlNumberIncreased',
|
|
'oldCertificateRevoked',
|
|
'replacementCertificateAccepted',
|
|
]) ||
|
|
!SHA256_PATTERN.test(pki?.clientIssuerBundleSha256) ||
|
|
!SHA256_PATTERN.test(pki?.clientIssuerCaSha256) ||
|
|
!SHA256_PATTERN.test(pki?.clientIssuerSubjectSha256) ||
|
|
!SHA256_PATTERN.test(pki?.beforeCrlSha256) ||
|
|
!SHA256_PATTERN.test(pki?.afterCrlSha256) ||
|
|
pki?.beforeCrlSha256 === pki?.afterCrlSha256 ||
|
|
!SHA256_PATTERN.test(pki?.crlIssuerSha256) ||
|
|
pki?.clientIssuerSubjectSha256 !== pki?.crlIssuerSha256 ||
|
|
!HEX_PATTERN.test(pki?.beforeCrlNumber) ||
|
|
!HEX_PATTERN.test(pki?.afterCrlNumber) ||
|
|
BigInt(`0x${pki?.afterCrlNumber ?? '0'}`) <=
|
|
BigInt(`0x${pki?.beforeCrlNumber ?? '0'}`) ||
|
|
pki?.crlNumberIncreased !== true ||
|
|
pki?.oldCertificateRevoked !== true ||
|
|
pki?.replacementCertificateAccepted !== true
|
|
) {
|
|
add('QL3_WORKER_MANAGEMENT_PKI_EVIDENCE_CRL');
|
|
}
|
|
const kubernetes = report?.kubernetes;
|
|
if (
|
|
!exactKeys(kubernetes, [
|
|
'clusterServerSha256',
|
|
'collectorSubjectSha256',
|
|
'deploymentUidSha256',
|
|
'beforeDeploymentResourceVersionSha256',
|
|
'afterDeploymentResourceVersionSha256',
|
|
'beforeGeneration',
|
|
'afterGeneration',
|
|
'beforeCrlAnnotationSha256',
|
|
'afterCrlAnnotationSha256',
|
|
'beforePodUidSha256',
|
|
'afterPodUidSha256',
|
|
'oldPodsFullyReplaced',
|
|
'twoReadyReplicasOnDistinctNodes',
|
|
'exactReadOnlyCollectorAuthority',
|
|
'secretReadDenied',
|
|
'mutationDenied',
|
|
]) ||
|
|
![
|
|
kubernetes?.clusterServerSha256,
|
|
kubernetes?.collectorSubjectSha256,
|
|
kubernetes?.deploymentUidSha256,
|
|
kubernetes?.beforeDeploymentResourceVersionSha256,
|
|
kubernetes?.afterDeploymentResourceVersionSha256,
|
|
kubernetes?.beforeCrlAnnotationSha256,
|
|
kubernetes?.afterCrlAnnotationSha256,
|
|
].every((entry) => SHA256_PATTERN.test(entry)) ||
|
|
!Number.isSafeInteger(kubernetes?.beforeGeneration) ||
|
|
!Number.isSafeInteger(kubernetes?.afterGeneration) ||
|
|
kubernetes.afterGeneration <= kubernetes.beforeGeneration ||
|
|
kubernetes.beforeDeploymentResourceVersionSha256 ===
|
|
kubernetes.afterDeploymentResourceVersionSha256 ||
|
|
kubernetes.beforeCrlAnnotationSha256 ===
|
|
kubernetes.afterCrlAnnotationSha256 ||
|
|
!Array.isArray(kubernetes.beforePodUidSha256) ||
|
|
kubernetes.beforePodUidSha256.length !== 2 ||
|
|
!Array.isArray(kubernetes.afterPodUidSha256) ||
|
|
kubernetes.afterPodUidSha256.length !== 2 ||
|
|
[...kubernetes.beforePodUidSha256, ...kubernetes.afterPodUidSha256].some(
|
|
(entry) => !SHA256_PATTERN.test(entry),
|
|
) ||
|
|
kubernetes.beforePodUidSha256.some((entry) =>
|
|
kubernetes.afterPodUidSha256.includes(entry),
|
|
) ||
|
|
kubernetes.oldPodsFullyReplaced !== true ||
|
|
kubernetes.twoReadyReplicasOnDistinctNodes !== true ||
|
|
kubernetes.exactReadOnlyCollectorAuthority !== true ||
|
|
kubernetes.secretReadDenied !== true ||
|
|
kubernetes.mutationDenied !== true
|
|
) {
|
|
add('QL3_WORKER_MANAGEMENT_PKI_EVIDENCE_KUBERNETES');
|
|
}
|
|
if (
|
|
!exactKeys(report?.gates, [
|
|
'sourceReportsBound',
|
|
'externalIdentityBound',
|
|
'serverTrustSeparatedFromClientIssuer',
|
|
'sameClientIssuer',
|
|
'oldAndReplacementInitiallyAccepted',
|
|
'crlMonotonic',
|
|
'deploymentRolled',
|
|
'oldPodsRetired',
|
|
'revokedCertificateRejected',
|
|
'replacementCertificateAccepted',
|
|
'readOnlyCollectorAuthority',
|
|
'passed',
|
|
]) ||
|
|
Object.values(report?.gates ?? {}).some((entry) => entry !== true)
|
|
) {
|
|
add('QL3_WORKER_MANAGEMENT_PKI_EVIDENCE_GATES');
|
|
}
|
|
if (containsSensitiveMaterial(report)) {
|
|
add('QL3_WORKER_MANAGEMENT_PKI_EVIDENCE_SECRET_EXPOSURE');
|
|
}
|
|
return Object.freeze({
|
|
compatible: findings.length === 0,
|
|
findings: Object.freeze(findings),
|
|
});
|
|
}
|
|
|
|
function exactInspectCommand(value, normalize) {
|
|
const command = normalize(value);
|
|
if (command?.operation !== 'worker-credential.inspect') {
|
|
fail('evidence command must be worker-credential.inspect');
|
|
}
|
|
return command;
|
|
}
|
|
|
|
function sameTransport(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(
|
|
'client certificates must be distinct on one endpoint and server trust',
|
|
);
|
|
}
|
|
}
|
|
|
|
function bindClientIssuer(oldProfile, newProfile, issuer, crl) {
|
|
const issued = [oldProfile, newProfile].every(
|
|
(profile) =>
|
|
profile.certificate.checkIssued(issuer.certificate) &&
|
|
profile.certificate.verify(issuer.certificate.publicKey),
|
|
);
|
|
if (!issued || crl.issuerSha256 !== issuer.subjectSha256) {
|
|
fail('client certificates and CRL are not bound to one explicit issuer CA');
|
|
}
|
|
}
|
|
|
|
async function invokeClient(execute, paths, expected) {
|
|
try {
|
|
await execute(paths);
|
|
if (expected !== 200) fail('revoked 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 WorkerCredentialManagementPkiRotationEvidenceError) {
|
|
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,
|
|
inspectIssuer: inspectClientIssuerAuthority,
|
|
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',
|
|
'inspectIssuer',
|
|
'inspectAuthoritySubject',
|
|
'inspectCrl',
|
|
'collectKubernetes',
|
|
],
|
|
'evidence dependencies',
|
|
);
|
|
if (
|
|
Object.values(dependencies).some((entry) => typeof entry !== 'function')
|
|
) {
|
|
fail('evidence dependencies are invalid');
|
|
}
|
|
return Object.freeze(dependencies);
|
|
}
|
|
|
|
function clientPaths(options, configFile) {
|
|
return Object.freeze({
|
|
configFile,
|
|
commandFile: options.commandFile,
|
|
assertionFile: options.assertionFile,
|
|
});
|
|
}
|
|
|
|
function identityFromFile(filePath, nowMs) {
|
|
const bytes = readBuffer(filePath, 'identity assertion');
|
|
try {
|
|
const assertion = bytes.toString('ascii');
|
|
if (bytes.some((byte) => byte > 0x7f)) {
|
|
fail('identity assertion encoding is invalid');
|
|
}
|
|
return assertionIdentity(assertion, nowMs);
|
|
} finally {
|
|
bytes.fill(0);
|
|
}
|
|
}
|
|
|
|
function crlFromFile(filePath, inspectCrl, nowMs) {
|
|
const bytes = readBuffer(filePath, 'client certificate revocation list', {
|
|
private: false,
|
|
maximum: 256 * 1024,
|
|
});
|
|
try {
|
|
const inspected = inspectCrl(bytes);
|
|
exactObject(
|
|
inspected,
|
|
['sha256', 'issuerSha256', 'number', 'lastUpdateMs', 'nextUpdateMs'],
|
|
'CRL inspection',
|
|
);
|
|
if (
|
|
!SHA256_PATTERN.test(inspected.sha256) ||
|
|
!SHA256_PATTERN.test(inspected.issuerSha256) ||
|
|
!HEX_PATTERN.test(inspected.number) ||
|
|
!Number.isSafeInteger(inspected.lastUpdateMs) ||
|
|
!Number.isSafeInteger(inspected.nextUpdateMs) ||
|
|
inspected.lastUpdateMs > nowMs + 5 * 60_000 ||
|
|
inspected.nextUpdateMs <= nowMs ||
|
|
inspected.nextUpdateMs <= inspected.lastUpdateMs
|
|
) {
|
|
fail('CRL inspection is not current and canonical');
|
|
}
|
|
return Object.freeze({ ...inspected });
|
|
} finally {
|
|
bytes.fill(0);
|
|
}
|
|
}
|
|
|
|
function commandFromFile(filePath, normalize) {
|
|
const document = readJson(filePath, 'management inspect command');
|
|
try {
|
|
const command = exactInspectCommand(document.value, normalize);
|
|
return Object.freeze({
|
|
command,
|
|
sha256: digest(
|
|
'qinglong3.worker-management.pki-evidence-command.v1',
|
|
JSON.stringify(command),
|
|
),
|
|
});
|
|
} finally {
|
|
document.bytes.fill(0);
|
|
}
|
|
}
|
|
|
|
async function runBeforeEvidence(options, dependencies = {}) {
|
|
exactObject(
|
|
options,
|
|
[
|
|
'oldConfigFile',
|
|
'newConfigFile',
|
|
'assertionFile',
|
|
'commandFile',
|
|
'kubernetesFile',
|
|
'issuerCaFile',
|
|
'crlFile',
|
|
'outputFile',
|
|
],
|
|
'before evidence options',
|
|
);
|
|
unusedOutput(options.outputFile);
|
|
const runtime = reviewedDependencies(dependencies);
|
|
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,
|
|
);
|
|
sameTransport(oldProfile, newProfile);
|
|
const issuer = runtime.inspectIssuer(
|
|
options.issuerCaFile,
|
|
nowMs,
|
|
runtime.inspectAuthoritySubject,
|
|
);
|
|
const crl = crlFromFile(options.crlFile, runtime.inspectCrl, nowMs);
|
|
bindClientIssuer(oldProfile, newProfile, issuer, crl);
|
|
const kubernetes = normalizeKubernetesSnapshot(
|
|
await runtime.collectKubernetes(options.kubernetesFile, crl.sha256),
|
|
crl.sha256,
|
|
);
|
|
const oldAccess = await invokeClient(
|
|
runtime.execute,
|
|
clientPaths(options, options.oldConfigFile),
|
|
200,
|
|
);
|
|
const newAccess = await invokeClient(
|
|
runtime.execute,
|
|
clientPaths(options, options.newConfigFile),
|
|
200,
|
|
);
|
|
const state = Object.freeze({
|
|
schemaVersion: 2,
|
|
fixture: STATE_FIXTURE,
|
|
observedAt: new Date(nowMs).toISOString(),
|
|
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,
|
|
}),
|
|
pki: Object.freeze({
|
|
clientIssuerBundleSha256: issuer.bundleSha256,
|
|
clientIssuerCaSha256: issuer.certificateSha256,
|
|
clientIssuerSubjectSha256: issuer.subjectSha256,
|
|
crlSha256: crl.sha256,
|
|
crlIssuerSha256: crl.issuerSha256,
|
|
crlNumber: crl.number,
|
|
lastUpdateMs: crl.lastUpdateMs,
|
|
nextUpdateMs: crl.nextUpdateMs,
|
|
}),
|
|
kubernetes,
|
|
access: Object.freeze({
|
|
oldCertificateStatus: oldAccess.statusCode,
|
|
newCertificateStatus: newAccess.statusCode,
|
|
}),
|
|
gates: Object.freeze({
|
|
externalIdentity: true,
|
|
sameEndpointAndServerTrust: true,
|
|
serverTrustSeparatedFromClientIssuer: true,
|
|
sameClientIssuer: true,
|
|
distinctClientCertificates: true,
|
|
bothCertificatesInitiallyAccepted: true,
|
|
crlBoundDeploymentReady: true,
|
|
readOnlyCollectorAuthority: true,
|
|
passed: true,
|
|
}),
|
|
});
|
|
const audit = validateBeforeState(state);
|
|
if (!audit.compatible) {
|
|
fail(
|
|
`before state failed audit: ${audit.findings
|
|
.map(({ code }) => code)
|
|
.join(',')}`,
|
|
);
|
|
}
|
|
writeNoReplace(options.outputFile, state);
|
|
return state;
|
|
}
|
|
|
|
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 assertSameBeforeSource(
|
|
before,
|
|
identity,
|
|
command,
|
|
oldProfile,
|
|
newProfile,
|
|
issuer,
|
|
) {
|
|
const subjectSha256 = digest(
|
|
'qinglong3.worker-management.subject.v1',
|
|
identity.subject,
|
|
);
|
|
if (
|
|
before.identity.issuer !== identity.issuer ||
|
|
before.identity.subjectSha256 !== subjectSha256 ||
|
|
before.transport.endpointSha256 !== oldProfile.endpointSha256 ||
|
|
before.transport.servernameSha256 !== oldProfile.servernameSha256 ||
|
|
before.transport.serverTrustBundleSha256 !==
|
|
oldProfile.serverTrustBundleSha256 ||
|
|
JSON.stringify(before.transport.serverAuthoritySha256) !==
|
|
JSON.stringify(oldProfile.serverAuthoritySha256) ||
|
|
before.transport.commandSha256 !== command.sha256 ||
|
|
before.transport.oldClientCertificateSha256 !==
|
|
oldProfile.clientCertificateSha256 ||
|
|
before.transport.newClientCertificateSha256 !==
|
|
newProfile.clientCertificateSha256 ||
|
|
before.pki.clientIssuerBundleSha256 !== issuer.bundleSha256 ||
|
|
before.pki.clientIssuerCaSha256 !== issuer.certificateSha256 ||
|
|
before.pki.clientIssuerSubjectSha256 !== issuer.subjectSha256
|
|
) {
|
|
fail('after phase authority does not match the before state');
|
|
}
|
|
return subjectSha256;
|
|
}
|
|
|
|
async function runAfterEvidence(options, dependencies = {}) {
|
|
exactObject(
|
|
options,
|
|
[
|
|
'beforeFile',
|
|
'oldConfigFile',
|
|
'newConfigFile',
|
|
'assertionFile',
|
|
'commandFile',
|
|
'kubernetesFile',
|
|
'issuerCaFile',
|
|
'crlFile',
|
|
'ceremonyReportFile',
|
|
'durableAuditReportFile',
|
|
'outputFile',
|
|
],
|
|
'after evidence options',
|
|
);
|
|
unusedOutput(options.outputFile);
|
|
const runtime = reviewedDependencies(dependencies);
|
|
const nowMs = runtime.now();
|
|
if (!Number.isSafeInteger(nowMs) || nowMs < 0) fail('clock is invalid');
|
|
const beforeDocument = readJson(options.beforeFile, 'before state');
|
|
const ceremonyDocument = readJson(
|
|
options.ceremonyReportFile,
|
|
'ceremony report',
|
|
);
|
|
const durableDocument = readJson(
|
|
options.durableAuditReportFile,
|
|
'durable audit report',
|
|
);
|
|
try {
|
|
const beforeAudit = validateBeforeState(beforeDocument.value);
|
|
if (!beforeAudit.compatible) fail('before state is incompatible');
|
|
assertSourceReports(ceremonyDocument, durableDocument);
|
|
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,
|
|
);
|
|
sameTransport(oldProfile, newProfile);
|
|
const issuer = runtime.inspectIssuer(
|
|
options.issuerCaFile,
|
|
nowMs,
|
|
runtime.inspectAuthoritySubject,
|
|
);
|
|
const subjectSha256 = assertSameBeforeSource(
|
|
beforeDocument.value,
|
|
identity,
|
|
command,
|
|
oldProfile,
|
|
newProfile,
|
|
issuer,
|
|
);
|
|
const ceremonySubjects = [
|
|
ceremonyDocument.value.identity.requesterSubjectSha256,
|
|
ceremonyDocument.value.identity.reviewerSubjectSha256,
|
|
];
|
|
if (
|
|
identity.issuer !== ceremonyDocument.value.identity.issuer ||
|
|
!ceremonySubjects.includes(subjectSha256) ||
|
|
![
|
|
durableDocument.value.durableState.requesterSubjectSha256,
|
|
durableDocument.value.durableState.reviewerSubjectSha256,
|
|
].includes(subjectSha256)
|
|
) {
|
|
fail('after phase identity is not bound to the reviewed ceremony');
|
|
}
|
|
const crl = crlFromFile(options.crlFile, runtime.inspectCrl, nowMs);
|
|
bindClientIssuer(oldProfile, newProfile, issuer, crl);
|
|
const beforeCrl = beforeDocument.value.pki;
|
|
if (
|
|
crl.sha256 === beforeCrl.crlSha256 ||
|
|
crl.issuerSha256 !== beforeCrl.crlIssuerSha256 ||
|
|
BigInt(`0x${crl.number}`) <= BigInt(`0x${beforeCrl.crlNumber}`) ||
|
|
crl.lastUpdateMs <= beforeCrl.lastUpdateMs
|
|
) {
|
|
fail('CRL did not advance monotonically');
|
|
}
|
|
const kubernetes = normalizeKubernetesSnapshot(
|
|
await runtime.collectKubernetes(options.kubernetesFile, crl.sha256),
|
|
crl.sha256,
|
|
);
|
|
const beforeKubernetes = beforeDocument.value.kubernetes;
|
|
if (
|
|
kubernetes.clusterServerSha256 !== beforeKubernetes.clusterServerSha256 ||
|
|
kubernetes.collectorSubjectSha256 !==
|
|
beforeKubernetes.collectorSubjectSha256 ||
|
|
kubernetes.deploymentUidSha256 !== beforeKubernetes.deploymentUidSha256 ||
|
|
kubernetes.deploymentGeneration <=
|
|
beforeKubernetes.deploymentGeneration ||
|
|
kubernetes.deploymentResourceVersionSha256 ===
|
|
beforeKubernetes.deploymentResourceVersionSha256 ||
|
|
beforeKubernetes.podUidSha256.some((entry) =>
|
|
kubernetes.podUidSha256.includes(entry),
|
|
)
|
|
) {
|
|
fail('Kubernetes rollout did not replace the complete old generation');
|
|
}
|
|
const oldAccess = await invokeClient(
|
|
runtime.execute,
|
|
clientPaths(options, options.oldConfigFile),
|
|
401,
|
|
);
|
|
const newAccess = await invokeClient(
|
|
runtime.execute,
|
|
clientPaths(options, options.newConfigFile),
|
|
200,
|
|
);
|
|
const report = Object.freeze({
|
|
schemaVersion: 2,
|
|
fixture: FIXTURE,
|
|
observedAt: new Date(nowMs).toISOString(),
|
|
source: Object.freeze({
|
|
beforeStateSha256: rawDigest(beforeDocument.bytes),
|
|
ceremonyReportSha256: rawDigest(ceremonyDocument.bytes),
|
|
durableAuditReportSha256: rawDigest(durableDocument.bytes),
|
|
ceremonyFixture: CEREMONY_FIXTURE,
|
|
durableAuditFixture: DURABLE_FIXTURE,
|
|
}),
|
|
identity: Object.freeze({
|
|
providerKind: 'external_oidc',
|
|
issuer: identity.issuer,
|
|
audience: AUDIENCE,
|
|
type: TYPE,
|
|
purpose: PURPOSE,
|
|
subjectSha256,
|
|
ceremonyIdentityBound: true,
|
|
durableAuditBound: true,
|
|
}),
|
|
transport: Object.freeze({
|
|
endpointSha256: oldProfile.endpointSha256,
|
|
servernameSha256: oldProfile.servernameSha256,
|
|
serverTrustBundleSha256: oldProfile.serverTrustBundleSha256,
|
|
serverAuthoritySha256: oldProfile.serverAuthoritySha256,
|
|
commandSha256: command.sha256,
|
|
oldClientCertificateSha256: oldProfile.clientCertificateSha256,
|
|
newClientCertificateSha256: newProfile.clientCertificateSha256,
|
|
beforeOldStatus: beforeDocument.value.access.oldCertificateStatus,
|
|
beforeNewStatus: beforeDocument.value.access.newCertificateStatus,
|
|
afterOldStatus: oldAccess.statusCode,
|
|
afterOldCode: oldAccess.responseCode,
|
|
afterNewStatus: newAccess.statusCode,
|
|
}),
|
|
pki: Object.freeze({
|
|
clientIssuerBundleSha256: issuer.bundleSha256,
|
|
clientIssuerCaSha256: issuer.certificateSha256,
|
|
clientIssuerSubjectSha256: issuer.subjectSha256,
|
|
beforeCrlSha256: beforeCrl.crlSha256,
|
|
afterCrlSha256: crl.sha256,
|
|
crlIssuerSha256: crl.issuerSha256,
|
|
beforeCrlNumber: beforeCrl.crlNumber,
|
|
afterCrlNumber: crl.number,
|
|
crlNumberIncreased: true,
|
|
oldCertificateRevoked: true,
|
|
replacementCertificateAccepted: true,
|
|
}),
|
|
kubernetes: Object.freeze({
|
|
clusterServerSha256: kubernetes.clusterServerSha256,
|
|
collectorSubjectSha256: kubernetes.collectorSubjectSha256,
|
|
deploymentUidSha256: kubernetes.deploymentUidSha256,
|
|
beforeDeploymentResourceVersionSha256:
|
|
beforeKubernetes.deploymentResourceVersionSha256,
|
|
afterDeploymentResourceVersionSha256:
|
|
kubernetes.deploymentResourceVersionSha256,
|
|
beforeGeneration: beforeKubernetes.deploymentGeneration,
|
|
afterGeneration: kubernetes.deploymentGeneration,
|
|
beforeCrlAnnotationSha256: beforeKubernetes.crlAnnotationSha256,
|
|
afterCrlAnnotationSha256: kubernetes.crlAnnotationSha256,
|
|
beforePodUidSha256: beforeKubernetes.podUidSha256,
|
|
afterPodUidSha256: kubernetes.podUidSha256,
|
|
oldPodsFullyReplaced: true,
|
|
twoReadyReplicasOnDistinctNodes: true,
|
|
exactReadOnlyCollectorAuthority: true,
|
|
secretReadDenied: true,
|
|
mutationDenied: true,
|
|
}),
|
|
gates: Object.freeze({
|
|
sourceReportsBound: true,
|
|
externalIdentityBound: true,
|
|
serverTrustSeparatedFromClientIssuer: true,
|
|
sameClientIssuer: true,
|
|
oldAndReplacementInitiallyAccepted: true,
|
|
crlMonotonic: true,
|
|
deploymentRolled: true,
|
|
oldPodsRetired: true,
|
|
revokedCertificateRejected: true,
|
|
replacementCertificateAccepted: true,
|
|
readOnlyCollectorAuthority: true,
|
|
passed: true,
|
|
}),
|
|
});
|
|
const audit = validateWorkerCredentialManagementPkiRotationEvidence(report);
|
|
if (!audit.compatible) {
|
|
fail(
|
|
`assembled report failed audit: ${audit.findings
|
|
.map(({ code }) => code)
|
|
.join(',')}`,
|
|
);
|
|
}
|
|
writeNoReplace(options.outputFile, report);
|
|
return report;
|
|
} finally {
|
|
beforeDocument.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 (!['before', 'after'].includes(values.phase)) {
|
|
fail('phase must be before or after');
|
|
}
|
|
const common = [
|
|
'phase',
|
|
'old-config',
|
|
'new-config',
|
|
'assertion',
|
|
'command',
|
|
'kubernetes',
|
|
'client-issuer-ca',
|
|
'crl',
|
|
'output',
|
|
];
|
|
const expected =
|
|
values.phase === 'before'
|
|
? common
|
|
: [...common, 'before', '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,
|
|
issuerCaFile: values['client-issuer-ca'],
|
|
crlFile: values.crl,
|
|
outputFile: values.output,
|
|
};
|
|
return Object.freeze(
|
|
values.phase === 'before'
|
|
? { phase: 'before', options: Object.freeze(shared) }
|
|
: {
|
|
phase: 'after',
|
|
options: Object.freeze({
|
|
beforeFile: values.before,
|
|
...shared,
|
|
ceremonyReportFile: values['ceremony-report'],
|
|
durableAuditReportFile: values['durable-audit-report'],
|
|
}),
|
|
},
|
|
);
|
|
}
|
|
|
|
async function runCli(argv) {
|
|
if (
|
|
process.env.QL3_WORKER_CREDENTIAL_MANAGEMENT_PKI_ROTATION_EVIDENCE !== '1'
|
|
) {
|
|
fail('explicit PKI rotation evidence opt-in is required');
|
|
}
|
|
const parsed = parseArguments(argv);
|
|
if (parsed.phase === 'before') await runBeforeEvidence(parsed.options);
|
|
else await runAfterEvidence(parsed.options);
|
|
process.stdout.write(
|
|
`${JSON.stringify({
|
|
schemaVersion: 2,
|
|
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
|
|
: 'Worker credential management PKI rotation evidence failed'
|
|
}\n`,
|
|
);
|
|
process.exitCode = 1;
|
|
});
|
|
}
|
|
|
|
module.exports = {
|
|
CRL_ANNOTATION,
|
|
FIXTURE,
|
|
REVIEWED_AUTHORITY,
|
|
STATE_FIXTURE,
|
|
WorkerCredentialManagementPkiRotationEvidenceError,
|
|
collectKubernetesSnapshot,
|
|
defaultInspectAuthoritySubject,
|
|
defaultInspectCrl,
|
|
inspectClientConfiguration,
|
|
inspectClientIssuerAuthority,
|
|
normalizeKubernetesSnapshot,
|
|
parseCrlInspectionOutput,
|
|
parseArguments,
|
|
runAfterEvidence,
|
|
runBeforeEvidence,
|
|
validateBeforeState,
|
|
validateWorkerCredentialManagementPkiRotationEvidence,
|
|
};
|