fix: bind final release authority and expiry

This commit is contained in:
2026-09-02 12:07:38 +03:00
parent a8fa09e21e
commit 90137c388c
7 changed files with 123 additions and 17 deletions

View File

@@ -674,6 +674,9 @@ async function activateUnlocked(input) {
) {
throw new Error("Protected authority changed before pointer publication.");
}
const publicationNow = nowFrom(input.testClock);
if (Date.parse(publicationNow) >= Date.parse(receipt.expiresAt))
throw new Error("Release expired before pointer publication.");
const nextPath = join(root, "current.next");
const nextInfo = await lstat(nextPath).catch((error) =>
error?.code === "ENOENT" ? null : Promise.reject(error),
@@ -697,7 +700,7 @@ async function activateUnlocked(input) {
finalState,
tx,
receipt,
activationNow,
publicationNow,
false,
);
}

View File

@@ -20,6 +20,12 @@ const reportNames = [
const allowedLicenses = new Set([
'0BSD', 'Apache-2.0', 'BlueOak-1.0.0', 'BSD-2-Clause', 'BSD-3-Clause', 'CC0-1.0', 'ISC', 'MIT', 'Python-2.0', 'Unlicense',
]);
const productionAuthorityRoot = '/etc/portfolio-release-authority';
const productionVerifyFields = ['projectRoot', 'evidenceDir'];
const testVerifyFields = [
'projectRoot', 'evidenceDir', 'toolTrustPolicyPath', 'expectedCommitSha',
'requireTrackedTriage', 'allowedLicenses', 'now', 'today',
];
const sha256 = (bytes) => createHash('sha256').update(bytes).digest('hex');
const isObject = (value) => value !== null && typeof value === 'object' && !Array.isArray(value);
@@ -31,6 +37,31 @@ async function safeRead(path, label) {
return readFile(path);
}
async function assertAuthorityDirectory(path, label) {
const stats = await lstat(path);
const uid = typeof process.getuid === 'function' ? process.getuid() : null;
if (!stats.isDirectory() || stats.isSymbolicLink() || uid === null || stats.uid !== uid || (stats.mode & 0o022) !== 0) {
throw new Error(`${label} must be an activation-service-owned, non-writable regular directory.`);
}
}
async function readProductionAuthority() {
const controlPath = resolve(productionAuthorityRoot, '.control');
await assertAuthorityDirectory(productionAuthorityRoot, 'Supply-chain authority root');
await assertAuthorityDirectory(controlPath, 'Supply-chain authority control directory');
const trustPolicyPath = resolve(controlPath, 'execution-trust.json');
const authorityPath = resolve(controlPath, 'supply-chain-authority.json');
const trustPolicyBytes = await safeRead(trustPolicyPath, 'Execution evidence trust policy');
const authority = parseJson(await safeRead(authorityPath, 'Supply-chain authority binding'), 'Supply-chain authority binding');
if (!exactKeys(authority, ['schema', 'executionTrustPolicyDigest']) ||
authority.schema !== 'portfolio-supply-chain-authority/v1' ||
!sha256Pattern.test(authority.executionTrustPolicyDigest) ||
authority.executionTrustPolicyDigest !== sha256(trustPolicyBytes)) {
throw new Error('Supply-chain authority binding does not match the protected execution trust policy.');
}
return { trustPolicyPath };
}
function parseJson(bytes, label) {
try { return JSON.parse(bytes.toString('utf8')); } catch { throw new Error(`${label} is not valid JSON.`); }
}
@@ -182,7 +213,7 @@ async function verifyExecutionSecurity(envelope, trustPath, now) {
if (!verify(null, canonicalValueBytes(payload), publicKey, Buffer.from(signature.value, 'base64url'))) {
throw new Error('Execution security evidence signature verification failed.');
}
return { imageCount: roles.length, nativeToolCount: nativeNames.size };
return { imageCount: roles.length, nativeToolCount: nativeNames.size, commitSha: repository.commitSha };
}
function assertTracked(projectRoot, triagePath) {
@@ -195,10 +226,31 @@ function assertTracked(projectRoot, triagePath) {
}
}
export async function verifySupplyChainEvidence(options) {
function assertVerifyOptions(options, fields, label, exact = false) {
if (!isObject(options) || (exact ? !exactKeys(options, fields) :
Object.keys(options).some((key) => !fields.includes(key)) ||
!['projectRoot', 'evidenceDir'].every((key) => Object.hasOwn(options, key)))) {
throw new Error(`${label} contains unknown fields or is incomplete.`);
}
}
function resolveGitHead(projectRoot) {
let output;
try {
output = execFileSync('git', ['rev-parse', '--verify', 'HEAD'], { cwd: projectRoot, encoding: 'utf8' });
} catch {
throw new Error('Current Git HEAD could not be independently resolved.');
}
const commitSha = output.trim();
if (!/^[a-f0-9]{40}$/u.test(commitSha)) throw new Error('Current Git HEAD is not a valid commit SHA.');
return commitSha;
}
async function verifySupplyChainEvidenceInternal(options) {
const projectRoot = resolve(options.projectRoot);
const evidenceDir = resolve(options.evidenceDir);
const today = options.today ?? new Date().toISOString().slice(0, 10);
const now = options.now ?? new Date().toISOString();
const today = options.today ?? now.slice(0, 10);
if (!datePattern.test(today)) throw new Error('Trusted supply-chain date must be YYYY-MM-DD.');
const packageJsonBytes = await safeRead(resolve(projectRoot, 'package.json'), 'package.json');
const packageLockBytes = await safeRead(resolve(projectRoot, 'package-lock.json'), 'package-lock.json');
@@ -237,12 +289,32 @@ export async function verifySupplyChainEvidence(options) {
const licenseCount = verifyLicenses(reports['licenses.json'], options.allowedLicenses ?? allowedLicenses);
const componentCount = verifySbom(reports['sbom.cdx.json'], packageJson);
if (typeof options.toolTrustPolicyPath !== 'string') throw new Error('Execution evidence trust policy is required.');
const execution = await verifyExecutionSecurity(reports['execution-security.json'], options.toolTrustPolicyPath, options.now ?? new Date().toISOString());
if (typeof options.expectedCommitSha !== 'string' || !/^[a-f0-9]{40}$/u.test(options.expectedCommitSha)) throw new Error('Expected repository commit SHA is invalid.');
const execution = await verifyExecutionSecurity(reports['execution-security.json'], options.toolTrustPolicyPath, now);
if (execution.commitSha !== options.expectedCommitSha) throw new Error('Signed repository commit does not match the expected current Git HEAD.');
return { schema: 'portfolio-supply-chain-verification/v1', critical, high, medium: mediumFindings.length, licenseCount, componentCount,
executionImages: execution.imageCount, nativeTools: execution.nativeToolCount,
packageLockSha256: manifest.packageLockSha256, evidenceManifestSha256: sha256(await safeRead(resolve(evidenceDir, 'evidence-manifest.json'), 'Supply-chain evidence manifest')) };
}
export async function verifySupplyChainEvidence(options) {
assertVerifyOptions(options, productionVerifyFields, 'Production supply-chain request', true);
const authority = await readProductionAuthority();
const trustedNow = new Date().toISOString();
return verifySupplyChainEvidenceInternal({
...options,
toolTrustPolicyPath: authority.trustPolicyPath,
now: trustedNow,
today: trustedNow.slice(0, 10),
expectedCommitSha: resolveGitHead(resolve(options.projectRoot)),
});
}
export async function verifySupplyChainEvidenceForTest(options) {
assertVerifyOptions(options, testVerifyFields, 'Test supply-chain request');
return verifySupplyChainEvidenceInternal(options);
}
function runTool(command, args, cwd, options = {}) {
const result = spawnSync(command, args, { cwd, encoding: 'utf8', env: { PATH: process.env.PATH ?? '', LANG: 'C.UTF-8', LC_ALL: 'C.UTF-8', NO_COLOR: '1' }, maxBuffer: 64 * 1024 * 1024 });
if (result.error || (!options.allowAuditExit && result.status !== 0) || (options.allowAuditExit && ![0, 1].includes(result.status))) {
@@ -284,12 +356,16 @@ async function collectEvidence(projectRoot, evidenceDir, triagePath, executionSe
function parseArguments(args) {
const command = args.shift();
const values = { alternate: [] };
const values = {};
for (let index = 0; index < args.length; index += 2) {
const key = args[index]; const value = args[index + 1];
if (!key?.startsWith('--') || !value || Object.hasOwn(values, key.slice(2))) throw new Error('Supply-chain arguments must be unique complete name/value pairs.');
values[key.slice(2)] = value;
}
const allowed = command === 'collect'
? new Set(['project', 'evidence', 'triage', 'execution-security', 'allow-registry-network'])
: command === 'verify' ? new Set(['project', 'evidence']) : null;
if (allowed === null || Object.keys(values).some((key) => !allowed.has(key))) throw new Error('Supply-chain command contains unknown fields or command.');
return { command, values };
}
@@ -303,8 +379,7 @@ async function main() {
if (!values['execution-security']) throw new Error('Collection requires --execution-security <signed-evidence>.');
await collectEvidence(projectRoot, evidenceDir, resolve(values.triage), resolve(values['execution-security']));
} else if (command !== 'verify') throw new Error('Usage: supply-chain-check.mjs <collect|verify> --project <root> --evidence <dir>.');
if (!values['tool-trust-policy']) throw new Error('Verification requires --tool-trust-policy <protected-policy>.');
process.stdout.write(`${JSON.stringify(await verifySupplyChainEvidence({ projectRoot, evidenceDir, today: values.today, now: values.now, toolTrustPolicyPath: resolve(values['tool-trust-policy']) }))}\n`);
process.stdout.write(`${JSON.stringify(await verifySupplyChainEvidence({ projectRoot, evidenceDir }))}\n`);
}
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {