Files
qinglong/scripts/ql3-worker-credential-management-pki-rotation-evidence.cjs
T

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,
};