From 90137c388cb31929211338b872c0b67a828770c6 Mon Sep 17 00:00:00 2001 From: LucaP Date: Wed, 2 Sep 2026 12:07:38 +0300 Subject: [PATCH] fix: bind final release authority and expiry --- .gitignore | 1 - docs/deployment/release-checklist.md | 9 +-- package.json | 2 +- scripts/deploy/lib/release-transaction.mjs | 5 +- scripts/supply-chain-check.mjs | 89 ++++++++++++++++++++-- tests/deployment/headers.spec.ts | 16 +++- tests/integration/atomic-deploy.test.ts | 18 +++++ 7 files changed, 123 insertions(+), 17 deletions(-) diff --git a/.gitignore b/.gitignore index 00cea09..e0c97ec 100644 --- a/.gitignore +++ b/.gitignore @@ -7,7 +7,6 @@ playwright-report/ test-results/ .env* config/site.production.json -config/release-execution-trust.json release-evidence/ *.release.tar *.release.tar.partial-* diff --git a/docs/deployment/release-checklist.md b/docs/deployment/release-checklist.md index c82db87..f94dd6b 100644 --- a/docs/deployment/release-checklist.md +++ b/docs/deployment/release-checklist.md @@ -25,14 +25,12 @@ node scripts/supply-chain-check.mjs collect \ --evidence /external/write-once/release-evidence/supply-chain \ --triage APPROVED_COMMITTED_MEDIUM_TRIAGE.json \ --execution-security SIGNED_EXECUTION_SECURITY_ENVELOPE.json \ - --tool-trust-policy config/release-execution-trust.json \ - --allow-registry-network yes \ - --now YYYY-MM-DDTHH:MM:SSZ + --allow-registry-network yes ``` -The collector runs `npm ci --ignore-scripts`, production and full development audits, the pinned license checker, and the pinned CycloneDX generator. It strips the environment to a minimal credential-free set. It also requires a separately signed execution-security envelope: all four privacy image roles must be digest-pinned with verified provenance and zero Critical/High findings; native tools must be integrity/signature verified and clean; repository secret/history scans, pinned CI actions, and distribution notices must all pass. The verifier checks that envelope against the protected public-key policy at `config/release-execution-trust.json`. Until Luca supplies and approves that public trust policy and the signed evidence, the supply-chain gate intentionally fails closed. +The collector runs `npm ci --ignore-scripts`, production and full development audits, the pinned license checker, and the pinned CycloneDX generator. It strips the environment to a minimal credential-free set. It also requires a separately signed execution-security envelope: all four privacy image roles must be digest-pinned with verified provenance and zero Critical/High findings; native tools must be integrity/signature verified and clean; repository secret/history scans, pinned CI actions, and distribution notices must all pass. The verifier checks that envelope against the fixed protected authority checkpoint described below. Until Luca supplies and approves that protected authority and the signed evidence, the supply-chain gate intentionally fails closed. -The verifier also fails when package metadata or the lockfile changes, evidence digests do not match, a license is not allowlisted, an SBOM is malformed, or a Critical/High finding exists. Every Medium finding must have one current, exact, committed triage entry; stale extras also fail. +The verifier reads the fixed protected authority checkpoint at `/etc/portfolio-release-authority/.control`: `execution-trust.json` and the exact-JSON `supply-chain-authority.json` binding whose SHA-256 digest covers the trust-policy bytes. The authority root and control directory must be real, activation-service-owned, and not group/world writable. Verification uses system time and independently resolves Git `HEAD`; the signed repository commit must match that current `HEAD` exactly. It fails when package metadata or the lockfile changes, evidence digests do not match, a license is not allowlisted, an SBOM is malformed, or a Critical/High finding exists. Every Medium finding must have one current, exact, committed triage entry; stale extras also fail. - [ ] `npm run verify:supply-chain` passes against the selected immutable evidence directory. - [ ] `npm audit` evidence covers production dependencies and the complete development tree. @@ -40,6 +38,7 @@ The verifier also fails when package metadata or the lockfile changes, evidence - [ ] Every Medium finding has a current committed owner, rationale, and review date. - [ ] CycloneDX SBOM is bound by the evidence manifest and packaged with the release. - [ ] License inventory contains no unknown, copyleft, or unapproved expression. +- [ ] Fixed protected authority checkpoint and signed current-`HEAD` commit binding pass. - [ ] Signed execution image/native-tool evidence is current, complete, digest-pinned, and has zero Critical/High findings. - [ ] Repository secret/history, CI action pinning, and distribution notice checks pass. - [ ] `npm run build:preview` passes. diff --git a/package.json b/package.json index 3c5f92e..8d507e2 100644 --- a/package.json +++ b/package.json @@ -23,7 +23,7 @@ "capture:runtime-budgets": "playwright test tests/performance/runtime-budget-trace.spec.ts --project=runtime-budget-desktop --workers=1", "verify:runtime-budgets": "node scripts/verify-runtime-budgets.mjs", "verify:phase3": "npm run verify:runtime-budgets && npm run test:browser", - "verify:supply-chain": "node scripts/supply-chain-check.mjs verify --project . --evidence release-evidence/supply-chain --tool-trust-policy config/release-execution-trust.json", + "verify:supply-chain": "node scripts/supply-chain-check.mjs verify --project . --evidence release-evidence/supply-chain", "smoke:production": "node scripts/smoke-production.mjs", "aggregate:performance": "node scripts/aggregate-drive-traces.mjs", "sign:performance": "node scripts/sign-drive-trace.mjs", diff --git a/scripts/deploy/lib/release-transaction.mjs b/scripts/deploy/lib/release-transaction.mjs index 4eaba6f..9151b0c 100644 --- a/scripts/deploy/lib/release-transaction.mjs +++ b/scripts/deploy/lib/release-transaction.mjs @@ -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, ); } diff --git a/scripts/supply-chain-check.mjs b/scripts/supply-chain-check.mjs index 755aa3d..700eec9 100644 --- a/scripts/supply-chain-check.mjs +++ b/scripts/supply-chain-check.mjs @@ -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 .'); await collectEvidence(projectRoot, evidenceDir, resolve(values.triage), resolve(values['execution-security'])); } else if (command !== 'verify') throw new Error('Usage: supply-chain-check.mjs --project --evidence .'); - if (!values['tool-trust-policy']) throw new Error('Verification requires --tool-trust-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) { diff --git a/tests/deployment/headers.spec.ts b/tests/deployment/headers.spec.ts index eeced90..002a016 100644 --- a/tests/deployment/headers.spec.ts +++ b/tests/deployment/headers.spec.ts @@ -7,7 +7,7 @@ import { describe, expect, it } from 'vitest'; import { canonicalValueBytes } from '../../src/security/canonical-digest.ts'; // @ts-expect-error The production release checker is an executable ESM module. -import { verifySupplyChainEvidence } from '../../scripts/supply-chain-check.mjs'; +import { verifySupplyChainEvidence, verifySupplyChainEvidenceForTest } from '../../scripts/supply-chain-check.mjs'; const sha256 = (value: string | Buffer) => createHash('sha256').update(value).digest('hex'); @@ -87,10 +87,11 @@ async function evidenceFixture(overrides: Record = {}) { } const verifyFixture = (fixture: Awaited>, extra: Record = {}) => - verifySupplyChainEvidence({ + verifySupplyChainEvidenceForTest({ projectRoot: fixture.root, evidenceDir: fixture.evidence, toolTrustPolicyPath: fixture.trustPolicyPath, + expectedCommitSha: 'b'.repeat(40), requireTrackedTriage: false, ...extra, }); @@ -151,4 +152,15 @@ describe('supply-chain evidence', () => { } }); await expect(verifyFixture(unscanned)).rejects.toThrow(/repository|secret/u); }); + + it('keeps production authority and time non-caller-selectable', async () => { + const fixture = await evidenceFixture(); + await expect(verifySupplyChainEvidence({ + projectRoot: fixture.root, + evidenceDir: fixture.evidence, + toolTrustPolicyPath: fixture.trustPolicyPath, + now: '2026-09-02T12:00:00Z', + today: '2026-09-02', + })).rejects.toThrow(/production supply-chain request|unknown fields/u); + }); }); diff --git a/tests/integration/atomic-deploy.test.ts b/tests/integration/atomic-deploy.test.ts index 50e995a..8e69add 100644 --- a/tests/integration/atomic-deploy.test.ts +++ b/tests/integration/atomic-deploy.test.ts @@ -252,6 +252,24 @@ describe("fenced atomic Nginx release transaction", () => { await expect(lstat(join(root, "current"))).rejects.toThrow(); }); + test("rechecks trusted time at the final pointer publication boundary", async () => { + const { fixture, root, archive } = await setup( + "portfolio-final-expiry-", + ); + const input = transactionInput(root, archive, "tx-a", fixture); + await prepareReleaseForTest(input); + const times = ["2026-09-02T01:00:00Z", fixture.expiresAt]; + await expect( + activateReleaseForTest({ + ...input, + expectedCurrentManifestDigest: null, + expectedFence: 1, + testClock: () => times.shift() ?? fixture.expiresAt, + }), + ).rejects.toThrow(/expired.*publication/i); + await expect(lstat(join(root, "current"))).rejects.toThrow(); + }); + test("rollback requires a fresh signed manifest that binds the selected old artifact closure", async () => { const { temp, fixture, root, archive } = await setup("portfolio-rollback-"); const inputA = transactionInput(root, archive, "tx-a", fixture);