fix: close release provenance and privacy gaps
This commit is contained in:
@@ -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<string, unknown>;
|
||||
}): Promise<Record<string, unknown>>;
|
||||
|
||||
export function verifySignedReleaseForTest(input: {
|
||||
signedManifestPath: string;
|
||||
trustPolicyPath: string;
|
||||
verifySignedReleaseEnvelope: (input: Record<string, unknown>) => unknown;
|
||||
protectedState: Record<string, unknown>;
|
||||
now: string;
|
||||
}): Promise<Record<string, unknown>>;
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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 <canonical-request.json>');
|
||||
if (args.length !== 2 || args[0] !== "--request" || args[1].length === 0)
|
||||
throw new Error(
|
||||
"Usage: verify-approvals.mjs --request <canonical-request.json>",
|
||||
);
|
||||
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;
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user