From 79e5d04395d1c4cef947f75fb5600cffaff7d5a6 Mon Sep 17 00:00:00 2001 From: LucaP Date: Wed, 2 Sep 2026 11:25:52 +0300 Subject: [PATCH] fix: close release provenance and privacy gaps --- containers/privacy-matcher/matcher.mjs | 105 +++-- containers/privacy-worker-a/worker.mjs | 72 +-- containers/privacy-worker-b/worker.py | 37 +- scripts/deploy/lib/release-common.d.mts | 14 + scripts/deploy/lib/release-common.mjs | 519 ++++++++++++++++----- scripts/release/reconcile-scan.mjs | 129 +++-- scripts/release/run-privacy-workers.mjs | 12 + scripts/release/sign-release.mjs | 152 ++++-- scripts/release/verify-approvals.mjs | 137 ++++-- src/security/approval-closure.ts | 317 ++++++++++--- src/security/approval-envelope.ts | 311 +++++++++--- tests/integration/release-manifest.test.ts | 349 +++++++++++--- tests/security/privacy-workers.test.ts | 172 ++++++- tests/security/release-authority.test.ts | 79 ++++ tests/security/release-signing.test.ts | 135 ++++-- tests/unit/approval-closure.test.ts | 280 ++++++++--- tests/unit/approval-envelope.test.ts | 155 ++++-- 17 files changed, 2271 insertions(+), 704 deletions(-) create mode 100644 tests/security/release-authority.test.ts diff --git a/containers/privacy-matcher/matcher.mjs b/containers/privacy-matcher/matcher.mjs index 64ef103..d70aa11 100644 --- a/containers/privacy-matcher/matcher.mjs +++ b/containers/privacy-matcher/matcher.mjs @@ -1,49 +1,60 @@ -import { createHash } from "node:crypto"; import { readFile, writeFile } from "node:fs/promises"; +import { pathToFileURL } from "node:url"; -const request = JSON.parse(await readFile("/input/request.json", "utf8")); -const [a, b] = request.reports ?? []; -if ( - !a || - !b || - a.workerId !== "worker-a" || - b.workerId !== "worker-b" || - a.imageDigest === b.imageDigest || - a.toolchain === b.toolchain || - a.inputDigest !== b.inputDigest || - a.checkId !== b.checkId || - a.normalizedOutputDigest !== b.normalizedOutputDigest || - JSON.stringify(a.normalizedMembers) !== JSON.stringify(b.normalizedMembers) || - a.verdict !== "pass" || - b.verdict !== "pass" -) - throw new Error("Independent worker evidence does not agree."); -const rules = request.rules; -if (!Array.isArray(rules) || rules.length === 0) - throw new Error("Matcher rules are required."); -const appliedRuleIds = rules - .map((rule) => rule.id) - .sort((left, right) => Buffer.from(left).compare(Buffer.from(right))); -if ( - new Set(appliedRuleIds).size !== appliedRuleIds.length || - rules.some((rule) => !Array.isArray(rule.deniedMemberDigests)) -) - throw new Error("Matcher rules are invalid."); -const denied = new Set(rules.flatMap((rule) => rule.deniedMemberDigests)); -if (a.normalizedMembers.some((member) => denied.has(member))) - throw new Error("Privacy rule matched normalized evidence."); -const result = { - schema: "portfolio-privacy-matcher-result/v1", - inputDigest: a.inputDigest, - checkId: a.checkId, - normalizedOutputDigest: a.normalizedOutputDigest, - normalizedMembers: a.normalizedMembers, - ruleSetDigest: request.ruleSetDigest, - appliedRuleIds, - verdict: "pass", - workerImageDigests: [a.imageDigest, b.imageDigest], -}; -await writeFile("/output/result.json", JSON.stringify(result), { - flag: "wx", - mode: 0o400, -}); +export function matchReports(request) { + const [a, b] = request.reports ?? []; + if ( + !a || + !b || + a.workerId !== "worker-a" || + b.workerId !== "worker-b" || + a.imageDigest === b.imageDigest || + a.toolchain === b.toolchain || + a.inputDigest !== b.inputDigest || + a.checkId !== b.checkId || + a.normalizedOutputDigest !== b.normalizedOutputDigest || + JSON.stringify(a.normalizedMembers) !== + JSON.stringify(b.normalizedMembers) || + a.verdict !== "pass" || + b.verdict !== "pass" + ) + throw new Error("Independent worker evidence does not agree and pass."); + const rules = request.rules; + if (!Array.isArray(rules) || rules.length === 0) + throw new Error("Matcher rules are required."); + const appliedRuleIds = rules + .map((rule) => rule.id) + .sort((left, right) => Buffer.from(left).compare(Buffer.from(right))); + if ( + new Set(appliedRuleIds).size !== appliedRuleIds.length || + rules.some((rule) => !Array.isArray(rule.deniedMemberDigests)) + ) + throw new Error("Matcher rules are invalid."); + const denied = new Set(rules.flatMap((rule) => rule.deniedMemberDigests)); + if (a.normalizedMembers.some((member) => denied.has(member))) + throw new Error("Privacy rule matched normalized evidence."); + return { + schema: "portfolio-privacy-matcher-result/v1", + inputDigest: a.inputDigest, + checkId: a.checkId, + normalizedOutputDigest: a.normalizedOutputDigest, + normalizedMembers: a.normalizedMembers, + ruleSetDigest: request.ruleSetDigest, + appliedRuleIds, + verdict: "pass", + workerImageDigests: [a.imageDigest, b.imageDigest], + }; +} + +async function main() { + const result = matchReports( + JSON.parse(await readFile("/input/request.json", "utf8")), + ); + await writeFile("/output/result.json", JSON.stringify(result), { + flag: "wx", + mode: 0o400, + }); +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) + await main(); diff --git a/containers/privacy-worker-a/worker.mjs b/containers/privacy-worker-a/worker.mjs index fbc6276..3939972 100644 --- a/containers/privacy-worker-a/worker.mjs +++ b/containers/privacy-worker-a/worker.mjs @@ -1,33 +1,45 @@ import { createHash } from "node:crypto"; import { readFile, writeFile } from "node:fs/promises"; +import { pathToFileURL } from "node:url"; -const request = JSON.parse(await readFile("/input/request.json", "utf8")); -if ( - request.schema !== "portfolio-privacy-worker-request/v1" || - request.workerId !== "worker-a" || - !/^[a-f0-9]{64}$/u.test(request.inputDigest) -) - throw new Error("Invalid worker request."); -const bytes = await readFile("/input/object"); -const actual = createHash("sha256").update(bytes).digest("hex"); -if (actual !== request.inputDigest) throw new Error("Input digest mismatch."); -const normalizedMembers = [`bytes:${actual}`]; -const normalizedOutputDigest = createHash("sha256") - .update(Buffer.from(JSON.stringify(normalizedMembers))) - .digest("hex"); -const output = { - schema: "portfolio-privacy-worker-report/v1", - workerId: "worker-a", - imageDigest: request.imageDigest, - toolchain: "bytes-js/v1", - inputDigest: actual, - checkId: request.checkId, - normalizedOutputDigest, - normalizedMembers, - verdict: "pass", - findingsDigest: "0".repeat(64), -}; -await writeFile("/output/report.json", JSON.stringify(output), { - flag: "wx", - mode: 0o400, -}); +export function inspectObject(request, bytes) { + if ( + request?.schema !== "portfolio-privacy-worker-request/v1" || + request.workerId !== "worker-a" || + !/^[a-f0-9]{64}$/u.test(request.inputDigest) || + !/^[a-f0-9]{64}$/u.test(request.imageDigest) || + !/^[A-Za-z0-9][A-Za-z0-9._/-]{0,127}$/u.test(request.checkId) + ) + throw new Error("Invalid worker request."); + const actual = createHash("sha256").update(bytes).digest("hex"); + if (actual !== request.inputDigest) throw new Error("Input digest mismatch."); + const normalizedMembers = [`unsupported:${actual}`]; + return { + schema: "portfolio-privacy-worker-report/v1", + workerId: "worker-a", + imageDigest: request.imageDigest, + toolchain: "scanner-unavailable-js/v1", + inputDigest: actual, + checkId: request.checkId, + normalizedOutputDigest: createHash("sha256") + .update(Buffer.from(JSON.stringify(normalizedMembers))) + .digest("hex"), + normalizedMembers, + verdict: "unsupported", + findingsDigest: createHash("sha256") + .update("scanner-unavailable") + .digest("hex"), + }; +} + +async function main() { + const request = JSON.parse(await readFile("/input/request.json", "utf8")); + const output = inspectObject(request, await readFile("/input/object")); + await writeFile("/output/report.json", JSON.stringify(output), { + flag: "wx", + mode: 0o400, + }); +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) + await main(); diff --git a/containers/privacy-worker-b/worker.py b/containers/privacy-worker-b/worker.py index fd67a87..46018c1 100644 --- a/containers/privacy-worker-b/worker.py +++ b/containers/privacy-worker-b/worker.py @@ -1,20 +1,23 @@ import hashlib import json -with open('/input/request.json', 'r', encoding='utf-8') as handle: - request = json.load(handle) -if request.get('schema') != 'portfolio-privacy-worker-request/v1' or request.get('workerId') != 'worker-b': - raise ValueError('Invalid worker request.') -with open('/input/object', 'rb') as handle: - payload = handle.read() -actual = hashlib.sha256(payload).hexdigest() -if actual != request.get('inputDigest'): - raise ValueError('Input digest mismatch.') -members = ['bytes:' + actual] -normalized = json.dumps(members, separators=(',', ':')).encode('utf-8') -output = {'schema': 'portfolio-privacy-worker-report/v1', 'workerId': 'worker-b', 'imageDigest': request['imageDigest'], - 'toolchain': 'bytes-python/v1', 'inputDigest': actual, 'checkId': request['checkId'], - 'normalizedOutputDigest': hashlib.sha256(normalized).hexdigest(), 'normalizedMembers': members, - 'verdict': 'pass', 'findingsDigest': '0' * 64} -with open('/output/report.json', 'x', encoding='utf-8') as handle: - json.dump(output, handle, separators=(',', ':')) +def inspect_object(request, payload): + if request.get('schema') != 'portfolio-privacy-worker-request/v1' or request.get('workerId') != 'worker-b': + raise ValueError('Invalid worker request.') + actual = hashlib.sha256(payload).hexdigest() + if actual != request.get('inputDigest'): + raise ValueError('Input digest mismatch.') + members = ['unsupported:' + actual] + normalized = json.dumps(members, separators=(',', ':')).encode('utf-8') + return {'schema': 'portfolio-privacy-worker-report/v1', 'workerId': 'worker-b', 'imageDigest': request['imageDigest'], + 'toolchain': 'scanner-unavailable-python/v1', 'inputDigest': actual, 'checkId': request['checkId'], + 'normalizedOutputDigest': hashlib.sha256(normalized).hexdigest(), 'normalizedMembers': members, + 'verdict': 'unsupported', 'findingsDigest': hashlib.sha256(b'scanner-unavailable').hexdigest()} + +if __name__ == '__main__': + with open('/input/request.json', 'r', encoding='utf-8') as handle: + request = json.load(handle) + with open('/input/object', 'rb') as handle: + payload = handle.read() + with open('/output/report.json', 'x', encoding='utf-8') as handle: + json.dump(inspect_object(request, payload), handle, separators=(',', ':')) diff --git a/scripts/deploy/lib/release-common.d.mts b/scripts/deploy/lib/release-common.d.mts index 78dc111..0d79a8a 100644 --- a/scripts/deploy/lib/release-common.d.mts +++ b/scripts/deploy/lib/release-common.d.mts @@ -5,3 +5,17 @@ export interface ArchiveFile { export function createDeterministicTar(files: readonly ArchiveFile[]): Buffer; export function parseDeterministicTar(source: Uint8Array): ArchiveFile[]; + +export function verifySignedRelease(input: { + signedManifestPath: string; + authorityRoot: string; + protectedState: Record; +}): Promise>; + +export function verifySignedReleaseForTest(input: { + signedManifestPath: string; + trustPolicyPath: string; + verifySignedReleaseEnvelope: (input: Record) => unknown; + protectedState: Record; + now: string; +}): Promise>; diff --git a/scripts/deploy/lib/release-common.mjs b/scripts/deploy/lib/release-common.mjs index 7fffe4e..7591219 100644 --- a/scripts/deploy/lib/release-common.mjs +++ b/scripts/deploy/lib/release-common.mjs @@ -1,34 +1,62 @@ -import { createHash } from 'node:crypto'; -import { chmod, lstat, mkdir, open, readFile, readdir, realpath, rename, rm, stat, writeFile } from 'node:fs/promises'; -import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path'; -import { pathToFileURL } from 'node:url'; +import { createHash } from "node:crypto"; +import { + chmod, + lstat, + mkdir, + open, + readFile, + readdir, + realpath, + rename, + rm, + stat, + writeFile, +} from "node:fs/promises"; +import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path"; -import { byteDigest, canonicalJsonDigest, canonicalValueBytes, isSha256Hex, parseCanonicalJson } from '../../../src/security/canonical-digest.ts'; -import { validatePublicPath } from '../../../src/security/production-input-root.ts'; -import { validateReleaseManifest } from '../../../src/security/release-manifest.ts'; +import { + byteDigest, + canonicalJsonDigest, + canonicalValueBytes, + isSha256Hex, + parseCanonicalJson, +} from "../../../src/security/canonical-digest.ts"; +import { validatePublicPath } from "../../../src/security/production-input-root.ts"; +import { validateReleaseManifest } from "../../../src/security/release-manifest.ts"; +import { verifySignedReleaseEnvelope as auditedReleaseVerifier } from "../../release/sign-release.mjs"; const encoder = new TextEncoder(); -const reservedPrefix = '.release/'; +const reservedPrefix = ".release/"; const safeToken = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/u; const timestamp = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,3})?Z$/u; export function exactObject(value, fields, label) { - if (value === null || typeof value !== 'object' || Array.isArray(value)) throw new Error(`${label} must be an object.`); + if (value === null || typeof value !== "object" || Array.isArray(value)) + throw new Error(`${label} must be an object.`); const actual = Object.keys(value).sort(); const expected = [...fields].sort(); - if (actual.length !== expected.length || actual.some((field, index) => field !== expected[index])) { + if ( + actual.length !== expected.length || + actual.some((field, index) => field !== expected[index]) + ) { throw new Error(`${label} contains missing or unknown fields.`); } return value; } export function assertToken(value, label) { - if (typeof value !== 'string' || !safeToken.test(value)) throw new Error(`${label} is invalid.`); + if (typeof value !== "string" || !safeToken.test(value)) + throw new Error(`${label} is invalid.`); return value; } export function assertTimestamp(value, label) { - if (typeof value !== 'string' || !timestamp.test(value) || Number.isNaN(Date.parse(value))) throw new Error(`${label} is invalid.`); + if ( + typeof value !== "string" || + !timestamp.test(value) || + Number.isNaN(Date.parse(value)) + ) + throw new Error(`${label} is invalid.`); return value; } @@ -39,78 +67,222 @@ export function assertDigest(value, label) { export async function readCanonicalFile(path, label) { const info = await lstat(path); - if (!info.isFile() || info.isSymbolicLink()) throw new Error(`${label} must be a regular file.`); + if (!info.isFile() || info.isSymbolicLink()) + throw new Error(`${label} must be a regular file.`); return parseCanonicalJson(await readFile(path)); } -export async function loadVerifier(modulePath) { - if (!isAbsolute(modulePath)) throw new Error('Verifier module path must be absolute.'); - const info = await lstat(modulePath); - if (!info.isFile() || info.isSymbolicLink()) throw new Error('Verifier module must be a regular file.'); - const module = await import(/* @vite-ignore */ pathToFileURL(await realpath(modulePath)).href); - if (typeof module.verifySignedReleaseEnvelope !== 'function') throw new Error('Verifier module does not expose verifySignedReleaseEnvelope.'); - return module.verifySignedReleaseEnvelope; -} - export function validateProtectedState(value) { - const state = exactObject(value, ['latestLedgerHeadHash', 'latestLedgerRevision', 'currentManifestDigest', 'publisherFence'], 'protected state'); - assertDigest(state.latestLedgerHeadHash, 'protected ledger head'); - if (!Number.isSafeInteger(state.latestLedgerRevision) || state.latestLedgerRevision < 0 || - !Number.isSafeInteger(state.publisherFence) || state.publisherFence < 0 || - (state.currentManifestDigest !== null && !isSha256Hex(state.currentManifestDigest))) throw new Error('Protected state is invalid.'); + const state = exactObject( + value, + [ + "latestLedgerHeadHash", + "latestLedgerRevision", + "currentManifestDigest", + "publisherFence", + "releaseTrustPolicyDigest", + ], + "protected state", + ); + assertDigest(state.latestLedgerHeadHash, "protected ledger head"); + assertDigest( + state.releaseTrustPolicyDigest, + "protected release trust policy", + ); + if ( + !Number.isSafeInteger(state.latestLedgerRevision) || + state.latestLedgerRevision < 0 || + !Number.isSafeInteger(state.publisherFence) || + state.publisherFence < 0 || + (state.currentManifestDigest !== null && + !isSha256Hex(state.currentManifestDigest)) + ) + throw new Error("Protected state is invalid."); return state; } export function validateAdmission(value, protectedState, now) { - const admission = exactObject(value, [ - 'schema', 'manifestDigest', 'manifestPayload', 'expiresAt', 'predecessorManifestDigest', 'latestLedgerHeadHash', - 'latestLedgerRevision', 'expectedFence', 'signatureVerified', - ], 'verified release admission'); - if (admission.schema !== 'portfolio-verified-release-admission/v1' || admission.signatureVerified !== true) { - throw new Error('Release signature was not verified.'); + const admission = exactObject( + value, + [ + "schema", + "manifestDigest", + "manifestPayload", + "expiresAt", + "predecessorManifestDigest", + "latestLedgerHeadHash", + "latestLedgerRevision", + "expectedFence", + "signatureVerified", + ], + "verified release admission", + ); + if ( + admission.schema !== "portfolio-verified-release-admission/v1" || + admission.signatureVerified !== true + ) { + throw new Error("Release signature was not verified."); } - assertDigest(admission.manifestDigest, 'manifest digest'); - assertTimestamp(now, 'trusted time'); - assertTimestamp(admission.expiresAt, 'admission expiry'); - if (Date.parse(now) >= Date.parse(admission.expiresAt)) throw new Error('Release admission is expired.'); - if (admission.predecessorManifestDigest !== null) assertDigest(admission.predecessorManifestDigest, 'predecessor manifest digest'); + assertDigest(admission.manifestDigest, "manifest digest"); + assertTimestamp(now, "trusted time"); + assertTimestamp(admission.expiresAt, "admission expiry"); + if (Date.parse(now) >= Date.parse(admission.expiresAt)) + throw new Error("Release admission is expired."); + if (admission.predecessorManifestDigest !== null) + assertDigest( + admission.predecessorManifestDigest, + "predecessor manifest digest", + ); const state = validateProtectedState(protectedState); - if (admission.latestLedgerHeadHash !== state.latestLedgerHeadHash || admission.latestLedgerRevision !== state.latestLedgerRevision) { - throw new Error('Release admission is stale relative to the protected ledger.'); + if ( + admission.latestLedgerHeadHash !== state.latestLedgerHeadHash || + admission.latestLedgerRevision !== state.latestLedgerRevision + ) { + throw new Error( + "Release admission is stale relative to the protected ledger.", + ); } - if (admission.predecessorManifestDigest !== state.currentManifestDigest || admission.expectedFence !== state.publisherFence + 1) { - throw new Error('Release admission predecessor or fence is stale.'); + if ( + admission.predecessorManifestDigest !== state.currentManifestDigest || + admission.expectedFence !== state.publisherFence + 1 + ) { + throw new Error("Release admission predecessor or fence is stale."); } const manifest = validateReleaseManifest(admission.manifestPayload, { now }); - if (manifest.expiresAt !== admission.expiresAt || manifest.currentManifestDigest !== admission.predecessorManifestDigest || - manifest.predecessorManifestDigest !== admission.predecessorManifestDigest) throw new Error('Admission does not bind the embedded release manifest.'); + if ( + manifest.expiresAt !== admission.expiresAt || + manifest.currentManifestDigest !== admission.predecessorManifestDigest || + manifest.predecessorManifestDigest !== admission.predecessorManifestDigest + ) + throw new Error("Admission does not bind the embedded release manifest."); return { admission, manifest }; } -export async function verifySignedRelease({ signedManifestPath, trustPolicyPath, verifierModulePath, verifySignedReleaseEnvelope, protectedState, now }) { +async function verifySignedReleaseAt({ + signedManifestPath, + trustPolicyPath, + verifier, + protectedState, + now, +}) { const [signedRelease, trustPolicy] = await Promise.all([ - readCanonicalFile(signedManifestPath, 'signed release'), readCanonicalFile(trustPolicyPath, 'trust policy'), + readCanonicalFile(signedManifestPath, "signed release"), + readCanonicalFile(trustPolicyPath, "trust policy"), ]); - const verifier = verifySignedReleaseEnvelope ?? await loadVerifier(verifierModulePath); - if (typeof verifier !== 'function') throw new Error('A release signature verifier is required.'); - const result = await verifier({ signedRelease, trustPolicy, now, protectedState }); + const state = validateProtectedState(protectedState); + if ( + canonicalJsonDigest("portfolio-trust-policy/v1", trustPolicy) !== + state.releaseTrustPolicyDigest + ) { + throw new Error( + "Release trust-policy identity does not match protected deployment authority state.", + ); + } + if (typeof verifier !== "function") + throw new Error("A release signature verifier is required."); + const result = await verifier({ + signedRelease, + trustPolicy, + now, + protectedState, + }); const verified = validateAdmission(result, protectedState, now); - return { ...verified, signedRelease, signedReleaseBytes: canonicalValueBytes(signedRelease) }; + return { + ...verified, + signedRelease, + signedReleaseBytes: canonicalValueBytes(signedRelease), + }; +} + +export async function verifySignedRelease(input) { + exactObject( + input, + ["signedManifestPath", "authorityRoot", "protectedState"], + "production release verification request", + ); + if ( + typeof input.authorityRoot !== "string" || + !isAbsolute(input.authorityRoot) + ) + throw new Error("Protected authority root is invalid."); + const now = new Date().toISOString(); + const signedRelease = await readCanonicalFile( + input.signedManifestPath, + "signed release", + ); + const trustPolicyPath = join( + input.authorityRoot, + ".control", + "release-trust.json", + ); + const trustPolicy = await readCanonicalFile( + trustPolicyPath, + "protected release trust policy", + ); + const state = validateProtectedState(input.protectedState); + if ( + canonicalJsonDigest("portfolio-trust-policy/v1", trustPolicy) !== + state.releaseTrustPolicyDigest + ) { + throw new Error( + "Release trust-policy identity does not match protected deployment authority state.", + ); + } + const result = auditedReleaseVerifier({ + signedRelease, + trustPolicy, + protectedState: input.protectedState, + }); + const verified = validateAdmission(result, input.protectedState, now); + return { + ...verified, + signedRelease, + signedReleaseBytes: canonicalValueBytes(signedRelease), + }; +} + +export async function verifySignedReleaseForTest(input) { + exactObject( + input, + [ + "signedManifestPath", + "trustPolicyPath", + "verifySignedReleaseEnvelope", + "protectedState", + "now", + ], + "test-only release verification request", + ); + return verifySignedReleaseAt({ + signedManifestPath: input.signedManifestPath, + trustPolicyPath: input.trustPolicyPath, + verifier: input.verifySignedReleaseEnvelope, + protectedState: input.protectedState, + now: input.now, + }); } export async function enumerateRegularFiles(root) { const rootPath = await realpath(root); const output = []; - async function visit(directory, prefix = '') { + async function visit(directory, prefix = "") { const entries = await readdir(directory, { withFileTypes: true }); - entries.sort((left, right) => Buffer.compare(Buffer.from(left.name), Buffer.from(right.name))); + entries.sort((left, right) => + Buffer.compare(Buffer.from(left.name), Buffer.from(right.name)), + ); for (const entry of entries) { const path = join(directory, entry.name); - const publicPath = prefix === '' ? entry.name : `${prefix}/${entry.name}`; - if (entry.isSymbolicLink()) throw new Error(`Symlink is forbidden in release closure: ${publicPath}`); + const publicPath = prefix === "" ? entry.name : `${prefix}/${entry.name}`; + if (entry.isSymbolicLink()) + throw new Error( + `Symlink is forbidden in release closure: ${publicPath}`, + ); if (entry.isDirectory()) await visit(path, publicPath); else if (entry.isFile()) output.push({ path, publicPath }); - else throw new Error(`Non-regular release input is forbidden: ${publicPath}`); + else + throw new Error( + `Non-regular release input is forbidden: ${publicPath}`, + ); } } await visit(rootPath); @@ -119,25 +291,31 @@ export async function enumerateRegularFiles(root) { function splitTarPath(path) { const bytes = encoder.encode(path); - if (bytes.length <= 100) return { name: path, prefix: '' }; - const segments = path.split('/'); + if (bytes.length <= 100) return { name: path, prefix: "" }; + const segments = path.split("/"); for (let index = segments.length - 1; index > 0; index -= 1) { - const prefix = segments.slice(0, index).join('/'); - const name = segments.slice(index).join('/'); - if (encoder.encode(prefix).length <= 155 && encoder.encode(name).length <= 100) return { name, prefix }; + const prefix = segments.slice(0, index).join("/"); + const name = segments.slice(index).join("/"); + if ( + encoder.encode(prefix).length <= 155 && + encoder.encode(name).length <= 100 + ) + return { name, prefix }; } throw new Error(`Release path is too long for deterministic ustar: ${path}`); } function writeString(target, offset, length, value) { const bytes = encoder.encode(value); - if (bytes.length > length) throw new Error('Tar header value exceeds its field.'); + if (bytes.length > length) + throw new Error("Tar header value exceeds its field."); target.set(bytes, offset); } function writeOctal(target, offset, length, value) { - const text = value.toString(8).padStart(length - 1, '0'); - if (text.length !== length - 1) throw new Error('Tar numeric field overflow.'); + const text = value.toString(8).padStart(length - 1, "0"); + if (text.length !== length - 1) + throw new Error("Tar numeric field overflow."); writeString(target, offset, length, `${text}\0`); } @@ -151,23 +329,26 @@ function tarHeader(path, size) { writeOctal(header, 124, 12, size); writeOctal(header, 136, 12, 0); header.fill(0x20, 148, 156); - writeString(header, 156, 1, '0'); - writeString(header, 257, 6, 'ustar\0'); - writeString(header, 263, 2, '00'); + writeString(header, 156, 1, "0"); + writeString(header, 257, 6, "ustar\0"); + writeString(header, 263, 2, "00"); writeString(header, 345, 155, prefix); const checksum = header.reduce((sum, byte) => sum + byte, 0); - const checksumText = checksum.toString(8).padStart(6, '0'); + const checksumText = checksum.toString(8).padStart(6, "0"); writeString(header, 148, 8, `${checksumText}\0 `); return header; } export function createDeterministicTar(files) { - const ordered = [...files].sort((left, right) => Buffer.compare(Buffer.from(left.path), Buffer.from(right.path))); + const ordered = [...files].sort((left, right) => + Buffer.compare(Buffer.from(left.path), Buffer.from(right.path)), + ); const seen = new Set(); const chunks = []; for (const file of ordered) { validatePublicPath(file.path); - if (seen.has(file.path)) throw new Error(`Duplicate release archive path: ${file.path}`); + if (seen.has(file.path)) + throw new Error(`Duplicate release archive path: ${file.path}`); seen.add(file.path); const bytes = new Uint8Array(file.bytes); chunks.push(tarHeader(file.path, bytes.length), bytes); @@ -180,14 +361,20 @@ export function createDeterministicTar(files) { function readString(bytes, start, length) { const end = bytes.indexOf(0, start); - return new TextDecoder('utf-8', { fatal: true }).decode(bytes.subarray(start, end === -1 || end > start + length ? start + length : end)); + return new TextDecoder("utf-8", { fatal: true }).decode( + bytes.subarray( + start, + end === -1 || end > start + length ? start + length : end, + ), + ); } function readOctal(bytes, start, length, label) { const text = readString(bytes, start, length).trim(); if (!/^[0-7]+$/u.test(text)) throw new Error(`Invalid tar ${label}.`); const value = Number.parseInt(text, 8); - if (!Number.isSafeInteger(value) || value < 0) throw new Error(`Invalid tar ${label}.`); + if (!Number.isSafeInteger(value) || value < 0) + throw new Error(`Invalid tar ${label}.`); return value; } @@ -205,31 +392,46 @@ export function parseDeterministicTar(source) { if (zeroBlocks === 2) break; continue; } - if (zeroBlocks !== 0) throw new Error('Malformed tar end marker.'); - const expectedChecksum = readOctal(header, 148, 8, 'checksum'); + if (zeroBlocks !== 0) throw new Error("Malformed tar end marker."); + const expectedChecksum = readOctal(header, 148, 8, "checksum"); const checksumHeader = new Uint8Array(header); checksumHeader.fill(0x20, 148, 156); - if (checksumHeader.reduce((sum, byte) => sum + byte, 0) !== expectedChecksum) throw new Error('Tar checksum mismatch.'); - if (readString(header, 257, 6) !== 'ustar' || String.fromCharCode(header[156] ?? 0) !== '0') throw new Error('Only deterministic regular ustar entries are accepted.'); + if ( + checksumHeader.reduce((sum, byte) => sum + byte, 0) !== expectedChecksum + ) + throw new Error("Tar checksum mismatch."); + if ( + readString(header, 257, 6) !== "ustar" || + String.fromCharCode(header[156] ?? 0) !== "0" + ) + throw new Error("Only deterministic regular ustar entries are accepted."); const name = readString(header, 0, 100); const prefix = readString(header, 345, 155); - const path = prefix === '' ? name : `${prefix}/${name}`; + const path = prefix === "" ? name : `${prefix}/${name}`; validatePublicPath(path); if (seen.has(path)) throw new Error(`Duplicate archive entry: ${path}`); seen.add(path); - const size = readOctal(header, 124, 12, 'size'); - if (offset + size > bytes.length) throw new Error('Truncated tar entry.'); + const size = readOctal(header, 124, 12, "size"); + if (offset + size > bytes.length) throw new Error("Truncated tar entry."); files.push({ path, bytes: bytes.slice(offset, offset + size) }); offset += Math.ceil(size / 512) * 512; } - if (zeroBlocks !== 2 || offset !== bytes.length) throw new Error('Tar archive has trailing or missing data.'); + if (zeroBlocks !== 2 || offset !== bytes.length) + throw new Error("Tar archive has trailing or missing data."); return files; } -export function packageIndexFor({ admission, manifest, signedBytes, sbomBytes }) { - const artifacts = [...manifest.artifacts].sort((left, right) => Buffer.compare(Buffer.from(left.path), Buffer.from(right.path))); +export function packageIndexFor({ + admission, + manifest, + signedBytes, + sbomBytes, +}) { + const artifacts = [...manifest.artifacts].sort((left, right) => + Buffer.compare(Buffer.from(left.path), Buffer.from(right.path)), + ); return { - schema: 'portfolio-release-package/v1', + schema: "portfolio-release-package/v1", buildId: manifest.buildId, releaseId: `${manifest.buildId}-${admission.manifestDigest}`, manifestDigest: admission.manifestDigest, @@ -238,42 +440,96 @@ export function packageIndexFor({ admission, manifest, signedBytes, sbomBytes }) expectedFence: admission.expectedFence, latestLedgerHeadHash: admission.latestLedgerHeadHash, latestLedgerRevision: admission.latestLedgerRevision, - signedManifest: { path: '.release/signed-release-manifest.json', sha256: byteDigest(signedBytes), bytes: signedBytes.length }, - sbom: { path: '.release/sbom.cdx.json', sha256: byteDigest(sbomBytes), bytes: sbomBytes.length }, + signedManifest: { + path: ".release/signed-release-manifest.json", + sha256: byteDigest(signedBytes), + bytes: signedBytes.length, + }, + sbom: { + path: ".release/sbom.cdx.json", + sha256: byteDigest(sbomBytes), + bytes: sbomBytes.length, + }, artifacts, }; } export function validatePackageFiles(files) { const byPath = new Map(files.map((file) => [file.path, file])); - const indexFile = byPath.get('.release/package-index.json'); - if (indexFile === undefined) throw new Error('Release package index is missing.'); - const index = exactObject(parseCanonicalJson(indexFile.bytes), [ - 'schema', 'buildId', 'releaseId', 'manifestDigest', 'predecessorManifestDigest', 'expiresAt', 'expectedFence', - 'latestLedgerHeadHash', 'latestLedgerRevision', 'signedManifest', 'sbom', 'artifacts', - ], 'release package index'); - if (index.schema !== 'portfolio-release-package/v1') throw new Error('Release package schema is invalid.'); - assertToken(index.buildId, 'package buildId'); - assertToken(index.releaseId, 'package releaseId'); - assertDigest(index.manifestDigest, 'package manifest digest'); - assertTimestamp(index.expiresAt, 'package expiry'); - if (!Number.isSafeInteger(index.expectedFence) || index.expectedFence < 1) throw new Error('Package fence is invalid.'); + const indexFile = byPath.get(".release/package-index.json"); + if (indexFile === undefined) + throw new Error("Release package index is missing."); + const index = exactObject( + parseCanonicalJson(indexFile.bytes), + [ + "schema", + "buildId", + "releaseId", + "manifestDigest", + "predecessorManifestDigest", + "expiresAt", + "expectedFence", + "latestLedgerHeadHash", + "latestLedgerRevision", + "signedManifest", + "sbom", + "artifacts", + ], + "release package index", + ); + if (index.schema !== "portfolio-release-package/v1") + throw new Error("Release package schema is invalid."); + assertToken(index.buildId, "package buildId"); + assertToken(index.releaseId, "package releaseId"); + assertDigest(index.manifestDigest, "package manifest digest"); + assertTimestamp(index.expiresAt, "package expiry"); + if (!Number.isSafeInteger(index.expectedFence) || index.expectedFence < 1) + throw new Error("Package fence is invalid."); const metadata = [index.signedManifest, index.sbom]; for (const record of metadata) { - exactObject(record, ['path', 'sha256', 'bytes'], 'package metadata record'); + exactObject(record, ["path", "sha256", "bytes"], "package metadata record"); const file = byPath.get(record.path); - if (file === undefined || byteDigest(file.bytes) !== record.sha256 || file.bytes.length !== record.bytes) throw new Error('Package metadata hash or size mismatch.'); + if ( + file === undefined || + byteDigest(file.bytes) !== record.sha256 || + file.bytes.length !== record.bytes + ) + throw new Error("Package metadata hash or size mismatch."); } - if (!Array.isArray(index.artifacts) || index.artifacts.length === 0) throw new Error('Package artifact closure is empty.'); - const expectedPaths = new Set(['.release/package-index.json', '.release/signed-release-manifest.json', '.release/sbom.cdx.json']); + if (!Array.isArray(index.artifacts) || index.artifacts.length === 0) + throw new Error("Package artifact closure is empty."); + const expectedPaths = new Set([ + ".release/package-index.json", + ".release/signed-release-manifest.json", + ".release/sbom.cdx.json", + ]); for (const artifact of index.artifacts) { - exactObject(artifact, ['path', 'sha256', 'bytes', 'encoding'], 'package artifact'); - if (typeof artifact.path !== 'string' || artifact.path.startsWith(reservedPrefix)) throw new Error('Package artifact uses a reserved path.'); + exactObject( + artifact, + ["path", "sha256", "bytes", "encoding"], + "package artifact", + ); + if ( + typeof artifact.path !== "string" || + artifact.path.startsWith(reservedPrefix) + ) + throw new Error("Package artifact uses a reserved path."); const file = byPath.get(artifact.path); - if (file === undefined || byteDigest(file.bytes) !== artifact.sha256 || file.bytes.length !== artifact.bytes) throw new Error(`Artifact hash or size mismatch: ${artifact.path}`); + if ( + file === undefined || + byteDigest(file.bytes) !== artifact.sha256 || + file.bytes.length !== artifact.bytes + ) + throw new Error(`Artifact hash or size mismatch: ${artifact.path}`); expectedPaths.add(artifact.path); } - if (expectedPaths.size !== byPath.size || [...byPath.keys()].some((path) => !expectedPaths.has(path))) throw new Error('Release package files do not exactly match the signed closure.'); + if ( + expectedPaths.size !== byPath.size || + [...byPath.keys()].some((path) => !expectedPaths.has(path)) + ) + throw new Error( + "Release package files do not exactly match the signed closure.", + ); return { index, byPath }; } @@ -282,44 +538,64 @@ export async function writeArchiveFiles(files, targetRoot) { const root = resolve(targetRoot); for (const file of files) { const destination = resolve(root, file.path); - if (relative(root, destination).startsWith(`..${sep}`) || destination === root) throw new Error('Archive entry escapes staging root.'); + if ( + relative(root, destination).startsWith(`..${sep}`) || + destination === root + ) + throw new Error("Archive entry escapes staging root."); await mkdir(dirname(destination), { recursive: true, mode: 0o700 }); - await writeFile(destination, file.bytes, { flag: 'wx', mode: 0o600 }); + await writeFile(destination, file.bytes, { flag: "wx", mode: 0o600 }); } } export async function makeImmutable(root, uid, gid) { - if (!Number.isSafeInteger(uid) || uid < 0 || !Number.isSafeInteger(gid) || gid < 0) throw new Error('Release owner UID/GID is invalid.'); + if ( + !Number.isSafeInteger(uid) || + uid < 0 || + !Number.isSafeInteger(gid) || + gid < 0 + ) + throw new Error("Release owner UID/GID is invalid."); async function visit(path) { const info = await lstat(path); - if (info.isSymbolicLink()) throw new Error('Symlink found in immutable release.'); - if (info.uid !== uid || info.gid !== gid) throw new Error(`Release ownership mismatch at ${path}.`); + if (info.isSymbolicLink()) + throw new Error("Symlink found in immutable release."); + if (info.uid !== uid || info.gid !== gid) + throw new Error(`Release ownership mismatch at ${path}.`); if (info.isDirectory()) { for (const entry of await readdir(path)) await visit(join(path, entry)); await chmod(path, 0o555); } else if (info.isFile()) await chmod(path, 0o444); - else throw new Error('Immutable release contains a non-file entry.'); + else throw new Error("Immutable release contains a non-file entry."); } await visit(root); } export async function assertReleaseRoot(rootPath) { - if (typeof rootPath !== 'string' || !isAbsolute(rootPath)) throw new Error('Release root must be an explicit absolute path.'); + if (typeof rootPath !== "string" || !isAbsolute(rootPath)) + throw new Error("Release root must be an explicit absolute path."); const normalized = resolve(rootPath); - if (normalized === '/' || normalized.split('/').filter(Boolean).length < 2) throw new Error('Broad release root is forbidden.'); + if (normalized === "/" || normalized.split("/").filter(Boolean).length < 2) + throw new Error("Broad release root is forbidden."); const info = await lstat(normalized); - if (!info.isDirectory() || info.isSymbolicLink()) throw new Error('Release root must be a real dedicated directory.'); - const marker = await readFile(join(normalized, '.portfolio-release-root'), 'utf8').catch((error) => { - if (error?.code === 'ENOENT') throw new Error('Release root sentinel is missing or invalid.'); + if (!info.isDirectory() || info.isSymbolicLink()) + throw new Error("Release root must be a real dedicated directory."); + const marker = await readFile( + join(normalized, ".portfolio-release-root"), + "utf8", + ).catch((error) => { + if (error?.code === "ENOENT") + throw new Error("Release root sentinel is missing or invalid."); throw error; }); - if (marker !== 'portfolio-release-root/v1\n') throw new Error('Release root sentinel is missing or invalid.'); + if (marker !== "portfolio-release-root/v1\n") + throw new Error("Release root sentinel is missing or invalid."); return normalized; } export async function atomicCanonicalWrite(path, value, mode = 0o600) { const temporary = `${path}.next-${process.pid}`; - const handle = await open(temporary, 'wx', mode); + const handle = await open(temporary, "wx", mode); try { await handle.writeFile(canonicalValueBytes(value)); await handle.sync(); @@ -330,14 +606,15 @@ export async function atomicCanonicalWrite(path, value, mode = 0o600) { } export async function safeRemoveStage(root, stageName) { - assertToken(stageName, 'stage name'); - const staging = join(root, 'staging'); + assertToken(stageName, "stage name"); + const staging = join(root, "staging"); const target = join(staging, stageName); const rel = relative(staging, target); - if (rel.startsWith('..') || isAbsolute(rel)) throw new Error('Stage cleanup escaped its root.'); + if (rel.startsWith("..") || isAbsolute(rel)) + throw new Error("Stage cleanup escaped its root."); await rm(target, { recursive: true, force: true }); } export function digestBytes(bytes) { - return createHash('sha256').update(bytes).digest('hex'); + return createHash("sha256").update(bytes).digest("hex"); } diff --git a/scripts/release/reconcile-scan.mjs b/scripts/release/reconcile-scan.mjs index 4413355..cdbc495 100644 --- a/scripts/release/reconcile-scan.mjs +++ b/scripts/release/reconcile-scan.mjs @@ -140,85 +140,68 @@ async function writeOnce(root, digest, bytes) { } } -export function reconcileScan({ - expectedChecks, - reports, - matcherResults, - candidate, -}) { - if ( - !Array.isArray(expectedChecks) || - !Array.isArray(reports) || - !Array.isArray(matcherResults) || - expectedChecks.length === 0 - ) +function closureDigest(value, schema, label) { + const closure = object(value, label); + exact(closure, ["schema", "members"], label); + if (closure.schema !== schema || !Array.isArray(closure.members)) + throw new Error(`${label} is invalid.`); + return canonicalJsonDigest(schema, closure); +} + +export function reconcileScan(input) { + const request = object(input, "reconciliation request"); + exact( + request, + [ + "sourceClosure", + "publicObjectClosure", + "reports", + "matcherResults", + "candidate", + ], + "reconciliation request", + ); + const { + sourceClosure, + publicObjectClosure, + reports, + matcherResults, + candidate, + } = request; + if (!Array.isArray(reports) || !Array.isArray(matcherResults)) throw new Error("Reconciliation input is invalid."); - const ordered = [...expectedChecks].sort((a, b) => - Buffer.from(`${a.inputDigest}\0${a.checkId}`).compare( - Buffer.from(`${b.inputDigest}\0${b.checkId}`), - ), + const sourceClosureDigest = closureDigest( + sourceClosure, + "portfolio-publication-closure/v1", + "source closure", + ); + const publicObjectClosureDigest = closureDigest( + publicObjectClosure, + "portfolio-public-object-closure/v1", + "public object closure", ); if ( - !stringsEqual(expectedChecks, ordered) || - new Set(expectedChecks.map((v) => `${v.inputDigest}\0${v.checkId}`)) - .size !== expectedChecks.length - ) - throw new Error("Expected check closure must be unique and byte sorted."); - if ( - reports.length !== expectedChecks.length * 2 || - matcherResults.length !== expectedChecks.length - ) - throw new Error("Missing or extra privacy evidence."); + candidate?.sourceClosureDigest !== sourceClosureDigest || + candidate?.publicObjectClosureDigest !== publicObjectClosureDigest + ) { + throw new Error( + "Candidate does not bind the exact canonical source/public closure.", + ); + } + if (publicObjectClosure.members.length !== 0) { + throw new Error( + "Verified privacy scanners are unavailable; every non-empty public object closure fails closed.", + ); + } + if (reports.length !== 0 || matcherResults.length !== 0) + throw new Error( + "Empty public object closure cannot carry fabricated scan evidence.", + ); const checks = []; - expectedChecks.forEach((expected, index) => { - if (!isSha256Hex(expected.inputDigest) || !safeToken.test(expected.checkId)) - throw new Error("Expected check is invalid."); - const a = validateWorker(reports[index * 2], "worker-a"); - const b = validateWorker(reports[index * 2 + 1], "worker-b"); - const match = validateMatcher(matcherResults[index]); - for (const item of [a, b, match]) - if ( - item.inputDigest !== expected.inputDigest || - item.checkId !== expected.checkId - ) - throw new Error( - "Privacy evidence is missing, reordered, or bound to another check.", - ); - if (a.imageDigest === b.imageDigest || a.toolchain === b.toolchain) - throw new Error("Privacy workers are not heterogeneous."); - if ( - a.verdict !== "pass" || - b.verdict !== "pass" || - match.verdict !== "pass" - ) - throw new Error("Every worker and matcher must independently pass."); - if ( - a.normalizedOutputDigest !== b.normalizedOutputDigest || - a.normalizedOutputDigest !== match.normalizedOutputDigest || - !stringsEqual(a.normalizedMembers, b.normalizedMembers) || - !stringsEqual(a.normalizedMembers, match.normalizedMembers) || - !stringsEqual(match.workerImageDigests, [a.imageDigest, b.imageDigest]) - ) - throw new Error("Independent normalized evidence disagrees."); - checks.push({ - inputDigest: expected.inputDigest, - checkId: expected.checkId, - normalizedOutputDigest: a.normalizedOutputDigest, - normalizedMembers: a.normalizedMembers, - workerReports: [ - canonicalJsonDigest("portfolio-privacy-worker-report/v1", a), - canonicalJsonDigest("portfolio-privacy-worker-report/v1", b), - ], - matcherResult: canonicalJsonDigest( - "portfolio-privacy-matcher-result/v1", - match, - ), - ruleSetDigest: match.ruleSetDigest, - appliedRuleIds: match.appliedRuleIds, - }); - }); const evidence = { schema: "portfolio-scan-evidence/v1", + sourceClosureDigest, + publicObjectClosureDigest, coverageCount: checks.length, passedCount: checks.length, checks, diff --git a/scripts/release/run-privacy-workers.mjs b/scripts/release/run-privacy-workers.mjs index 14a9d53..8a09ad7 100644 --- a/scripts/release/run-privacy-workers.mjs +++ b/scripts/release/run-privacy-workers.mjs @@ -107,6 +107,18 @@ export function liveContainerStatus() { }; } +export function requireLiveContainerValidation(value) { + if ( + value?.schema !== "portfolio-live-container-validation/v1" || + value.status !== "verified" + ) { + throw new Error( + "Live privacy scanners are not verified; pending or unsupported scanner state is a hard release failure.", + ); + } + return value; +} + if ( process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href diff --git a/scripts/release/sign-release.mjs b/scripts/release/sign-release.mjs index e294e43..7504a02 100644 --- a/scripts/release/sign-release.mjs +++ b/scripts/release/sign-release.mjs @@ -11,6 +11,11 @@ import { parseCanonicalJson, } from "../../src/security/canonical-digest.ts"; import { validateReleaseManifest } from "../../src/security/release-manifest.ts"; +import { + APPROVAL_PROJECTION_ATTESTATION_SCHEMA, + APPROVAL_PROJECTION_ATTESTATION_SCOPE, + verifyApprovalProjectionAttestation, +} from "../../src/security/approval-closure.ts"; const rootContract = { approval: { @@ -18,7 +23,7 @@ const rootContract = { recovery: ["freeze-publication", "audit-checkpoint", "fresh-approvals"], }, projection: { - scope: "portfolio-approval-projection/v1", + scope: APPROVAL_PROJECTION_ATTESTATION_SCOPE, recovery: ["freeze-publication", "revoke-epoch", "fresh-evidence"], }, privacyWorkload: { @@ -62,6 +67,7 @@ const candidateFields = [ "latestLedgerHeadHash", "latestLedgerRevision", "sourceClosureDigest", + "publicObjectClosureDigest", "publicRefMap", "lfsOids", "releaseArchives", @@ -78,21 +84,6 @@ const signatureFields = [ "value", ]; const attestationPayloadFields = { - projection: [ - "schema", - "keyId", - "issuerEpoch", - "scope", - "issuedAt", - "expiresAt", - "digest", - "productionInputRoot", - "approvedRecordRoot", - "approvedAssetRoot", - "approvalProofSetDigest", - "latestLedgerHeadHash", - "latestLedgerRevision", - ], privacyWorkload: [ "schema", "keyId", @@ -293,6 +284,7 @@ function validateCandidate(value, now) { throw new Error("Candidate schema is invalid."); digest(candidate.latestLedgerHeadHash, "latestLedgerHeadHash"); digest(candidate.sourceClosureDigest, "sourceClosureDigest"); + digest(candidate.publicObjectClosureDigest, "publicObjectClosureDigest"); digest(candidate.buildProvenanceDigest, "buildProvenanceDigest"); digest(candidate.privacyPolicyDigest, "privacyPolicyDigest"); if ( @@ -354,18 +346,30 @@ function validateEvidence(value, expectedDigest) { const evidence = object(value, "scan evidence"); exact( evidence, - ["schema", "coverageCount", "passedCount", "checks"], + [ + "schema", + "sourceClosureDigest", + "publicObjectClosureDigest", + "coverageCount", + "passedCount", + "checks", + ], "scan evidence", ); if ( evidence.schema !== "portfolio-scan-evidence/v1" || !Number.isSafeInteger(evidence.coverageCount) || - evidence.coverageCount < 1 || + evidence.coverageCount < 0 || evidence.passedCount !== evidence.coverageCount || !Array.isArray(evidence.checks) || evidence.checks.length !== evidence.coverageCount ) throw new Error("Scan evidence coverage is incomplete."); + digest(evidence.sourceClosureDigest, "scan source closure digest"); + digest( + evidence.publicObjectClosureDigest, + "scan public object closure digest", + ); const keys = []; for (const raw of evidence.checks) { const check = object(raw, "scan evidence check"); @@ -452,6 +456,8 @@ function verifyCandidateAuthority({ trustPolicy, now, protectedState, + sourceClosure, + publicObjectClosure, }) { if (trustPolicy.publicationFrozen) throw new Error("Publication is frozen by trust policy."); @@ -462,6 +468,39 @@ function verifyCandidateAuthority({ now, ); const state = validateProtectedState(protectedState); + const sourceRecord = object(sourceClosure, "source closure"); + exact(sourceRecord, ["schema", "members"], "source closure"); + if ( + sourceRecord.schema !== "portfolio-publication-closure/v1" || + !Array.isArray(sourceRecord.members) + ) + throw new Error("Source closure is invalid."); + const publicRecord = object(publicObjectClosure, "public object closure"); + exact(publicRecord, ["schema", "members"], "public object closure"); + if ( + publicRecord.schema !== "portfolio-public-object-closure/v1" || + !Array.isArray(publicRecord.members) + ) + throw new Error("Public object closure is invalid."); + const sourceClosureDigest = canonicalJsonDigest( + "portfolio-publication-closure/v1", + sourceRecord, + ); + const publicObjectClosureDigest = canonicalJsonDigest( + "portfolio-public-object-closure/v1", + publicRecord, + ); + if ( + record.sourceClosureDigest !== sourceClosureDigest || + record.publicObjectClosureDigest !== publicObjectClosureDigest + ) + throw new Error( + "Candidate does not bind exact source/public closure bytes.", + ); + if (publicRecord.members.length !== 0) + throw new Error( + "Verified scanners are unavailable; non-empty public object closure cannot be signed.", + ); if ( record.latestLedgerHeadHash !== state.latestLedgerHeadHash || record.latestLedgerRevision !== state.latestLedgerRevision @@ -474,7 +513,19 @@ function verifyCandidateAuthority({ throw new Error( "Candidate predecessor does not equal protected current manifest.", ); - validateEvidence(evidence, releaseManifest.scanEvidenceDigest); + const validatedEvidence = validateEvidence( + evidence, + releaseManifest.scanEvidenceDigest, + ); + if ( + validatedEvidence.sourceClosureDigest !== sourceClosureDigest || + validatedEvidence.publicObjectClosureDigest !== publicObjectClosureDigest || + validatedEvidence.coverageCount !== 0 || + validatedEvidence.checks.length !== 0 + ) + throw new Error( + "Scan evidence does not exactly cover the canonical empty public object closure.", + ); const candidateDigest = canonicalJsonDigest( "portfolio-candidate-release-manifest/v1", record, @@ -483,12 +534,20 @@ function verifyCandidateAuthority({ "portfolio-scan-evidence/v1", evidence, ); - const projection = verifyAttestation( + const projectionRoot = trustPolicy.roots.projection; + const projection = verifyApprovalProjectionAttestation( attestations.projection, - trustPolicy.roots.projection, - "portfolio-approval-projection-attestation/v1", - "projection", - now, + { + now, + latestLedgerHeadHash: record.latestLedgerHeadHash, + latestLedgerRevision: record.latestLedgerRevision, + trustedKey: { + keyId: projectionRoot.keyId, + issuerEpoch: projectionRoot.issuerEpoch, + scope: APPROVAL_PROJECTION_ATTESTATION_SCOPE, + publicKey: createPublicKey(projectionRoot.publicKeyPem), + }, + }, ); const workload = verifyAttestation( attestations.privacyWorkload, @@ -516,13 +575,18 @@ function verifyCandidateAuthority({ ["approvedRecordRoot", releaseManifest.approvedRecordRoot], ["approvedAssetRoot", releaseManifest.approvedAssetRoot], ["approvalProofSetDigest", releaseManifest.approvalProofSetDigest], - ["latestLedgerHeadHash", record.latestLedgerHeadHash], - ["latestLedgerRevision", record.latestLedgerRevision], + ["liveLedgerHeadHash", record.latestLedgerHeadHash], + ["liveLedgerRevision", record.latestLedgerRevision], ]) { if (projection[field] !== expected) throw new Error(`Approval projection mismatch: ${field}.`); } - if (projection.digest !== releaseManifest.approvalProjectionAttestationDigest) + if ( + canonicalJsonDigest( + "portfolio-approval-projection-attestation-envelope/v1", + attestations.projection, + ) !== releaseManifest.approvalProjectionAttestationDigest + ) throw new Error("Projection attestation digest binding is invalid."); if ( workload.candidateDigest !== candidateDigest || @@ -572,9 +636,9 @@ async function externalPrivateKey(path) { return key; } -export async function signReleaseCandidate(input) { +async function signReleaseCandidateAt(input, now) { const trustPolicy = validateTrustPolicy(input.trustPolicy); - const verified = verifyCandidateAuthority({ ...input, trustPolicy }); + const verified = verifyCandidateAuthority({ ...input, trustPolicy, now }); const key = await externalPrivateKey(input.releaseKeyPath); const releaseRoot = trustPolicy.roots.release; const bytes = canonicalValueBytes(verified.record); @@ -605,7 +669,20 @@ export async function signReleaseCandidate(input) { }; } -export function verifySignedReleaseEnvelope({ +export async function signReleaseCandidate(input) { + if (Object.prototype.hasOwnProperty.call(input, "now")) + throw new Error("Production signer does not accept caller-selected now."); + return signReleaseCandidateAt(input, new Date().toISOString()); +} + +export async function signReleaseCandidateForTest(input) { + if (typeof input?.now !== "string") + throw new Error("Test-only signer requires an explicit clock."); + const { now, ...rest } = input; + return signReleaseCandidateAt(rest, now); +} + +function verifySignedReleaseEnvelopeAt({ signedRelease, trustPolicy: trustValue, now, @@ -672,6 +749,21 @@ export function verifySignedReleaseEnvelope({ }; } +export function verifySignedReleaseEnvelope(input) { + if (Object.prototype.hasOwnProperty.call(input, "now")) + throw new Error("Production verifier does not accept caller-selected now."); + return verifySignedReleaseEnvelopeAt({ + ...input, + now: new Date().toISOString(), + }); +} + +export function verifySignedReleaseEnvelopeForTest(input) { + if (typeof input?.now !== "string") + throw new Error("Test-only verifier requires an explicit clock."); + return verifySignedReleaseEnvelopeAt(input); +} + async function main() { const args = process.argv.slice(2); if (args.length !== 4 || args[0] !== "--request" || args[2] !== "--key") diff --git a/scripts/release/verify-approvals.mjs b/scripts/release/verify-approvals.mjs index d4fde1e..8597bab 100644 --- a/scripts/release/verify-approvals.mjs +++ b/scripts/release/verify-approvals.mjs @@ -1,55 +1,110 @@ #!/usr/bin/env node -import { createPublicKey } from 'node:crypto'; -import { readFile } from 'node:fs/promises'; -import { pathToFileURL } from 'node:url'; +import { createPublicKey } from "node:crypto"; +import { readFile } from "node:fs/promises"; +import { pathToFileURL } from "node:url"; -import { computeApprovalClosure } from '../../src/security/approval-closure.ts'; -import { canonicalValueBytes, isSha256Hex, parseCanonicalJson } from '../../src/security/canonical-digest.ts'; -import { computeProductionInputRoot } from '../../src/security/production-input-root.ts'; +import { + APPROVAL_PROJECTION_ATTESTATION_SCHEMA, + APPROVAL_PROJECTION_ATTESTATION_SCOPE, + computeApprovalClosure, +} from "../../src/security/approval-closure.ts"; +import { + canonicalValueBytes, + isSha256Hex, + parseCanonicalJson, +} from "../../src/security/canonical-digest.ts"; +import { computeProductionInputRoot } from "../../src/security/production-input-root.ts"; const expectedRequestFields = [ - 'schema', 'now', 'latestLedgerHeadHash', 'latestLedgerRevision', 'trustedKeys', 'contentRecords', 'assetRecords', 'inputDigests', + "schema", + "latestLedgerHeadHash", + "latestLedgerRevision", + "trustedKeys", + "ledgerProof", + "contentRecords", + "assetRecords", + "inputDigests", ].sort(); -const expectedKeyFields = ['keyId', 'issuerEpoch', 'scope', 'authorizedRoles', 'publicKeyPem'].sort(); -const expectedInputs = ['PortfolioContent', 'PreparedAssetSet', 'PublicAssetManifest', 'SiteConfig']; +const expectedKeyFields = [ + "keyId", + "issuerEpoch", + "scope", + "authorizedRoles", + "publicKeyPem", +].sort(); +const expectedInputs = [ + "PortfolioContent", + "PreparedAssetSet", + "PublicAssetManifest", + "SiteConfig", +]; function exactObject(value, fields, label) { - if (value === null || typeof value !== 'object' || Array.isArray(value)) throw new Error(`${label} must be an object.`); - if (JSON.stringify(Object.keys(value).sort()) !== JSON.stringify(fields)) throw new Error(`${label} contains missing or unknown fields.`); + if (value === null || typeof value !== "object" || Array.isArray(value)) + throw new Error(`${label} must be an object.`); + if (JSON.stringify(Object.keys(value).sort()) !== JSON.stringify(fields)) + throw new Error(`${label} contains missing or unknown fields.`); return value; } function requestPath(args) { - if (args.length !== 2 || args[0] !== '--request' || args[1].length === 0) throw new Error('Usage: verify-approvals.mjs --request '); + if (args.length !== 2 || args[0] !== "--request" || args[1].length === 0) + throw new Error( + "Usage: verify-approvals.mjs --request ", + ); return args[1]; } -export function verifyApprovalRequest(source) { - const request = exactObject(parseCanonicalJson(source), expectedRequestFields, 'request'); - if (request.schema !== 'portfolio-approval-verification-request/v1') throw new Error('Request schema is invalid.'); - if (!Array.isArray(request.trustedKeys) || !Array.isArray(request.contentRecords) || !Array.isArray(request.assetRecords)) throw new Error('Approval record lists are invalid.'); +function verifyApprovalRequestAt(source, now) { + const request = exactObject( + parseCanonicalJson(source), + expectedRequestFields, + "request", + ); + if (request.schema !== "portfolio-approval-verification-request/v1") + throw new Error("Request schema is invalid."); + if ( + !Array.isArray(request.trustedKeys) || + !Array.isArray(request.contentRecords) || + !Array.isArray(request.assetRecords) + ) + throw new Error("Approval record lists are invalid."); const trustedKeys = request.trustedKeys.map((candidate) => { - const key = exactObject(candidate, expectedKeyFields, 'trusted key'); - if (key.scope !== 'portfolio-approval-decision/v1' || typeof key.keyId !== 'string' || - !Number.isSafeInteger(key.issuerEpoch) || !Array.isArray(key.authorizedRoles) || - key.authorizedRoles.some((role) => typeof role !== 'string') || typeof key.publicKeyPem !== 'string') { - throw new Error('Trusted approval key is invalid.'); + const key = exactObject(candidate, expectedKeyFields, "trusted key"); + if ( + key.scope !== "portfolio-approval-decision/v1" || + typeof key.keyId !== "string" || + !Number.isSafeInteger(key.issuerEpoch) || + !Array.isArray(key.authorizedRoles) || + key.authorizedRoles.some((role) => typeof role !== "string") || + typeof key.publicKeyPem !== "string" + ) { + throw new Error("Trusted approval key is invalid."); } return { - keyId: key.keyId, issuerEpoch: key.issuerEpoch, scope: key.scope, - authorizedRoles: key.authorizedRoles, publicKey: createPublicKey(key.publicKeyPem), + keyId: key.keyId, + issuerEpoch: key.issuerEpoch, + scope: key.scope, + authorizedRoles: key.authorizedRoles, + publicKey: createPublicKey(key.publicKeyPem), }; }); - const inputDigests = exactObject(request.inputDigests, expectedInputs, 'inputDigests'); - if (Object.values(inputDigests).some((digest) => !isSha256Hex(digest))) throw new Error('Input digest is invalid.'); + const inputDigests = exactObject( + request.inputDigests, + expectedInputs, + "inputDigests", + ); + if (Object.values(inputDigests).some((digest) => !isSha256Hex(digest))) + throw new Error("Input digest is invalid."); const closure = computeApprovalClosure({ contentRecords: request.contentRecords, assetRecords: request.assetRecords, trust: { - now: request.now, + now, latestLedgerHeadHash: request.latestLedgerHeadHash, latestLedgerRevision: request.latestLedgerRevision, trustedKeys, + ledgerProof: request.ledgerProof, }, }); const productionInputRoot = computeProductionInputRoot({ @@ -59,7 +114,9 @@ export function verifyApprovalRequest(source) { siteConfigDigest: inputDigests.SiteConfig, }); return { - schema: 'portfolio-approval-verification-result/v1', + schema: "portfolio-approval-verification-result/v1", + projectionSchema: APPROVAL_PROJECTION_ATTESTATION_SCHEMA, + projectionScope: APPROVAL_PROJECTION_ATTESTATION_SCOPE, approvedRecordRoot: closure.approvedRecordRoot, approvedAssetRoot: closure.approvedAssetRoot, approvalProofSetDigest: closure.approvalProofSetDigest, @@ -70,16 +127,36 @@ export function verifyApprovalRequest(source) { }; } +export function verifyApprovalRequest(source) { + return verifyApprovalRequestAt(source, new Date().toISOString()); +} + +export function verifyApprovalRequestForTest(source, clock) { + if ( + clock === null || + typeof clock !== "object" || + Object.keys(clock).length !== 1 || + typeof clock.now !== "string" + ) { + throw new Error("Test-only approval clock is invalid."); + } + return verifyApprovalRequestAt(source, clock.now); +} + async function main() { const path = requestPath(process.argv.slice(2)); const result = verifyApprovalRequest(await readFile(path)); process.stdout.write(Buffer.from(canonicalValueBytes(result))); - process.stdout.write('\n'); + process.stdout.write("\n"); } -if (process.argv[1] !== undefined && import.meta.url === pathToFileURL(process.argv[1]).href) { +if ( + process.argv[1] !== undefined && + import.meta.url === pathToFileURL(process.argv[1]).href +) { main().catch((error) => { - const message = error instanceof Error ? error.message : 'Unknown verification failure.'; + const message = + error instanceof Error ? error.message : "Unknown verification failure."; process.stderr.write(`Approval verification failed: ${message}\n`); process.exitCode = 1; }); diff --git a/src/security/approval-closure.ts b/src/security/approval-closure.ts index ce50fec..51e3cec 100644 --- a/src/security/approval-closure.ts +++ b/src/security/approval-closure.ts @@ -1,11 +1,22 @@ -import type { KeyLike } from 'node:crypto'; -import { canonicalJsonDigest, isSha256Hex, verifyEd25519Envelope, type Sha256Hex } from './canonical-digest.ts'; +import type { KeyLike } from "node:crypto"; +import { + canonicalJsonDigest, + isSha256Hex, + verifyEd25519Envelope, + type Sha256Hex, +} from "./canonical-digest.ts"; import { type ApprovalEnvelope, type ApprovalTrustPolicy, + verifyApprovalLedger, verifyApprovalEnvelope, -} from './approval-envelope.ts'; -import { computeProductionInputRoot } from './production-input-root.ts'; +} from "./approval-envelope.ts"; +import { computeProductionInputRoot } from "./production-input-root.ts"; + +export const APPROVAL_PROJECTION_ATTESTATION_SCHEMA = + "portfolio-approval-projection-attestation/v1" as const; +export const APPROVAL_PROJECTION_ATTESTATION_SCOPE = + APPROVAL_PROJECTION_ATTESTATION_SCHEMA; export interface ContentApprovalRecord { recordType: string; @@ -29,14 +40,19 @@ export interface ApprovalClosure { } export interface ApprovalProjectionAttestationPayload { - schema: 'portfolio-approval-projection-attestation/v1'; + schema: typeof APPROVAL_PROJECTION_ATTESTATION_SCHEMA; keyId: string; issuerEpoch: number; - scope: 'portfolio-approval-projection-attestation/v1'; + scope: typeof APPROVAL_PROJECTION_ATTESTATION_SCOPE; approvedRecordRoot: string; approvedAssetRoot: string; approvalProofSetDigest: string; - inputDigests: { PortfolioContent: string; PublicAssetManifest: string; PreparedAssetSet: string; SiteConfig: string }; + inputDigests: { + PortfolioContent: string; + PublicAssetManifest: string; + PreparedAssetSet: string; + SiteConfig: string; + }; productionInputRoot: string; liveLedgerHeadHash: string; liveLedgerRevision: number; @@ -47,16 +63,20 @@ export interface ApprovalProjectionAttestationPayload { } export interface ApprovalProjectionAttestationEnvelope { - schema: 'portfolio-approval-projection-attestation-envelope/v1'; + schema: "portfolio-approval-projection-attestation-envelope/v1"; payload: ApprovalProjectionAttestationPayload; - signature: { algorithm: 'Ed25519'; encoding: 'base64url-nopad'; value: string }; + signature: { + algorithm: "Ed25519"; + encoding: "base64url-nopad"; + value: string; + }; } const encoder = new TextEncoder(); function utf8TupleCompare(left: string[], right: string[]): number { for (let field = 0; field < Math.max(left.length, right.length); field += 1) { - const a = encoder.encode(left[field] ?? ''); - const b = encoder.encode(right[field] ?? ''); + const a = encoder.encode(left[field] ?? ""); + const b = encoder.encode(right[field] ?? ""); for (let index = 0; index < Math.min(a.length, b.length); index += 1) { if (a[index] !== b[index]) return (a[index] ?? 0) - (b[index] ?? 0); } @@ -70,51 +90,122 @@ export function computeApprovalClosure(input: { assetRecords: AssetApprovalRecord[]; trust: ApprovalTrustPolicy; }): ApprovalClosure { + const latestLedger = verifyApprovalLedger(input.trust); const ids = new Set(); const expiries: string[] = []; - const contentTuples = input.contentRecords.map((record) => { - const typedId = record.publicRecordId; - if (ids.has(typedId)) throw new Error('Duplicate content approval ID.'); - ids.add(typedId); - if (!isSha256Hex(record.approvedPublicRecordHash)) throw new Error('Content record hash is invalid.'); - const payload = verifyApprovalEnvelope(record.envelope, input.trust); - if (payload.recordClass !== 'content' || payload.recordType !== record.recordType || + const contentTuples = input.contentRecords + .map((record) => { + const typedId = record.publicRecordId; + if (ids.has(typedId)) throw new Error("Duplicate content approval ID."); + ids.add(typedId); + if (!isSha256Hex(record.approvedPublicRecordHash)) + throw new Error("Content record hash is invalid."); + const payload = verifyApprovalEnvelope(record.envelope, input.trust); + if ( + payload.recordClass !== "content" || + payload.recordType !== record.recordType || payload.publicRecordIdOrAssetId !== record.publicRecordId || - payload.approvedPublicRecordHashOrManifestRecordHash !== record.approvedPublicRecordHash || - payload.sanitizedOutputHashOrNull !== null) { - throw new Error('Content approval does not bind the exact emitted record.'); - } - expiries.push(payload.expiresAt); - const envelopeDigest = canonicalJsonDigest('portfolio-approval-envelope-digest/v1', record.envelope); - return [record.recordType, record.publicRecordId, record.approvedPublicRecordHash, envelopeDigest] as const; - }).sort((a, b) => utf8TupleCompare([a[0], a[1]], [b[0], b[1]])); + payload.approvedPublicRecordHashOrManifestRecordHash !== + record.approvedPublicRecordHash || + payload.sanitizedOutputHashOrNull !== null + ) { + throw new Error( + "Content approval does not bind the exact emitted record.", + ); + } + expiries.push(payload.expiresAt); + const envelopeDigest = canonicalJsonDigest( + "portfolio-approval-envelope-digest/v1", + record.envelope, + ); + return [ + record.recordType, + record.publicRecordId, + record.approvedPublicRecordHash, + envelopeDigest, + ] as const; + }) + .sort((a, b) => utf8TupleCompare([a[0], a[1]], [b[0], b[1]])); - const assetTuples = input.assetRecords.map((record) => { - const typedId = record.assetId; - if (ids.has(typedId)) throw new Error('Duplicate asset approval ID.'); - ids.add(typedId); - if (!isSha256Hex(record.approvedManifestRecordHash) || !isSha256Hex(record.sanitizedOutputHash)) throw new Error('Asset record hash is invalid.'); - const payload = verifyApprovalEnvelope(record.envelope, input.trust); - if (payload.recordClass !== 'asset' || payload.recordType !== '' || + const assetTuples = input.assetRecords + .map((record) => { + const typedId = record.assetId; + if (ids.has(typedId)) throw new Error("Duplicate asset approval ID."); + ids.add(typedId); + if ( + !isSha256Hex(record.approvedManifestRecordHash) || + !isSha256Hex(record.sanitizedOutputHash) + ) + throw new Error("Asset record hash is invalid."); + const payload = verifyApprovalEnvelope(record.envelope, input.trust); + if ( + payload.recordClass !== "asset" || + payload.recordType !== "" || payload.publicRecordIdOrAssetId !== record.assetId || - payload.approvedPublicRecordHashOrManifestRecordHash !== record.approvedManifestRecordHash || - payload.sanitizedOutputHashOrNull !== record.sanitizedOutputHash) { - throw new Error('Asset approval does not bind the exact emitted record and bytes.'); - } - expiries.push(payload.expiresAt); - const envelopeDigest = canonicalJsonDigest('portfolio-approval-envelope-digest/v1', record.envelope); - return [record.assetId, record.approvedManifestRecordHash, record.sanitizedOutputHash, envelopeDigest] as const; - }).sort((a, b) => utf8TupleCompare([a[0]], [b[0]])); + payload.approvedPublicRecordHashOrManifestRecordHash !== + record.approvedManifestRecordHash || + payload.sanitizedOutputHashOrNull !== record.sanitizedOutputHash + ) { + throw new Error( + "Asset approval does not bind the exact emitted record and bytes.", + ); + } + expiries.push(payload.expiresAt); + const envelopeDigest = canonicalJsonDigest( + "portfolio-approval-envelope-digest/v1", + record.envelope, + ); + return [ + record.assetId, + record.approvedManifestRecordHash, + record.sanitizedOutputHash, + envelopeDigest, + ] as const; + }) + .sort((a, b) => utf8TupleCompare([a[0]], [b[0]])); const proofList = [ - ...contentTuples.map((tuple) => ['content', tuple[0], tuple[1], tuple[3]] as const), - ...assetTuples.map((tuple) => ['asset', '', tuple[0], tuple[3]] as const), + ...contentTuples.map( + (tuple) => ["content", tuple[0], tuple[1], tuple[3]] as const, + ), + ...assetTuples.map((tuple) => ["asset", "", tuple[0], tuple[3]] as const), ].sort((a, b) => utf8TupleCompare([a[0], a[1], a[2]], [b[0], b[1], b[2]])); + const supplied = new Set([ + ...input.contentRecords.map( + (record) => `content\0${record.recordType}\0${record.publicRecordId}`, + ), + ...input.assetRecords.map((record) => `asset\0\0${record.assetId}`), + ]); + const required = new Set( + [...latestLedger.entries()] + .filter( + ([, envelope]) => + envelope.payload.decision === "approve" && + Date.parse(input.trust.now) < Date.parse(envelope.payload.expiresAt), + ) + .map(([typedId]) => typedId), + ); + if ( + supplied.size !== required.size || + [...supplied].some((typedId) => !required.has(typedId)) + ) { + throw new Error( + "Approval records are not the exact latest authenticated approved ledger projection.", + ); + } + return { - approvedRecordRoot: canonicalJsonDigest('portfolio-record-closure/v1', { records: contentTuples }), - approvedAssetRoot: canonicalJsonDigest('portfolio-asset-closure/v1', { assets: assetTuples }), - approvalProofSetDigest: canonicalJsonDigest('portfolio-approval-proof-set/v1', { envelopes: proofList }), + approvedRecordRoot: canonicalJsonDigest("portfolio-record-closure/v1", { + records: contentTuples, + }), + approvedAssetRoot: canonicalJsonDigest("portfolio-asset-closure/v1", { + assets: assetTuples, + }), + approvalProofSetDigest: canonicalJsonDigest( + "portfolio-approval-proof-set/v1", + { envelopes: proofList }, + ), earliestApprovalExpiry: expiries.sort()[0] ?? null, }; } @@ -125,43 +216,129 @@ export function verifyApprovalProjectionAttestation( now: string; latestLedgerHeadHash: string; latestLedgerRevision: number; - trustedKey: { keyId: string; issuerEpoch: number; scope: 'portfolio-approval-projection-attestation/v1'; publicKey: KeyLike; revoked?: boolean }; + trustedKey: { + keyId: string; + issuerEpoch: number; + scope: typeof APPROVAL_PROJECTION_ATTESTATION_SCOPE; + publicKey: KeyLike; + revoked?: boolean; + }; }, ): ApprovalProjectionAttestationPayload { - if (envelopeValue === null || typeof envelopeValue !== 'object' || Array.isArray(envelopeValue)) throw new Error('Projection envelope is invalid.'); + if ( + envelopeValue === null || + typeof envelopeValue !== "object" || + Array.isArray(envelopeValue) + ) + throw new Error("Projection envelope is invalid."); const envelope = envelopeValue as Record; - if (JSON.stringify(Object.keys(envelope).sort()) !== JSON.stringify(['payload', 'schema', 'signature'])) throw new Error('Projection envelope contains unknown fields.'); - if (envelope.schema !== 'portfolio-approval-projection-attestation-envelope/v1') throw new Error('Projection envelope schema is invalid.'); - if (envelope.payload === null || typeof envelope.payload !== 'object' || Array.isArray(envelope.payload)) throw new Error('Projection payload is invalid.'); - const payload = envelope.payload as unknown as ApprovalProjectionAttestationPayload; + if ( + JSON.stringify(Object.keys(envelope).sort()) !== + JSON.stringify(["payload", "schema", "signature"]) + ) + throw new Error("Projection envelope contains unknown fields."); + if ( + envelope.schema !== "portfolio-approval-projection-attestation-envelope/v1" + ) + throw new Error("Projection envelope schema is invalid."); + if ( + envelope.payload === null || + typeof envelope.payload !== "object" || + Array.isArray(envelope.payload) + ) + throw new Error("Projection payload is invalid."); + const payload = + envelope.payload as unknown as ApprovalProjectionAttestationPayload; const payloadFields = [ - 'schema', 'keyId', 'issuerEpoch', 'scope', 'approvedRecordRoot', 'approvedAssetRoot', 'approvalProofSetDigest', - 'inputDigests', 'productionInputRoot', 'liveLedgerHeadHash', 'liveLedgerRevision', 'policyVersion', 'issuedAt', - 'expiresAt', 'earliestApprovalExpiry', + "schema", + "keyId", + "issuerEpoch", + "scope", + "approvedRecordRoot", + "approvedAssetRoot", + "approvalProofSetDigest", + "inputDigests", + "productionInputRoot", + "liveLedgerHeadHash", + "liveLedgerRevision", + "policyVersion", + "issuedAt", + "expiresAt", + "earliestApprovalExpiry", ].sort(); - if (JSON.stringify(Object.keys(payload).sort()) !== JSON.stringify(payloadFields)) throw new Error('Projection payload contains missing or unknown fields.'); - if (payload.schema !== 'portfolio-approval-projection-attestation/v1' || payload.scope !== 'portfolio-approval-projection-attestation/v1') throw new Error('Projection payload schema/scope is invalid.'); - if (trust.trustedKey.revoked || trust.trustedKey.scope !== payload.scope || trust.trustedKey.keyId !== payload.keyId || trust.trustedKey.issuerEpoch !== payload.issuerEpoch) { - throw new Error('Projection key, scope, or issuer epoch is not trusted.'); + if ( + JSON.stringify(Object.keys(payload).sort()) !== + JSON.stringify(payloadFields) + ) + throw new Error("Projection payload contains missing or unknown fields."); + if ( + payload.schema !== APPROVAL_PROJECTION_ATTESTATION_SCHEMA || + payload.scope !== APPROVAL_PROJECTION_ATTESTATION_SCOPE + ) + throw new Error("Projection payload schema/scope is invalid."); + if ( + trust.trustedKey.revoked || + trust.trustedKey.scope !== payload.scope || + trust.trustedKey.keyId !== payload.keyId || + trust.trustedKey.issuerEpoch !== payload.issuerEpoch + ) { + throw new Error("Projection key, scope, or issuer epoch is not trusted."); } - for (const digest of [payload.approvedRecordRoot, payload.approvedAssetRoot, payload.approvalProofSetDigest, payload.productionInputRoot, payload.liveLedgerHeadHash]) { - if (!isSha256Hex(digest)) throw new Error('Projection digest field is invalid.'); + for (const digest of [ + payload.approvedRecordRoot, + payload.approvedAssetRoot, + payload.approvalProofSetDigest, + payload.productionInputRoot, + payload.liveLedgerHeadHash, + ]) { + if (!isSha256Hex(digest)) + throw new Error("Projection digest field is invalid."); } const inputKeys = Object.keys(payload.inputDigests).sort(); - if (JSON.stringify(inputKeys) !== JSON.stringify(['PortfolioContent', 'PreparedAssetSet', 'PublicAssetManifest', 'SiteConfig']) || - Object.values(payload.inputDigests).some((digest) => !isSha256Hex(digest))) throw new Error('Projection input digest closure is invalid.'); + if ( + JSON.stringify(inputKeys) !== + JSON.stringify([ + "PortfolioContent", + "PreparedAssetSet", + "PublicAssetManifest", + "SiteConfig", + ]) || + Object.values(payload.inputDigests).some((digest) => !isSha256Hex(digest)) + ) + throw new Error("Projection input digest closure is invalid."); const expectedRoot = computeProductionInputRoot({ portfolioContentDigest: payload.inputDigests.PortfolioContent, publicAssetManifestDigest: payload.inputDigests.PublicAssetManifest, preparedAssetSetDigest: payload.inputDigests.PreparedAssetSet, siteConfigDigest: payload.inputDigests.SiteConfig, }); - if (expectedRoot !== payload.productionInputRoot) throw new Error('Projection does not bind the exact four-input root.'); - if (payload.liveLedgerHeadHash !== trust.latestLedgerHeadHash || payload.liveLedgerRevision !== trust.latestLedgerRevision) throw new Error('Projection live-ledger state is stale.'); - for (const [label, value] of [['now', trust.now], ['issuedAt', payload.issuedAt], ['expiresAt', payload.expiresAt], ['earliestApprovalExpiry', payload.earliestApprovalExpiry]] as const) { - if (!/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,3})?Z$/u.test(value) || Number.isNaN(Date.parse(value))) throw new Error(`${label} timestamp is invalid.`); + if (expectedRoot !== payload.productionInputRoot) + throw new Error("Projection does not bind the exact four-input root."); + if ( + payload.liveLedgerHeadHash !== trust.latestLedgerHeadHash || + payload.liveLedgerRevision !== trust.latestLedgerRevision + ) + throw new Error("Projection live-ledger state is stale."); + for (const [label, value] of [ + ["now", trust.now], + ["issuedAt", payload.issuedAt], + ["expiresAt", payload.expiresAt], + ["earliestApprovalExpiry", payload.earliestApprovalExpiry], + ] as const) { + if ( + !/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,3})?Z$/u.test(value) || + Number.isNaN(Date.parse(value)) + ) + throw new Error(`${label} timestamp is invalid.`); } - if (Date.parse(payload.issuedAt) > Date.parse(trust.now) || Date.parse(trust.now) >= Date.parse(payload.expiresAt) || - Date.parse(payload.expiresAt) > Date.parse(payload.earliestApprovalExpiry)) throw new Error('Projection is outside its trusted expiry bounds.'); - return verifyEd25519Envelope(envelope as unknown as ApprovalProjectionAttestationEnvelope, trust.trustedKey); + if ( + Date.parse(payload.issuedAt) > Date.parse(trust.now) || + Date.parse(trust.now) >= Date.parse(payload.expiresAt) || + Date.parse(payload.expiresAt) > Date.parse(payload.earliestApprovalExpiry) + ) + throw new Error("Projection is outside its trusted expiry bounds."); + return verifyEd25519Envelope( + envelope as unknown as ApprovalProjectionAttestationEnvelope, + trust.trustedKey, + ); } diff --git a/src/security/approval-envelope.ts b/src/security/approval-envelope.ts index a351752..7bc1b44 100644 --- a/src/security/approval-envelope.ts +++ b/src/security/approval-envelope.ts @@ -1,23 +1,24 @@ -import type { KeyLike } from 'node:crypto'; +import type { KeyLike } from "node:crypto"; import { + canonicalJsonDigest, assertWellFormedNfcString, isSha256Hex, parseCanonicalJson, verifyEd25519Envelope, -} from './canonical-digest.ts'; +} from "./canonical-digest.ts"; export interface ApprovalPayload { - schema: 'portfolio-approval-payload/v1'; + schema: "portfolio-approval-payload/v1"; keyId: string; issuerEpoch: number; authorizedRole: string; evidenceFingerprints: string[]; - recordClass: 'content' | 'asset'; + recordClass: "content" | "asset"; recordType: string; publicRecordIdOrAssetId: string; approvedPublicRecordHashOrManifestRecordHash: string; sanitizedOutputHashOrNull: string | null; - decision: 'approve' | 'reject' | 'revoke'; + decision: "approve" | "reject" | "revoke"; issuedAt: string; expiresAt: string; previousLedgerHeadHash: string; @@ -26,15 +27,19 @@ export interface ApprovalPayload { } export interface ApprovalEnvelope { - schema: 'portfolio-approval-envelope/v1'; + schema: "portfolio-approval-envelope/v1"; payload: ApprovalPayload; - signature: { algorithm: 'Ed25519'; encoding: 'base64url-nopad'; value: string }; + signature: { + algorithm: "Ed25519"; + encoding: "base64url-nopad"; + value: string; + }; } export interface ApprovalTrustKey { keyId: string; issuerEpoch: number; - scope: 'portfolio-approval-decision/v1'; + scope: "portfolio-approval-decision/v1"; authorizedRoles: string[]; publicKey: KeyLike; revoked?: boolean; @@ -45,99 +50,273 @@ export interface ApprovalTrustPolicy { latestLedgerHeadHash: string; latestLedgerRevision: number; trustedKeys: ApprovalTrustKey[]; + ledgerProof: ApprovalLedgerProof; +} + +export interface ApprovalLedgerProof { + schema: "portfolio-approval-ledger-proof/v1"; + genesisHeadHash: string; + entries: ApprovalEnvelope[]; } const payloadKeys = [ - 'schema', 'keyId', 'issuerEpoch', 'authorizedRole', 'evidenceFingerprints', 'recordClass', 'recordType', - 'publicRecordIdOrAssetId', 'approvedPublicRecordHashOrManifestRecordHash', 'sanitizedOutputHashOrNull', - 'decision', 'issuedAt', 'expiresAt', 'previousLedgerHeadHash', 'ledgerRevision', 'policyVersion', + "schema", + "keyId", + "issuerEpoch", + "authorizedRole", + "evidenceFingerprints", + "recordClass", + "recordType", + "publicRecordIdOrAssetId", + "approvedPublicRecordHashOrManifestRecordHash", + "sanitizedOutputHashOrNull", + "decision", + "issuedAt", + "expiresAt", + "previousLedgerHeadHash", + "ledgerRevision", + "policyVersion", ] as const; -const envelopeKeys = ['schema', 'payload', 'signature'] as const; -const signatureKeys = ['algorithm', 'encoding', 'value'] as const; +const envelopeKeys = ["schema", "payload", "signature"] as const; +const signatureKeys = ["algorithm", "encoding", "value"] as const; const utcTimestamp = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,3})?Z$/u; const safeToken = /^[A-Za-z0-9][A-Za-z0-9._/-]{0,127}$/u; function record(value: unknown, label: string): Record { - if (value === null || typeof value !== 'object' || Array.isArray(value)) throw new Error(`${label} must be an object.`); + if (value === null || typeof value !== "object" || Array.isArray(value)) + throw new Error(`${label} must be an object.`); return value as Record; } -function exactKeys(value: Record, keys: readonly string[], label: string): void { +function exactKeys( + value: Record, + keys: readonly string[], + label: string, +): void { const actual = Object.keys(value).sort(); const expected = [...keys].sort(); - if (actual.length !== expected.length || actual.some((key, index) => key !== expected[index])) { + if ( + actual.length !== expected.length || + actual.some((key, index) => key !== expected[index]) + ) { throw new Error(`${label} contains missing or unknown fields.`); } } -function validTimestamp(value: unknown, label: string): asserts value is string { - if (typeof value !== 'string' || !utcTimestamp.test(value) || Number.isNaN(Date.parse(value))) { +function validTimestamp( + value: unknown, + label: string, +): asserts value is string { + if ( + typeof value !== "string" || + !utcTimestamp.test(value) || + Number.isNaN(Date.parse(value)) + ) { throw new Error(`${label} must be a UTC RFC 3339 timestamp.`); } } export function validateApprovalEnvelope(value: unknown): ApprovalEnvelope { - const outer = record(value, 'approval envelope'); - exactKeys(outer, envelopeKeys, 'approval envelope'); - if (outer.schema !== 'portfolio-approval-envelope/v1') throw new Error('Approval envelope schema is invalid.'); - const payload = record(outer.payload, 'approval payload'); - exactKeys(payload, payloadKeys, 'approval payload'); - const signature = record(outer.signature, 'approval signature'); - exactKeys(signature, signatureKeys, 'approval signature'); + const outer = record(value, "approval envelope"); + exactKeys(outer, envelopeKeys, "approval envelope"); + if (outer.schema !== "portfolio-approval-envelope/v1") + throw new Error("Approval envelope schema is invalid."); + const payload = record(outer.payload, "approval payload"); + exactKeys(payload, payloadKeys, "approval payload"); + const signature = record(outer.signature, "approval signature"); + exactKeys(signature, signatureKeys, "approval signature"); - if (payload.schema !== 'portfolio-approval-payload/v1') throw new Error('Approval payload schema is invalid.'); - for (const key of ['keyId', 'authorizedRole', 'publicRecordIdOrAssetId', 'policyVersion'] as const) { + if (payload.schema !== "portfolio-approval-payload/v1") + throw new Error("Approval payload schema is invalid."); + for (const key of [ + "keyId", + "authorizedRole", + "publicRecordIdOrAssetId", + "policyVersion", + ] as const) { const field = payload[key]; - if (typeof field !== 'string' || !safeToken.test(field)) throw new Error(`${key} is invalid.`); + if (typeof field !== "string" || !safeToken.test(field)) + throw new Error(`${key} is invalid.`); assertWellFormedNfcString(field, key); } - if (!Number.isSafeInteger(payload.issuerEpoch) || (payload.issuerEpoch as number) < 1) throw new Error('issuerEpoch is invalid.'); - if (!Number.isSafeInteger(payload.ledgerRevision) || (payload.ledgerRevision as number) < 0) throw new Error('ledgerRevision is invalid.'); - if (payload.recordClass !== 'content' && payload.recordClass !== 'asset') throw new Error('recordClass is invalid.'); - if (typeof payload.recordType !== 'string') throw new Error('recordType is invalid.'); - assertWellFormedNfcString(payload.recordType, 'recordType'); - if (payload.recordClass === 'content' && !safeToken.test(payload.recordType)) throw new Error('Content recordType is required.'); - if (payload.recordClass === 'asset' && payload.recordType !== '') throw new Error('Asset recordType must be empty.'); - if (!isSha256Hex(payload.approvedPublicRecordHashOrManifestRecordHash) || !isSha256Hex(payload.previousLedgerHeadHash)) { - throw new Error('Approval hash field is invalid.'); + if ( + !Number.isSafeInteger(payload.issuerEpoch) || + (payload.issuerEpoch as number) < 1 + ) + throw new Error("issuerEpoch is invalid."); + if ( + !Number.isSafeInteger(payload.ledgerRevision) || + (payload.ledgerRevision as number) < 0 + ) + throw new Error("ledgerRevision is invalid."); + if (payload.recordClass !== "content" && payload.recordClass !== "asset") + throw new Error("recordClass is invalid."); + if (typeof payload.recordType !== "string") + throw new Error("recordType is invalid."); + assertWellFormedNfcString(payload.recordType, "recordType"); + if (payload.recordClass === "content" && !safeToken.test(payload.recordType)) + throw new Error("Content recordType is required."); + if (payload.recordClass === "asset" && payload.recordType !== "") + throw new Error("Asset recordType must be empty."); + if ( + !isSha256Hex(payload.approvedPublicRecordHashOrManifestRecordHash) || + !isSha256Hex(payload.previousLedgerHeadHash) + ) { + throw new Error("Approval hash field is invalid."); } - if (!Array.isArray(payload.evidenceFingerprints) || payload.evidenceFingerprints.length === 0 || - payload.evidenceFingerprints.some((item) => !isSha256Hex(item)) || - new Set(payload.evidenceFingerprints).size !== payload.evidenceFingerprints.length) { - throw new Error('evidenceFingerprints must be a unique, non-empty digest list.'); + if ( + !Array.isArray(payload.evidenceFingerprints) || + payload.evidenceFingerprints.length === 0 || + payload.evidenceFingerprints.some((item) => !isSha256Hex(item)) || + new Set(payload.evidenceFingerprints).size !== + payload.evidenceFingerprints.length + ) { + throw new Error( + "evidenceFingerprints must be a unique, non-empty digest list.", + ); } - if (payload.recordClass === 'content' && payload.sanitizedOutputHashOrNull !== null) throw new Error('Content approval cannot bind sanitized bytes.'); - if (payload.recordClass === 'asset' && !isSha256Hex(payload.sanitizedOutputHashOrNull)) throw new Error('Asset approval must bind sanitized bytes.'); - if (!['approve', 'reject', 'revoke'].includes(payload.decision as string)) throw new Error('Decision is invalid.'); - validTimestamp(payload.issuedAt, 'issuedAt'); - validTimestamp(payload.expiresAt, 'expiresAt'); - if (Date.parse(payload.issuedAt) >= Date.parse(payload.expiresAt)) throw new Error('Approval expiry must follow issue time.'); - if (signature.algorithm !== 'Ed25519' || signature.encoding !== 'base64url-nopad' || typeof signature.value !== 'string') { - throw new Error('Approval signature metadata is invalid.'); + if ( + payload.recordClass === "content" && + payload.sanitizedOutputHashOrNull !== null + ) + throw new Error("Content approval cannot bind sanitized bytes."); + if ( + payload.recordClass === "asset" && + !isSha256Hex(payload.sanitizedOutputHashOrNull) + ) + throw new Error("Asset approval must bind sanitized bytes."); + if (!["approve", "reject", "revoke"].includes(payload.decision as string)) + throw new Error("Decision is invalid."); + validTimestamp(payload.issuedAt, "issuedAt"); + validTimestamp(payload.expiresAt, "expiresAt"); + if (Date.parse(payload.issuedAt) >= Date.parse(payload.expiresAt)) + throw new Error("Approval expiry must follow issue time."); + if ( + signature.algorithm !== "Ed25519" || + signature.encoding !== "base64url-nopad" || + typeof signature.value !== "string" + ) { + throw new Error("Approval signature metadata is invalid."); } return outer as unknown as ApprovalEnvelope; } -export function parseApprovalEnvelopeJson(source: string | Uint8Array): ApprovalEnvelope { +export function parseApprovalEnvelopeJson( + source: string | Uint8Array, +): ApprovalEnvelope { return validateApprovalEnvelope(parseCanonicalJson(source)); } -export function verifyApprovalEnvelope(envelopeValue: unknown, trust: ApprovalTrustPolicy): ApprovalPayload { +function verifyEnvelopeSignature( + envelopeValue: unknown, + trust: ApprovalTrustPolicy, +): ApprovalEnvelope { const envelope = validateApprovalEnvelope(envelopeValue); - validTimestamp(trust.now, 'trusted now'); - if (!isSha256Hex(trust.latestLedgerHeadHash) || !Number.isSafeInteger(trust.latestLedgerRevision)) { - throw new Error('Protected live-ledger state is invalid.'); - } - const matchingKeys = trust.trustedKeys.filter((candidate) => - candidate.keyId === envelope.payload.keyId && candidate.issuerEpoch === envelope.payload.issuerEpoch); - if (matchingKeys.length !== 1) throw new Error('Approval trust policy must contain exactly one matching key epoch.'); + const matchingKeys = trust.trustedKeys.filter( + (candidate) => + candidate.keyId === envelope.payload.keyId && + candidate.issuerEpoch === envelope.payload.issuerEpoch, + ); + if (matchingKeys.length !== 1) + throw new Error( + "Approval trust policy must contain exactly one matching key epoch.", + ); const key = matchingKeys[0]; - if (key === undefined || key.revoked || key.scope !== 'portfolio-approval-decision/v1') throw new Error('Approval key, scope, or epoch is not trusted.'); - if (!key.authorizedRoles.includes(envelope.payload.authorizedRole)) throw new Error('Approval role is not authorized.'); - if (envelope.payload.ledgerRevision >= trust.latestLedgerRevision) throw new Error('Approval is not anchored before the protected live-ledger revision.'); - if (Date.parse(trust.now) < Date.parse(envelope.payload.issuedAt) || Date.parse(trust.now) >= Date.parse(envelope.payload.expiresAt)) { - throw new Error('Approval is not current at the trusted time.'); - } - if (envelope.payload.decision !== 'approve') throw new Error('Only a current approval decision can enter the production closure.'); - return verifyEd25519Envelope(envelope, key); + if ( + key === undefined || + key.revoked || + key.scope !== "portfolio-approval-decision/v1" + ) + throw new Error("Approval key, scope, or epoch is not trusted."); + if (!key.authorizedRoles.includes(envelope.payload.authorizedRole)) + throw new Error("Approval role is not authorized."); + verifyEd25519Envelope(envelope, key); + return envelope; +} + +function typedDecisionId(payload: ApprovalPayload): string { + return `${payload.recordClass}\0${payload.recordType}\0${payload.publicRecordIdOrAssetId}`; +} + +export function verifyApprovalLedger( + trust: ApprovalTrustPolicy, +): Map { + validTimestamp(trust.now, "trusted now"); + if ( + !isSha256Hex(trust.latestLedgerHeadHash) || + !Number.isSafeInteger(trust.latestLedgerRevision) || + trust.latestLedgerRevision < 0 + ) { + throw new Error("Protected live-ledger state is invalid."); + } + const proof = trust.ledgerProof; + if ( + proof === null || + typeof proof !== "object" || + Array.isArray(proof) || + JSON.stringify(Object.keys(proof).sort()) !== + JSON.stringify(["entries", "genesisHeadHash", "schema"]) || + proof.schema !== "portfolio-approval-ledger-proof/v1" || + !isSha256Hex(proof.genesisHeadHash) || + !Array.isArray(proof.entries) || + proof.entries.length !== trust.latestLedgerRevision + ) + throw new Error("Authenticated approval ledger proof is incomplete."); + let head = proof.genesisHeadHash; + const latest = new Map(); + proof.entries.forEach((raw, index) => { + const envelope = verifyEnvelopeSignature(raw, trust); + const revision = index + 1; + if ( + envelope.payload.ledgerRevision !== revision || + envelope.payload.previousLedgerHeadHash !== head || + Date.parse(envelope.payload.issuedAt) > Date.parse(trust.now) + ) + throw new Error( + "Approval ledger chain is not contiguous through the protected revision.", + ); + const envelopeDigest = canonicalJsonDigest( + "portfolio-approval-envelope-digest/v1", + envelope, + ); + head = canonicalJsonDigest("portfolio-approval-ledger-entry/v1", { + previousLedgerHeadHash: head, + ledgerRevision: revision, + envelopeDigest, + }); + latest.set(typedDecisionId(envelope.payload), envelope); + }); + if (head !== trust.latestLedgerHeadHash) + throw new Error( + "Approval ledger proof does not authenticate the protected head.", + ); + return latest; +} + +export function verifyApprovalEnvelope( + envelopeValue: unknown, + trust: ApprovalTrustPolicy, +): ApprovalPayload { + const envelope = verifyEnvelopeSignature(envelopeValue, trust); + const latest = verifyApprovalLedger(trust).get( + typedDecisionId(envelope.payload), + ); + if ( + latest === undefined || + canonicalJsonDigest("portfolio-approval-envelope-digest/v1", latest) !== + canonicalJsonDigest("portfolio-approval-envelope-digest/v1", envelope) + ) { + throw new Error( + "Approval is not the latest authenticated decision for this record.", + ); + } + if ( + Date.parse(trust.now) < Date.parse(envelope.payload.issuedAt) || + Date.parse(trust.now) >= Date.parse(envelope.payload.expiresAt) + ) { + throw new Error("Approval is not current at the trusted time."); + } + if (envelope.payload.decision !== "approve") + throw new Error("The latest decision is reject/revoke, not approval."); + return envelope.payload; } diff --git a/tests/integration/release-manifest.test.ts b/tests/integration/release-manifest.test.ts index 08a897e..39f647c 100644 --- a/tests/integration/release-manifest.test.ts +++ b/tests/integration/release-manifest.test.ts @@ -1,101 +1,314 @@ -import { generateKeyPairSync, sign } from 'node:crypto'; -import { describe, expect, test } from 'vitest'; +import { generateKeyPairSync, sign } from "node:crypto"; +import { describe, expect, test } from "vitest"; -import { canonicalJsonDigest, canonicalValueBytes } from '../../src/security/canonical-digest.ts'; -import { computePreparedAssetSetDigest, computeProductionInputRoot } from '../../src/security/production-input-root.ts'; -import { validateReleaseManifest } from '../../src/security/release-manifest.ts'; +import { + canonicalJsonDigest, + canonicalValueBytes, +} from "../../src/security/canonical-digest.ts"; +import { + computePreparedAssetSetDigest, + computeProductionInputRoot, +} from "../../src/security/production-input-root.ts"; +import { validateReleaseManifest } from "../../src/security/release-manifest.ts"; // @ts-expect-error The executable JavaScript verifier intentionally has no browser-facing declaration. -import { verifyApprovalRequest } from '../../scripts/release/verify-approvals.mjs'; +import { verifyApprovalRequestForTest } from "../../scripts/release/verify-approvals.mjs"; const h = (c: string) => c.repeat(64); -describe('production input and private-source release manifest', () => { - test('hashes an exact manifest-bound PreparedAssetSet and rejects missing or extra members', () => { - const bytes = new TextEncoder().encode('image'); - const member = { assetId: 'asset-one', publicPath: 'asset-one.webp', mediaType: 'image/webp', width: 10, height: 20, durationMs: null, bytes }; - const manifest = [{ id: 'asset-one', fileName: 'asset-one.webp', sha256: '6105d6cc76af400325e94d588ce511be5bfdbb73b437dc51eca43917d7a43e3d', mediaType: 'image/webp', width: 10, height: 20, durationMs: null }]; - expect(computePreparedAssetSetDigest([member], manifest)).toMatch(/^[a-f0-9]{64}$/); +describe("production input and private-source release manifest", () => { + test("hashes an exact manifest-bound PreparedAssetSet and rejects missing or extra members", () => { + const bytes = new TextEncoder().encode("image"); + const member = { + assetId: "asset-one", + publicPath: "asset-one.webp", + mediaType: "image/webp", + width: 10, + height: 20, + durationMs: null, + bytes, + }; + const manifest = [ + { + id: "asset-one", + fileName: "asset-one.webp", + sha256: + "6105d6cc76af400325e94d588ce511be5bfdbb73b437dc51eca43917d7a43e3d", + mediaType: "image/webp", + width: 10, + height: 20, + durationMs: null, + }, + ]; + expect(computePreparedAssetSetDigest([member], manifest)).toMatch( + /^[a-f0-9]{64}$/, + ); expect(() => computePreparedAssetSetDigest([], manifest)).toThrow(); - expect(() => computePreparedAssetSetDigest([member, { ...member, assetId: 'asset-two', publicPath: 'asset-two.webp' }], manifest)).toThrow(); + expect(() => + computePreparedAssetSetDigest( + [ + member, + { ...member, assetId: "asset-two", publicPath: "asset-two.webp" }, + ], + manifest, + ), + ).toThrow(); }); - test('matches the fixed four-label input-root vector', () => { - expect(computeProductionInputRoot({ - portfolioContentDigest: h('a'), publicAssetManifestDigest: h('b'), - preparedAssetSetDigest: h('c'), siteConfigDigest: h('d'), - })).toBe('38707dd72ded582a346048ff66a1625cdaca91f6536dd27c935bc3b8af262ae8'); + test("matches the fixed four-label input-root vector", () => { + expect( + computeProductionInputRoot({ + portfolioContentDigest: h("a"), + publicAssetManifestDigest: h("b"), + preparedAssetSetDigest: h("c"), + siteConfigDigest: h("d"), + }), + ).toBe("38707dd72ded582a346048ff66a1625cdaca91f6536dd27c935bc3b8af262ae8"); }); - test('validates an exact initial-launch private-source manifest', () => { + test("validates an exact initial-launch private-source manifest", () => { const inputDigests = { - PortfolioContent: h('a'), PublicAssetManifest: h('b'), PreparedAssetSet: h('c'), SiteConfig: h('d'), + PortfolioContent: h("a"), + PublicAssetManifest: h("b"), + PreparedAssetSet: h("c"), + SiteConfig: h("d"), }; const manifest = { - schema: 'portfolio-release-manifest/v1', launchMode: 'private-source-initial-launch', buildId: 'release-2026-09-02', - inputDigests, productionInputRoot: computeProductionInputRoot({ - portfolioContentDigest: h('a'), publicAssetManifestDigest: h('b'), preparedAssetSetDigest: h('c'), siteConfigDigest: h('d'), + schema: "portfolio-release-manifest/v1", + launchMode: "private-source-initial-launch", + buildId: "release-2026-09-02", + inputDigests, + productionInputRoot: computeProductionInputRoot({ + portfolioContentDigest: h("a"), + publicAssetManifestDigest: h("b"), + preparedAssetSetDigest: h("c"), + siteConfigDigest: h("d"), }), - artifacts: [{ path: 'index.html', sha256: h('e'), bytes: 42, encoding: 'identity' }], - deployFiles: ['index.html'], sourceCommit: h('f'), sourceTree: h('0'), - approvedRecordRoot: h('1'), approvedAssetRoot: h('2'), approvalProofSetDigest: h('3'), - approvalProjectionAttestationDigest: h('4'), scanEvidenceDigest: h('5'), - policyVersions: { approval: '1', privacy: '1', release: '1' }, toolVersions: { node: '22.22.1' }, - predecessorManifestDigest: null, currentManifestDigest: null, - issuedAt: '2026-09-02T00:00:00Z', expiresAt: '2026-09-03T00:00:00Z', minimumBoundExpiry: '2026-09-03T00:00:00Z', + artifacts: [ + { path: "index.html", sha256: h("e"), bytes: 42, encoding: "identity" }, + ], + deployFiles: ["index.html"], + sourceCommit: h("f"), + sourceTree: h("0"), + approvedRecordRoot: h("1"), + approvedAssetRoot: h("2"), + approvalProofSetDigest: h("3"), + approvalProjectionAttestationDigest: h("4"), + scanEvidenceDigest: h("5"), + policyVersions: { approval: "1", privacy: "1", release: "1" }, + toolVersions: { node: "22.22.1" }, + predecessorManifestDigest: null, + currentManifestDigest: null, + issuedAt: "2026-09-02T00:00:00Z", + expiresAt: "2026-09-03T00:00:00Z", + minimumBoundExpiry: "2026-09-03T00:00:00Z", }; - expect(validateReleaseManifest(manifest, { now: '2026-09-02T01:00:00Z' })).toEqual(manifest); - expect(canonicalJsonDigest('portfolio-release-manifest/v1', manifest)).toMatch(/^[a-f0-9]{64}$/); + expect( + validateReleaseManifest(manifest, { now: "2026-09-02T01:00:00Z" }), + ).toEqual(manifest); + expect( + canonicalJsonDigest("portfolio-release-manifest/v1", manifest), + ).toMatch(/^[a-f0-9]{64}$/); }); test.each([ - ['public Git target', { publicGitTarget: 'https://example.com/repo.git' }], - ['extra deploy file', { deployFiles: ['index.html', 'extra.txt'] }], - ['case-fold collision', { artifacts: [ - { path: 'A.js', sha256: h('e'), bytes: 1, encoding: 'identity' }, - { path: 'a.js', sha256: h('f'), bytes: 1, encoding: 'identity' }, - ], deployFiles: ['A.js', 'a.js'] }], - ['expired manifest', { expiresAt: '2026-09-02T00:30:00Z' }], - ])('rejects %s', (_label, override) => { - const inputDigests = { PortfolioContent: h('a'), PublicAssetManifest: h('b'), PreparedAssetSet: h('c'), SiteConfig: h('d') }; - const baseline = { - schema: 'portfolio-release-manifest/v1', launchMode: 'private-source-initial-launch', buildId: 'release', inputDigests, - productionInputRoot: computeProductionInputRoot({ portfolioContentDigest: h('a'), publicAssetManifestDigest: h('b'), preparedAssetSetDigest: h('c'), siteConfigDigest: h('d') }), - artifacts: [{ path: 'index.html', sha256: h('e'), bytes: 42, encoding: 'identity' }], deployFiles: ['index.html'], - sourceCommit: h('f'), sourceTree: h('0'), approvedRecordRoot: h('1'), approvedAssetRoot: h('2'), approvalProofSetDigest: h('3'), - approvalProjectionAttestationDigest: h('4'), scanEvidenceDigest: h('5'), policyVersions: { approval: '1', privacy: '1', release: '1' }, - toolVersions: { node: '22' }, predecessorManifestDigest: null, currentManifestDigest: null, - issuedAt: '2026-09-02T00:00:00Z', expiresAt: '2026-09-03T00:00:00Z', minimumBoundExpiry: '2026-09-03T00:00:00Z', + ["public Git target", { publicGitTarget: "https://example.com/repo.git" }], + ["extra deploy file", { deployFiles: ["index.html", "extra.txt"] }], + [ + "case-fold collision", + { + artifacts: [ + { path: "A.js", sha256: h("e"), bytes: 1, encoding: "identity" }, + { path: "a.js", sha256: h("f"), bytes: 1, encoding: "identity" }, + ], + deployFiles: ["A.js", "a.js"], + }, + ], + ["expired manifest", { expiresAt: "2026-09-02T00:30:00Z" }], + ])("rejects %s", (_label, override) => { + const inputDigests = { + PortfolioContent: h("a"), + PublicAssetManifest: h("b"), + PreparedAssetSet: h("c"), + SiteConfig: h("d"), }; - expect(() => validateReleaseManifest({ ...baseline, ...override }, { now: '2026-09-02T01:00:00Z' })).toThrow(); + const baseline = { + schema: "portfolio-release-manifest/v1", + launchMode: "private-source-initial-launch", + buildId: "release", + inputDigests, + productionInputRoot: computeProductionInputRoot({ + portfolioContentDigest: h("a"), + publicAssetManifestDigest: h("b"), + preparedAssetSetDigest: h("c"), + siteConfigDigest: h("d"), + }), + artifacts: [ + { path: "index.html", sha256: h("e"), bytes: 42, encoding: "identity" }, + ], + deployFiles: ["index.html"], + sourceCommit: h("f"), + sourceTree: h("0"), + approvedRecordRoot: h("1"), + approvedAssetRoot: h("2"), + approvalProofSetDigest: h("3"), + approvalProjectionAttestationDigest: h("4"), + scanEvidenceDigest: h("5"), + policyVersions: { approval: "1", privacy: "1", release: "1" }, + toolVersions: { node: "22" }, + predecessorManifestDigest: null, + currentManifestDigest: null, + issuedAt: "2026-09-02T00:00:00Z", + expiresAt: "2026-09-03T00:00:00Z", + minimumBoundExpiry: "2026-09-03T00:00:00Z", + }; + expect(() => + validateReleaseManifest( + { ...baseline, ...override }, + { now: "2026-09-02T01:00:00Z" }, + ), + ).toThrow(); }); - test('CLI verifier emits only canonical public roots from a signed external approval request', () => { - const keys = generateKeyPairSync('ed25519'); + test("approval verifier requires an authenticated exact ledger and rejects replay after revoke", () => { + const keys = generateKeyPairSync("ed25519"); const approvalPayload = { - schema: 'portfolio-approval-payload/v1', keyId: 'approval-key', issuerEpoch: 1, authorizedRole: 'approver', - evidenceFingerprints: [h('1')], recordClass: 'content', recordType: 'profile', publicRecordIdOrAssetId: 'profile-main', - approvedPublicRecordHashOrManifestRecordHash: h('2'), sanitizedOutputHashOrNull: null, decision: 'approve', - issuedAt: '2026-09-01T00:00:00Z', expiresAt: '2026-09-04T00:00:00Z', previousLedgerHeadHash: h('8'), - ledgerRevision: 1, policyVersion: 'policy-1', + schema: "portfolio-approval-payload/v1", + keyId: "approval-key", + issuerEpoch: 1, + authorizedRole: "approver", + evidenceFingerprints: [h("1")], + recordClass: "content", + recordType: "profile", + publicRecordIdOrAssetId: "profile-main", + approvedPublicRecordHashOrManifestRecordHash: h("2"), + sanitizedOutputHashOrNull: null, + decision: "approve", + issuedAt: "2026-09-01T00:00:00Z", + expiresAt: "2026-09-04T00:00:00Z", + previousLedgerHeadHash: h("0"), + ledgerRevision: 1, + policyVersion: "policy-1", }; const envelope = { - schema: 'portfolio-approval-envelope/v1', payload: approvalPayload, - signature: { algorithm: 'Ed25519', encoding: 'base64url-nopad', value: sign(null, canonicalValueBytes(approvalPayload), keys.privateKey).toString('base64url') }, + schema: "portfolio-approval-envelope/v1", + payload: approvalPayload, + signature: { + algorithm: "Ed25519", + encoding: "base64url-nopad", + value: sign( + null, + canonicalValueBytes(approvalPayload), + keys.privateKey, + ).toString("base64url"), + }, }; + const envelopeDigest = canonicalJsonDigest( + "portfolio-approval-envelope-digest/v1", + envelope, + ); + const head = canonicalJsonDigest("portfolio-approval-ledger-entry/v1", { + previousLedgerHeadHash: h("0"), + ledgerRevision: 1, + envelopeDigest, + }); const request = { - schema: 'portfolio-approval-verification-request/v1', now: '2026-09-02T00:00:00Z', - latestLedgerHeadHash: h('9'), latestLedgerRevision: 2, - trustedKeys: [{ keyId: 'approval-key', issuerEpoch: 1, scope: 'portfolio-approval-decision/v1', authorizedRoles: ['approver'], publicKeyPem: keys.publicKey.export({ type: 'spki', format: 'pem' }) }], - contentRecords: [{ recordType: 'profile', publicRecordId: 'profile-main', approvedPublicRecordHash: h('2'), envelope }], + schema: "portfolio-approval-verification-request/v1", + latestLedgerHeadHash: head, + latestLedgerRevision: 1, + trustedKeys: [ + { + keyId: "approval-key", + issuerEpoch: 1, + scope: "portfolio-approval-decision/v1", + authorizedRoles: ["approver"], + publicKeyPem: keys.publicKey.export({ type: "spki", format: "pem" }), + }, + ], + ledgerProof: { + schema: "portfolio-approval-ledger-proof/v1", + genesisHeadHash: h("0"), + entries: [envelope], + }, + contentRecords: [ + { + recordType: "profile", + publicRecordId: "profile-main", + approvedPublicRecordHash: h("2"), + envelope, + }, + ], assetRecords: [], - inputDigests: { PortfolioContent: h('a'), PublicAssetManifest: h('b'), PreparedAssetSet: h('c'), SiteConfig: h('d') }, + inputDigests: { + PortfolioContent: h("a"), + PublicAssetManifest: h("b"), + PreparedAssetSet: h("c"), + SiteConfig: h("d"), + }, }; - const result = verifyApprovalRequest(canonicalValueBytes(request)); + const result = verifyApprovalRequestForTest(canonicalValueBytes(request), { + now: "2026-09-02T00:00:00Z", + }); expect(Object.keys(result).sort()).toEqual([ - 'approvalProofSetDigest', 'approvedAssetRoot', 'approvedRecordRoot', 'earliestApprovalExpiry', - 'latestLedgerHeadHash', 'latestLedgerRevision', 'productionInputRoot', 'schema', + "approvalProofSetDigest", + "approvedAssetRoot", + "approvedRecordRoot", + "earliestApprovalExpiry", + "latestLedgerHeadHash", + "latestLedgerRevision", + "productionInputRoot", + "projectionSchema", + "projectionScope", + "schema", ]); const serialized = new TextDecoder().decode(canonicalValueBytes(result)); - expect(serialized).not.toContain('evidenceFingerprints'); + expect(serialized).not.toContain("evidenceFingerprints"); + + const revokePayload = { + ...approvalPayload, + decision: "revoke", + issuedAt: "2026-09-02T01:00:00Z", + ledgerRevision: 2, + previousLedgerHeadHash: head, + }; + const revoke = { + schema: "portfolio-approval-envelope/v1", + payload: revokePayload, + signature: { + algorithm: "Ed25519", + encoding: "base64url-nopad", + value: sign( + null, + canonicalValueBytes(revokePayload), + keys.privateKey, + ).toString("base64url"), + }, + }; + const revokeDigest = canonicalJsonDigest( + "portfolio-approval-envelope-digest/v1", + revoke, + ); + const revokedHead = canonicalJsonDigest( + "portfolio-approval-ledger-entry/v1", + { + previousLedgerHeadHash: head, + ledgerRevision: 2, + envelopeDigest: revokeDigest, + }, + ); + expect(() => + verifyApprovalRequestForTest( + canonicalValueBytes({ + ...request, + latestLedgerHeadHash: revokedHead, + latestLedgerRevision: 2, + ledgerProof: { + schema: "portfolio-approval-ledger-proof/v1", + genesisHeadHash: h("0"), + entries: [envelope, revoke], + }, + }), + { now: "2026-09-02T02:00:00Z" }, + ), + ).toThrow(/latest|revoke|supersed/i); }); }); diff --git a/tests/security/privacy-workers.test.ts b/tests/security/privacy-workers.test.ts index 9b7e13d..bb55ffd 100644 --- a/tests/security/privacy-workers.test.ts +++ b/tests/security/privacy-workers.test.ts @@ -6,9 +6,11 @@ import { symlink, writeFile, } from "node:fs/promises"; +import { createHash } from "node:crypto"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { describe, expect, test } from "vitest"; +import { canonicalJsonDigest } from "../../src/security/canonical-digest.ts"; // @ts-expect-error executable release modules intentionally have no browser declarations import { enumerateClosure } from "../../scripts/release/enumerate-closure.mjs"; @@ -16,10 +18,24 @@ import { enumerateClosure } from "../../scripts/release/enumerate-closure.mjs"; import * as privacyRunner from "../../scripts/release/run-privacy-workers.mjs"; // @ts-expect-error executable release modules intentionally have no browser declarations import { reconcileScan } from "../../scripts/release/reconcile-scan.mjs"; +// @ts-expect-error container worker is executable JavaScript +import { inspectObject } from "../../containers/privacy-worker-a/worker.mjs"; +// @ts-expect-error container matcher is executable JavaScript +import { matchReports } from "../../containers/privacy-matcher/matcher.mjs"; -const { buildWorkerCommand, validateScanObjectDescriptor } = privacyRunner; +const { + buildWorkerCommand, + validateScanObjectDescriptor, + requireLiveContainerValidation, +} = privacyRunner; const h = (c: string) => c.repeat(64); +const sourceClosure = (members: unknown[] = []) => { + return { schema: "portfolio-publication-closure/v1", members }; +}; +const publicClosure = (members: unknown[] = []) => { + return { schema: "portfolio-public-object-closure/v1", members }; +}; function report( workerId: "worker-a" | "worker-b", @@ -56,6 +72,124 @@ function matcher(override: Record = {}) { } describe("isolated privacy closure and reconciliation", () => { + test("fails closed while live scanners are pending and for every non-empty public object closure", () => { + expect(() => + requireLiveContainerValidation({ + schema: "portfolio-live-container-validation/v1", + status: "pending", + reason: "not verified", + }), + ).toThrow(/pending|verified/i); + const canary = Buffer.from("PRIVATE-PROJECT-NAME\nTOKEN=secret-canary"); + const inputDigest = createHash("sha256").update(canary).digest("hex"); + expect( + inspectObject( + { + schema: "portfolio-privacy-worker-request/v1", + workerId: "worker-a", + imageDigest: h("a"), + inputDigest, + checkId: "secret", + }, + canary, + ).verdict, + ).not.toBe("pass"); + const pythonReport = report("worker-b", { + inputDigest, + checkId: "secret", + verdict: "unsupported", + }); + expect(() => + matchReports({ + reports: [ + inspectObject( + { + schema: "portfolio-privacy-worker-request/v1", + workerId: "worker-a", + imageDigest: h("a"), + inputDigest, + checkId: "secret", + }, + canary, + ), + pythonReport, + ], + rules: [{ id: "private-name-v1", deniedMemberDigests: [] }], + ruleSetDigest: h("4"), + }), + ).toThrow(/pass|unsupported|agree/i); + const source = sourceClosure(); + const publicObjectClosure = publicClosure([ + { + objectId: "private-leak.txt", + inputDigest, + requiredChecks: ["secret"], + }, + ]); + expect(() => + reconcileScan({ + sourceClosure: source, + publicObjectClosure, + reports: [ + report("worker-a", { inputDigest, checkId: "secret" }), + report("worker-b", { inputDigest, checkId: "secret" }), + ], + matcherResults: [matcher({ inputDigest, checkId: "secret" })], + candidate: { + schema: "portfolio-candidate-release-manifest/v1", + buildId: "r1", + sourceClosureDigest: canonicalJsonDigest( + "portfolio-publication-closure/v1", + { schema: source.schema, members: source.members }, + ), + publicObjectClosureDigest: canonicalJsonDigest( + "portfolio-public-object-closure/v1", + publicObjectClosure, + ), + }, + }), + ).toThrow(/unavailable|non-empty|scanner/i); + }); + + test("derives exact scan coverage from canonical closures instead of caller expectedChecks", () => { + const source = sourceClosure(); + const publicObjectClosure = publicClosure(); + const candidate = { + schema: "portfolio-candidate-release-manifest/v1", + buildId: "r1", + sourceClosureDigest: canonicalJsonDigest( + "portfolio-publication-closure/v1", + source, + ), + publicObjectClosureDigest: canonicalJsonDigest( + "portfolio-public-object-closure/v1", + publicObjectClosure, + ), + }; + const result = reconcileScan({ + sourceClosure: source, + publicObjectClosure, + reports: [], + matcherResults: [], + candidate, + }); + expect(result.evidence).toMatchObject({ + coverageCount: 0, + passedCount: 0, + sourceClosureDigest: candidate.sourceClosureDigest, + publicObjectClosureDigest: candidate.publicObjectClosureDigest, + }); + expect(() => + reconcileScan({ + sourceClosure: source, + publicObjectClosure, + expectedChecks: [{ inputDigest: h("1"), checkId: "bytes" }], + reports: [], + matcherResults: [], + candidate, + }), + ).toThrow(/missing|extra|unknown|caller|expectedChecks/i); + }); test("enumerates an exact byte-sorted closure and rejects unsafe filesystem members", async () => { const root = await mkdtemp(join(tmpdir(), "portfolio-closure-")); await mkdir(join(root, "nested")); @@ -142,14 +276,25 @@ describe("isolated privacy closure and reconciliation", () => { ).toThrowError(expect.not.stringContaining(secret)); }); - test("accepts only ordered heterogeneous agreement for every expected check", () => { + test("accepts only the exact empty public object closure while scanners are unavailable", () => { + const source = sourceClosure(); + const publicObjectClosure = publicClosure(); const result = reconcileScan({ - expectedChecks: [{ inputDigest: h("1"), checkId: "bytes" }], - reports: [report("worker-a"), report("worker-b")], - matcherResults: [matcher()], + sourceClosure: source, + publicObjectClosure, + reports: [], + matcherResults: [], candidate: { schema: "portfolio-candidate-release-manifest/v1", buildId: "r1", + sourceClosureDigest: canonicalJsonDigest( + "portfolio-publication-closure/v1", + source, + ), + publicObjectClosureDigest: canonicalJsonDigest( + "portfolio-public-object-closure/v1", + publicObjectClosure, + ), }, }); expect(result.evidenceDigest).toMatch(/^[a-f0-9]{64}$/); @@ -217,13 +362,24 @@ describe("isolated privacy closure and reconciliation", () => { }); test("writes canonical evidence and candidate objects content-addressed without overwrite", async () => { + const source = sourceClosure(); + const publicObjectClosure = publicClosure(); const result = reconcileScan({ - expectedChecks: [{ inputDigest: h("1"), checkId: "bytes" }], - reports: [report("worker-a"), report("worker-b")], - matcherResults: [matcher()], + sourceClosure: source, + publicObjectClosure, + reports: [], + matcherResults: [], candidate: { schema: "portfolio-candidate-release-manifest/v1", buildId: "r1", + sourceClosureDigest: canonicalJsonDigest( + "portfolio-publication-closure/v1", + source, + ), + publicObjectClosureDigest: canonicalJsonDigest( + "portfolio-public-object-closure/v1", + publicObjectClosure, + ), }, }); const root = await mkdtemp(join(tmpdir(), "portfolio-evidence-")); diff --git a/tests/security/release-authority.test.ts b/tests/security/release-authority.test.ts new file mode 100644 index 0000000..13d2dce --- /dev/null +++ b/tests/security/release-authority.test.ts @@ -0,0 +1,79 @@ +import { mkdtemp, mkdir, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { describe, expect, test } from "vitest"; + +import { + canonicalJsonDigest, + canonicalValueBytes, +} from "../../src/security/canonical-digest.ts"; +import { + verifySignedRelease, + verifySignedReleaseForTest, +} from "../../scripts/deploy/lib/release-common.mjs"; + +const h = (c: string) => c.repeat(64); + +describe("protected release verification authority", () => { + test("production seam rejects caller-selected verifier, policy path, and time", async () => { + await expect( + verifySignedRelease({ + signedManifestPath: "/not-used", + authorityRoot: "/not-used", + verifierModulePath: "/tmp/evil.mjs", + trustPolicyPath: "/tmp/evil.json", + now: "2000-01-01T00:00:00Z", + protectedState: {}, + } as never), + ).rejects.toThrow(/unknown|caller|production/i); + }); + + test("test seam still requires the protected trust-policy digest", async () => { + const root = await mkdtemp(join(tmpdir(), "portfolio-release-authority-")); + await mkdir(join(root, ".control")); + const signedManifestPath = join(root, "signed.json"); + const trustPolicyPath = join(root, ".control", "release-trust.json"); + const trustPolicy = { + schema: "portfolio-trust-policy/v1", + policyVersion: "test", + }; + await writeFile( + signedManifestPath, + canonicalValueBytes({ schema: "signed" }), + ); + await writeFile(trustPolicyPath, canonicalValueBytes(trustPolicy)); + const protectedState = { + latestLedgerHeadHash: h("1"), + latestLedgerRevision: 1, + currentManifestDigest: null, + publisherFence: 0, + releaseTrustPolicyDigest: h("9"), + }; + await expect( + verifySignedReleaseForTest({ + signedManifestPath, + trustPolicyPath, + protectedState, + now: "2026-09-02T01:00:00Z", + verifySignedReleaseEnvelope: () => { + throw new Error("must not reach verifier"); + }, + }), + ).rejects.toThrow(/trust.policy|authority|digest/i); + protectedState.releaseTrustPolicyDigest = canonicalJsonDigest( + "portfolio-trust-policy/v1", + trustPolicy, + ); + await expect( + verifySignedReleaseForTest({ + signedManifestPath, + trustPolicyPath, + protectedState, + now: "2026-09-02T01:00:00Z", + verifySignedReleaseEnvelope: () => { + throw new Error("reached-bound-verifier"); + }, + }), + ).rejects.toThrow("reached-bound-verifier"); + }); +}); diff --git a/tests/security/release-signing.test.ts b/tests/security/release-signing.test.ts index 4769407..12a38e4 100644 --- a/tests/security/release-signing.test.ts +++ b/tests/security/release-signing.test.ts @@ -9,19 +9,22 @@ import { canonicalValueBytes, } from "../../src/security/canonical-digest.ts"; import { computeProductionInputRoot } from "../../src/security/production-input-root.ts"; +import { APPROVAL_PROJECTION_ATTESTATION_SCOPE } from "../../src/security/approval-closure.ts"; // @ts-expect-error executable release modules intentionally have no browser declarations import * as releaseSigner from "../../scripts/release/sign-release.mjs"; const { signReleaseCandidate, + signReleaseCandidateForTest, validateTrustPolicy, verifySignedReleaseEnvelope, + verifySignedReleaseEnvelopeForTest, } = releaseSigner; const h = (c: string) => c.repeat(64); const roles = [ ["approval", "portfolio-approval-decision/v1"], - ["projection", "portfolio-approval-projection/v1"], + ["projection", APPROVAL_PROJECTION_ATTESTATION_SCOPE], ["privacyWorkload", "portfolio-privacy-workload/v1"], ["policy", "portfolio-policy-attestation/v1"], ["toolImage", "portfolio-tool-image-provenance/v1"], @@ -91,6 +94,9 @@ function envelope( ...payload, }; return { + ...(role === "projection" + ? { schema: "portfolio-approval-projection-attestation-envelope/v1" } + : {}), payload: body, signature: { algorithm: "Ed25519", @@ -142,12 +148,27 @@ function fixture() { expiresAt: "2026-09-02T12:00:00Z", minimumBoundExpiry: "2026-09-02T12:00:00Z", }; + const sourceClosure = { + schema: "portfolio-publication-closure/v1", + members: [], + }; + const publicObjectClosure = { + schema: "portfolio-public-object-closure/v1", + members: [], + }; const candidate = { schema: "portfolio-candidate-release-manifest/v1", releaseManifest, latestLedgerHeadHash: h("1"), latestLedgerRevision: 7, - sourceClosureDigest: h("4"), + sourceClosureDigest: canonicalJsonDigest( + "portfolio-publication-closure/v1", + sourceClosure, + ), + publicObjectClosureDigest: canonicalJsonDigest( + "portfolio-public-object-closure/v1", + publicObjectClosure, + ), publicRefMap: {}, lfsOids: [], releaseArchives: [], @@ -157,40 +178,40 @@ function fixture() { }; const evidence = { schema: "portfolio-scan-evidence/v1", - coverageCount: 1, - passedCount: 1, - checks: [ - { - inputDigest: h("5"), - checkId: "bytes", - normalizedOutputDigest: h("6"), - normalizedMembers: [`bytes:${h("7")}`], - workerReports: [h("8"), h("9")], - matcherResult: h("a"), - ruleSetDigest: h("b"), - appliedRuleIds: ["denylist-v1"], - }, - ], + sourceClosureDigest: candidate.sourceClosureDigest, + publicObjectClosureDigest: candidate.publicObjectClosureDigest, + coverageCount: 0, + passedCount: 0, + checks: [], }; const evidenceDigest = canonicalJsonDigest( "portfolio-scan-evidence/v1", evidence, ); candidate.releaseManifest.scanEvidenceDigest = evidenceDigest; + const projection = envelope("projection", roles[1][1], keyPairs, { + approvedRecordRoot: h("9"), + approvedAssetRoot: h("e"), + approvalProofSetDigest: h("f"), + inputDigests, + productionInputRoot: releaseManifest.productionInputRoot, + liveLedgerHeadHash: h("1"), + liveLedgerRevision: 7, + policyVersion: "2026-09-02", + expiresAt: "2026-09-02T12:00:00Z", + earliestApprovalExpiry: "2026-09-02T12:00:00Z", + }); + candidate.releaseManifest.approvalProjectionAttestationDigest = + canonicalJsonDigest( + "portfolio-approval-projection-attestation-envelope/v1", + projection, + ); const candidateDigest = canonicalJsonDigest( "portfolio-candidate-release-manifest/v1", candidate, ); const attestations = { - projection: envelope("projection", roles[1][1], keyPairs, { - digest: h("2"), - productionInputRoot: releaseManifest.productionInputRoot, - approvedRecordRoot: h("9"), - approvedAssetRoot: h("e"), - approvalProofSetDigest: h("f"), - latestLedgerHeadHash: h("1"), - latestLedgerRevision: 7, - }), + projection, privacyWorkload: envelope("privacyWorkload", roles[2][1], keyPairs, { digest: h("2"), candidateDigest, @@ -207,6 +228,8 @@ function fixture() { }; return { keyPairs, + sourceClosure, + publicObjectClosure, candidate, evidence, candidateDigest, @@ -217,6 +240,54 @@ function fixture() { } describe("external scoped release signing", () => { + test("production signer/verifier reject caller-selected time and signer reconstructs exact source/public closure", async () => { + const f = fixture(); + const dir = await mkdtemp(join(tmpdir(), "portfolio-release-key-")); + const keyPath = join(dir, "release.pem"); + await writeFile( + keyPath, + f.keyPairs.release!.privateKey.export({ type: "pkcs8", format: "pem" }), + { mode: 0o400 }, + ); + await expect( + signReleaseCandidate({ + ...f, + releaseKeyPath: keyPath, + now: "2000-01-01T00:00:00Z", + protectedState: { + latestLedgerHeadHash: h("1"), + latestLedgerRevision: 7, + currentManifestDigest: null, + publisherFence: 4, + }, + }), + ).rejects.toThrow(/now|unknown|caller/i); + const nonEmptyPublic = { + schema: "portfolio-public-object-closure/v1", + members: [ + { + objectId: "private.txt", + inputDigest: h("d"), + requiredChecks: ["secret"], + }, + ], + }; + await expect( + signReleaseCandidateForTest({ + ...f, + publicObjectClosure: nonEmptyPublic, + releaseKeyPath: keyPath, + now: "2026-09-02T01:00:00Z", + protectedState: { + latestLedgerHeadHash: h("1"), + latestLedgerRevision: 7, + currentManifestDigest: null, + publisherFence: 4, + }, + }), + ).rejects.toThrow(/closure|digest|non-empty/i); + }); + test("signs exact canonical candidate bytes with an external Ed25519 key", async () => { const f = fixture(); const dir = await mkdtemp(join(tmpdir(), "portfolio-release-key-")); @@ -226,7 +297,7 @@ describe("external scoped release signing", () => { f.keyPairs.release!.privateKey.export({ type: "pkcs8", format: "pem" }), { mode: 0o400 }, ); - const result = await signReleaseCandidate({ + const result = await signReleaseCandidateForTest({ ...f, releaseKeyPath: keyPath, now: "2026-09-02T01:00:00Z", @@ -247,7 +318,7 @@ describe("external scoped release signing", () => { ), ).toBe(true); expect( - verifySignedReleaseEnvelope({ + verifySignedReleaseEnvelopeForTest({ signedRelease: result, trustPolicy: f.trustPolicy, now: "2026-09-02T01:00:00Z", @@ -285,14 +356,14 @@ describe("external scoped release signing", () => { currentManifestDigest: null, publisherFence: 4, }; - const signedRelease = await signReleaseCandidate({ + const signedRelease = await signReleaseCandidateForTest({ ...f, releaseKeyPath: keyPath, now: "2026-09-02T01:00:00Z", protectedState: state, }); expect(() => - verifySignedReleaseEnvelope({ + verifySignedReleaseEnvelopeForTest({ signedRelease, trustPolicy: f.trustPolicy, now: "2026-09-02T01:00:00Z", @@ -300,7 +371,7 @@ describe("external scoped release signing", () => { }), ).toThrow(/stale/i); expect(() => - verifySignedReleaseEnvelope({ + verifySignedReleaseEnvelopeForTest({ signedRelease: { ...signedRelease, payload: { ...signedRelease.payload, latestLedgerRevision: 8 }, @@ -315,7 +386,7 @@ describe("external scoped release signing", () => { test("refuses repository-resident keys and non-external key material", async () => { const f = fixture(); await expect( - signReleaseCandidate({ + signReleaseCandidateForTest({ ...f, releaseKeyPath: join(process.cwd(), "release.pem"), now: "2026-09-02T01:00:00Z", @@ -424,7 +495,7 @@ describe("external scoped release signing", () => { { mode: 0o400 }, ); await expect( - signReleaseCandidate({ + signReleaseCandidateForTest({ ...f, releaseKeyPath: keyPath, now: "2026-09-02T01:00:00Z", diff --git a/tests/unit/approval-closure.test.ts b/tests/unit/approval-closure.test.ts index c681e84..62ba172 100644 --- a/tests/unit/approval-closure.test.ts +++ b/tests/unit/approval-closure.test.ts @@ -1,86 +1,242 @@ -import { generateKeyPairSync, sign } from 'node:crypto'; -import { describe, expect, test } from 'vitest'; +import { generateKeyPairSync, sign } from "node:crypto"; +import { describe, expect, test } from "vitest"; -import { computeApprovalClosure, verifyApprovalProjectionAttestation } from '../../src/security/approval-closure.ts'; -import { type ApprovalPayload } from '../../src/security/approval-envelope.ts'; -import { canonicalJsonDigest, canonicalValueBytes } from '../../src/security/canonical-digest.ts'; +import { + computeApprovalClosure, + verifyApprovalProjectionAttestation, +} from "../../src/security/approval-closure.ts"; +import { type ApprovalPayload } from "../../src/security/approval-envelope.ts"; +import { + canonicalJsonDigest, + canonicalValueBytes, +} from "../../src/security/canonical-digest.ts"; const h = (c: string) => c.repeat(64); -const keys = generateKeyPairSync('ed25519'); -const trust = { - now: '2026-09-02T00:00:00Z', latestLedgerHeadHash: h('9'), latestLedgerRevision: 3, - trustedKeys: [{ keyId: 'key', issuerEpoch: 1, scope: 'portfolio-approval-decision/v1' as const, authorizedRoles: ['approver'], publicKey: keys.publicKey }], -}; +const keys = generateKeyPairSync("ed25519"); function envelope(payload: ApprovalPayload) { return { - schema: 'portfolio-approval-envelope/v1' as const, + schema: "portfolio-approval-envelope/v1" as const, payload, - signature: { algorithm: 'Ed25519' as const, encoding: 'base64url-nopad' as const, value: sign(null, canonicalValueBytes(payload), keys.privateKey).toString('base64url') }, + signature: { + algorithm: "Ed25519" as const, + encoding: "base64url-nopad" as const, + value: sign(null, canonicalValueBytes(payload), keys.privateKey).toString( + "base64url", + ), + }, }; } -function base(recordClass: 'content' | 'asset', id: string): ApprovalPayload { +function base( + recordClass: "content" | "asset", + id: string, + ledgerRevision: number, + previousLedgerHeadHash: string, +): ApprovalPayload { return { - schema: 'portfolio-approval-payload/v1', keyId: 'key', issuerEpoch: 1, authorizedRole: 'approver', - evidenceFingerprints: [h('1')], recordClass, recordType: recordClass === 'content' ? 'profile' : '', - publicRecordIdOrAssetId: id, approvedPublicRecordHashOrManifestRecordHash: h(recordClass === 'content' ? '2' : '3'), - sanitizedOutputHashOrNull: recordClass === 'asset' ? h('4') : null, decision: 'approve', - issuedAt: '2026-09-01T00:00:00Z', expiresAt: '2026-10-01T00:00:00Z', previousLedgerHeadHash: h('8'), - ledgerRevision: 2, policyVersion: 'p1', + schema: "portfolio-approval-payload/v1", + keyId: "key", + issuerEpoch: 1, + authorizedRole: "approver", + evidenceFingerprints: [h("1")], + recordClass, + recordType: recordClass === "content" ? "profile" : "", + publicRecordIdOrAssetId: id, + approvedPublicRecordHashOrManifestRecordHash: h( + recordClass === "content" ? "2" : "3", + ), + sanitizedOutputHashOrNull: recordClass === "asset" ? h("4") : null, + decision: "approve", + issuedAt: "2026-09-01T00:00:00Z", + expiresAt: "2026-10-01T00:00:00Z", + previousLedgerHeadHash, + ledgerRevision, + policyVersion: "p1", }; } -describe('approval closure', () => { - test('computes exact sorted record, asset, and proof roots', () => { - const content = base('content', 'profile-main'); - const asset = base('asset', 'asset-main'); - const result = computeApprovalClosure({ - contentRecords: [{ recordType: 'profile', publicRecordId: 'profile-main', approvedPublicRecordHash: h('2'), envelope: envelope(content) }], - assetRecords: [{ assetId: 'asset-main', approvedManifestRecordHash: h('3'), sanitizedOutputHash: h('4'), envelope: envelope(asset) }], - trust, +function ledgerTrust(entries: ReturnType[]) { + let head = h("0"); + entries.forEach((entry, index) => { + const envelopeDigest = canonicalJsonDigest( + "portfolio-approval-envelope-digest/v1", + entry, + ); + head = canonicalJsonDigest("portfolio-approval-ledger-entry/v1", { + previousLedgerHeadHash: head, + ledgerRevision: index + 1, + envelopeDigest, }); - const contentEnvelopeDigest = canonicalJsonDigest('portfolio-approval-envelope-digest/v1', envelope(content)); - const assetEnvelopeDigest = canonicalJsonDigest('portfolio-approval-envelope-digest/v1', envelope(asset)); - expect(result.approvedRecordRoot).toBe(canonicalJsonDigest('portfolio-record-closure/v1', { - records: [['profile', 'profile-main', h('2'), contentEnvelopeDigest]], - })); - expect(result.approvedAssetRoot).toBe(canonicalJsonDigest('portfolio-asset-closure/v1', { - assets: [['asset-main', h('3'), h('4'), assetEnvelopeDigest]], - })); - expect(result.approvalProofSetDigest).toBe(canonicalJsonDigest('portfolio-approval-proof-set/v1', { - envelopes: [['asset', '', 'asset-main', assetEnvelopeDigest], ['content', 'profile', 'profile-main', contentEnvelopeDigest]], - })); + }); + return { + now: "2026-09-02T00:00:00Z", + latestLedgerHeadHash: head, + latestLedgerRevision: entries.length, + trustedKeys: [ + { + keyId: "key", + issuerEpoch: 1, + scope: "portfolio-approval-decision/v1" as const, + authorizedRoles: ["approver"], + publicKey: keys.publicKey, + }, + ], + ledgerProof: { + schema: "portfolio-approval-ledger-proof/v1" as const, + genesisHeadHash: h("0"), + entries, + }, + }; +} + +describe("approval closure", () => { + test("computes exact sorted record, asset, and proof roots", () => { + const content = base("content", "profile-main", 1, h("0")); + const contentEnvelope = envelope(content); + const head1 = canonicalJsonDigest("portfolio-approval-ledger-entry/v1", { + previousLedgerHeadHash: h("0"), + ledgerRevision: 1, + envelopeDigest: canonicalJsonDigest( + "portfolio-approval-envelope-digest/v1", + contentEnvelope, + ), + }); + const asset = base("asset", "asset-main", 2, head1); + const assetEnvelope = envelope(asset); + const result = computeApprovalClosure({ + contentRecords: [ + { + recordType: "profile", + publicRecordId: "profile-main", + approvedPublicRecordHash: h("2"), + envelope: contentEnvelope, + }, + ], + assetRecords: [ + { + assetId: "asset-main", + approvedManifestRecordHash: h("3"), + sanitizedOutputHash: h("4"), + envelope: assetEnvelope, + }, + ], + trust: ledgerTrust([contentEnvelope, assetEnvelope]), + }); + const contentEnvelopeDigest = canonicalJsonDigest( + "portfolio-approval-envelope-digest/v1", + envelope(content), + ); + const assetEnvelopeDigest = canonicalJsonDigest( + "portfolio-approval-envelope-digest/v1", + envelope(asset), + ); + expect(result.approvedRecordRoot).toBe( + canonicalJsonDigest("portfolio-record-closure/v1", { + records: [["profile", "profile-main", h("2"), contentEnvelopeDigest]], + }), + ); + expect(result.approvedAssetRoot).toBe( + canonicalJsonDigest("portfolio-asset-closure/v1", { + assets: [["asset-main", h("3"), h("4"), assetEnvelopeDigest]], + }), + ); + expect(result.approvalProofSetDigest).toBe( + canonicalJsonDigest("portfolio-approval-proof-set/v1", { + envelopes: [ + ["asset", "", "asset-main", assetEnvelopeDigest], + ["content", "profile", "profile-main", contentEnvelopeDigest], + ], + }), + ); }); - test('rejects duplicate IDs and stale protected ledger input', () => { - const content = base('content', 'profile-main'); - const record = { recordType: 'profile', publicRecordId: 'profile-main', approvedPublicRecordHash: h('2'), envelope: envelope(content) }; - expect(() => computeApprovalClosure({ contentRecords: [record, record], assetRecords: [], trust })).toThrow(); - expect(() => computeApprovalClosure({ contentRecords: [record], assetRecords: [], trust: { ...trust, latestLedgerRevision: 1 } })).toThrow(); + test("rejects duplicate IDs and stale protected ledger input", () => { + const content = base("content", "profile-main", 1, h("0")); + const record = { + recordType: "profile", + publicRecordId: "profile-main", + approvedPublicRecordHash: h("2"), + envelope: envelope(content), + }; + const trust = ledgerTrust([record.envelope]); + expect(() => + computeApprovalClosure({ + contentRecords: [record, record], + assetRecords: [], + trust, + }), + ).toThrow(); + expect(() => + computeApprovalClosure({ + contentRecords: [record], + assetRecords: [], + trust: { ...trust, latestLedgerRevision: 0 }, + }), + ).toThrow(); }); - test('verifies a scoped projection attestation against protected state and trusted time', () => { + test("verifies a scoped projection attestation against protected state and trusted time", () => { const payload = { - schema: 'portfolio-approval-projection-attestation/v1' as const, - keyId: 'projection-key', issuerEpoch: 4, scope: 'portfolio-approval-projection-attestation/v1' as const, - approvedRecordRoot: h('1'), approvedAssetRoot: h('2'), approvalProofSetDigest: h('3'), - inputDigests: { PortfolioContent: h('4'), PublicAssetManifest: h('5'), PreparedAssetSet: h('6'), SiteConfig: h('7') }, - productionInputRoot: 'e19fd4083ba973074449e5ecc9364c2b339eadea32e61d8dd2f0d8e3d6e91c85', - liveLedgerHeadHash: h('8'), liveLedgerRevision: 42, policyVersion: 'projection-policy-1', - issuedAt: '2026-09-02T00:00:00Z', expiresAt: '2026-09-03T00:00:00Z', earliestApprovalExpiry: '2026-09-04T00:00:00Z', + schema: "portfolio-approval-projection-attestation/v1" as const, + keyId: "projection-key", + issuerEpoch: 4, + scope: "portfolio-approval-projection-attestation/v1" as const, + approvedRecordRoot: h("1"), + approvedAssetRoot: h("2"), + approvalProofSetDigest: h("3"), + inputDigests: { + PortfolioContent: h("4"), + PublicAssetManifest: h("5"), + PreparedAssetSet: h("6"), + SiteConfig: h("7"), + }, + productionInputRoot: + "e19fd4083ba973074449e5ecc9364c2b339eadea32e61d8dd2f0d8e3d6e91c85", + liveLedgerHeadHash: h("8"), + liveLedgerRevision: 42, + policyVersion: "projection-policy-1", + issuedAt: "2026-09-02T00:00:00Z", + expiresAt: "2026-09-03T00:00:00Z", + earliestApprovalExpiry: "2026-09-04T00:00:00Z", }; - const projectionKeys = generateKeyPairSync('ed25519'); + const projectionKeys = generateKeyPairSync("ed25519"); const attestation = { - schema: 'portfolio-approval-projection-attestation-envelope/v1' as const, + schema: "portfolio-approval-projection-attestation-envelope/v1" as const, payload, - signature: { algorithm: 'Ed25519' as const, encoding: 'base64url-nopad' as const, value: sign(null, canonicalValueBytes(payload), projectionKeys.privateKey).toString('base64url') }, + signature: { + algorithm: "Ed25519" as const, + encoding: "base64url-nopad" as const, + value: sign( + null, + canonicalValueBytes(payload), + projectionKeys.privateKey, + ).toString("base64url"), + }, }; - expect(verifyApprovalProjectionAttestation(attestation, { - now: '2026-09-02T01:00:00Z', latestLedgerHeadHash: h('8'), latestLedgerRevision: 42, - trustedKey: { keyId: 'projection-key', issuerEpoch: 4, scope: 'portfolio-approval-projection-attestation/v1', publicKey: projectionKeys.publicKey }, - }).liveLedgerRevision).toBe(42); - expect(() => verifyApprovalProjectionAttestation(attestation, { - now: '2026-09-02T01:00:00Z', latestLedgerHeadHash: h('9'), latestLedgerRevision: 42, - trustedKey: { keyId: 'projection-key', issuerEpoch: 4, scope: 'portfolio-approval-projection-attestation/v1', publicKey: projectionKeys.publicKey }, - })).toThrow(); + expect( + verifyApprovalProjectionAttestation(attestation, { + now: "2026-09-02T01:00:00Z", + latestLedgerHeadHash: h("8"), + latestLedgerRevision: 42, + trustedKey: { + keyId: "projection-key", + issuerEpoch: 4, + scope: "portfolio-approval-projection-attestation/v1", + publicKey: projectionKeys.publicKey, + }, + }).liveLedgerRevision, + ).toBe(42); + expect(() => + verifyApprovalProjectionAttestation(attestation, { + now: "2026-09-02T01:00:00Z", + latestLedgerHeadHash: h("9"), + latestLedgerRevision: 42, + trustedKey: { + keyId: "projection-key", + issuerEpoch: 4, + scope: "portfolio-approval-projection-attestation/v1", + publicKey: projectionKeys.publicKey, + }, + }), + ).toThrow(); }); }); diff --git a/tests/unit/approval-envelope.test.ts b/tests/unit/approval-envelope.test.ts index 0a6fc4d..999392b 100644 --- a/tests/unit/approval-envelope.test.ts +++ b/tests/unit/approval-envelope.test.ts @@ -1,82 +1,137 @@ -import { generateKeyPairSync, sign } from 'node:crypto'; -import { describe, expect, test } from 'vitest'; +import { generateKeyPairSync, sign } from "node:crypto"; +import { describe, expect, test } from "vitest"; import { type ApprovalPayload, parseApprovalEnvelopeJson, verifyApprovalEnvelope, -} from '../../src/security/approval-envelope.ts'; -import { canonicalValueBytes } from '../../src/security/canonical-digest.ts'; +} from "../../src/security/approval-envelope.ts"; +import { + canonicalJsonDigest, + canonicalValueBytes, +} from "../../src/security/canonical-digest.ts"; const hash = (character: string) => character.repeat(64); const payload = (): ApprovalPayload => ({ - schema: 'portfolio-approval-payload/v1', - keyId: 'approval-2026', + schema: "portfolio-approval-payload/v1", + keyId: "approval-2026", issuerEpoch: 3, - authorizedRole: 'portfolio-approver', - evidenceFingerprints: [hash('a')], - recordClass: 'content', - recordType: 'profile', - publicRecordIdOrAssetId: 'profile-main', - approvedPublicRecordHashOrManifestRecordHash: hash('b'), + authorizedRole: "portfolio-approver", + evidenceFingerprints: [hash("a")], + recordClass: "content", + recordType: "profile", + publicRecordIdOrAssetId: "profile-main", + approvedPublicRecordHashOrManifestRecordHash: hash("b"), sanitizedOutputHashOrNull: null, - decision: 'approve', - issuedAt: '2026-09-01T08:00:00Z', - expiresAt: '2026-10-01T08:00:00Z', - previousLedgerHeadHash: hash('c'), - ledgerRevision: 12, - policyVersion: 'approval-policy-1', + decision: "approve", + issuedAt: "2026-09-01T08:00:00Z", + expiresAt: "2026-10-01T08:00:00Z", + previousLedgerHeadHash: hash("0"), + ledgerRevision: 1, + policyVersion: "approval-policy-1", }); function signedEnvelope(overrides: Partial = {}) { - const keys = generateKeyPairSync('ed25519'); + const keys = generateKeyPairSync("ed25519"); const signedPayload = { ...payload(), ...overrides }; - const signature = sign(null, canonicalValueBytes(signedPayload), keys.privateKey).toString('base64url'); + const signature = sign( + null, + canonicalValueBytes(signedPayload), + keys.privateKey, + ).toString("base64url"); return { envelope: { - schema: 'portfolio-approval-envelope/v1' as const, + schema: "portfolio-approval-envelope/v1" as const, payload: signedPayload, - signature: { algorithm: 'Ed25519' as const, encoding: 'base64url-nopad' as const, value: signature }, + signature: { + algorithm: "Ed25519" as const, + encoding: "base64url-nopad" as const, + value: signature, + }, }, publicKey: keys.publicKey, }; } -describe('approval envelopes', () => { - test('verifies an exact, current, scoped Ed25519 approval', () => { +function trustFor( + envelope: ReturnType["envelope"], + publicKey: ReturnType["publicKey"], +) { + const envelopeDigest = canonicalJsonDigest( + "portfolio-approval-envelope-digest/v1", + envelope, + ); + const latestLedgerHeadHash = canonicalJsonDigest( + "portfolio-approval-ledger-entry/v1", + { + previousLedgerHeadHash: hash("0"), + ledgerRevision: 1, + envelopeDigest, + }, + ); + return { + now: "2026-09-02T00:00:00Z", + latestLedgerHeadHash, + latestLedgerRevision: 1, + trustedKeys: [ + { + keyId: "approval-2026", + issuerEpoch: 3, + scope: "portfolio-approval-decision/v1" as const, + authorizedRoles: ["portfolio-approver"], + publicKey, + }, + ], + ledgerProof: { + schema: "portfolio-approval-ledger-proof/v1" as const, + genesisHeadHash: hash("0"), + entries: [envelope], + }, + }; +} + +describe("approval envelopes", () => { + test("verifies an exact, current, scoped Ed25519 approval", () => { const { envelope, publicKey } = signedEnvelope(); - const verified = verifyApprovalEnvelope(envelope, { - now: '2026-09-02T00:00:00Z', - latestLedgerHeadHash: hash('d'), - latestLedgerRevision: 13, - trustedKeys: [{ - keyId: 'approval-2026', issuerEpoch: 3, scope: 'portfolio-approval-decision/v1', - authorizedRoles: ['portfolio-approver'], publicKey, - }], - }); - expect(verified.publicRecordIdOrAssetId).toBe('profile-main'); + const verified = verifyApprovalEnvelope( + envelope, + trustFor(envelope, publicKey), + ); + expect(verified.publicRecordIdOrAssetId).toBe("profile-main"); }); test.each([ - ['expired', { expiresAt: '2026-09-01T09:00:00Z' }], - ['non-UTC timestamp', { issuedAt: '2026-09-01T10:00:00+02:00' }], - ['wrong epoch', { issuerEpoch: 2 }], - ['rejection', { decision: 'reject' }], - ] as const)('rejects a %s envelope', (_label, override) => { + ["expired", { expiresAt: "2026-09-01T09:00:00Z" }], + ["non-UTC timestamp", { issuedAt: "2026-09-01T10:00:00+02:00" }], + ["wrong epoch", { issuerEpoch: 2 }], + ["rejection", { decision: "reject" }], + ] as const)("rejects a %s envelope", (_label, override) => { const { envelope, publicKey } = signedEnvelope(override); - expect(() => verifyApprovalEnvelope(envelope, { - now: '2026-09-02T00:00:00Z', latestLedgerHeadHash: hash('d'), latestLedgerRevision: 13, - trustedKeys: [{ keyId: 'approval-2026', issuerEpoch: 3, scope: 'portfolio-approval-decision/v1', authorizedRoles: ['portfolio-approver'], publicKey }], - })).toThrow(); + expect(() => + verifyApprovalEnvelope(envelope, trustFor(envelope, publicKey)), + ).toThrow(); }); - test('rejects unknown fields, duplicate keys, and alternate signature encodings at ingest', () => { + test("rejects unknown fields, duplicate keys, and alternate signature encodings at ingest", () => { const { envelope } = signedEnvelope(); - expect(() => parseApprovalEnvelopeJson(JSON.stringify({ ...envelope, extra: true }))).toThrow(); - expect(() => parseApprovalEnvelopeJson('{"schema":"portfolio-approval-envelope/v1","schema":"portfolio-approval-envelope/v1"}')).toThrow(); - expect(() => parseApprovalEnvelopeJson(JSON.stringify({ - ...envelope, - signature: { ...envelope.signature, value: `${envelope.signature.value}=` }, - }))).toThrow(); + expect(() => + parseApprovalEnvelopeJson(JSON.stringify({ ...envelope, extra: true })), + ).toThrow(); + expect(() => + parseApprovalEnvelopeJson( + '{"schema":"portfolio-approval-envelope/v1","schema":"portfolio-approval-envelope/v1"}', + ), + ).toThrow(); + expect(() => + parseApprovalEnvelopeJson( + JSON.stringify({ + ...envelope, + signature: { + ...envelope.signature, + value: `${envelope.signature.value}=`, + }, + }), + ), + ).toThrow(); }); });