fix: close release provenance and privacy gaps
This commit is contained in:
@@ -1,7 +1,7 @@
|
|||||||
import { createHash } from "node:crypto";
|
|
||||||
import { readFile, writeFile } from "node:fs/promises";
|
import { readFile, writeFile } from "node:fs/promises";
|
||||||
|
import { pathToFileURL } from "node:url";
|
||||||
|
|
||||||
const request = JSON.parse(await readFile("/input/request.json", "utf8"));
|
export function matchReports(request) {
|
||||||
const [a, b] = request.reports ?? [];
|
const [a, b] = request.reports ?? [];
|
||||||
if (
|
if (
|
||||||
!a ||
|
!a ||
|
||||||
@@ -13,11 +13,12 @@ if (
|
|||||||
a.inputDigest !== b.inputDigest ||
|
a.inputDigest !== b.inputDigest ||
|
||||||
a.checkId !== b.checkId ||
|
a.checkId !== b.checkId ||
|
||||||
a.normalizedOutputDigest !== b.normalizedOutputDigest ||
|
a.normalizedOutputDigest !== b.normalizedOutputDigest ||
|
||||||
JSON.stringify(a.normalizedMembers) !== JSON.stringify(b.normalizedMembers) ||
|
JSON.stringify(a.normalizedMembers) !==
|
||||||
|
JSON.stringify(b.normalizedMembers) ||
|
||||||
a.verdict !== "pass" ||
|
a.verdict !== "pass" ||
|
||||||
b.verdict !== "pass"
|
b.verdict !== "pass"
|
||||||
)
|
)
|
||||||
throw new Error("Independent worker evidence does not agree.");
|
throw new Error("Independent worker evidence does not agree and pass.");
|
||||||
const rules = request.rules;
|
const rules = request.rules;
|
||||||
if (!Array.isArray(rules) || rules.length === 0)
|
if (!Array.isArray(rules) || rules.length === 0)
|
||||||
throw new Error("Matcher rules are required.");
|
throw new Error("Matcher rules are required.");
|
||||||
@@ -32,7 +33,7 @@ if (
|
|||||||
const denied = new Set(rules.flatMap((rule) => rule.deniedMemberDigests));
|
const denied = new Set(rules.flatMap((rule) => rule.deniedMemberDigests));
|
||||||
if (a.normalizedMembers.some((member) => denied.has(member)))
|
if (a.normalizedMembers.some((member) => denied.has(member)))
|
||||||
throw new Error("Privacy rule matched normalized evidence.");
|
throw new Error("Privacy rule matched normalized evidence.");
|
||||||
const result = {
|
return {
|
||||||
schema: "portfolio-privacy-matcher-result/v1",
|
schema: "portfolio-privacy-matcher-result/v1",
|
||||||
inputDigest: a.inputDigest,
|
inputDigest: a.inputDigest,
|
||||||
checkId: a.checkId,
|
checkId: a.checkId,
|
||||||
@@ -43,7 +44,17 @@ const result = {
|
|||||||
verdict: "pass",
|
verdict: "pass",
|
||||||
workerImageDigests: [a.imageDigest, b.imageDigest],
|
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), {
|
await writeFile("/output/result.json", JSON.stringify(result), {
|
||||||
flag: "wx",
|
flag: "wx",
|
||||||
mode: 0o400,
|
mode: 0o400,
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href)
|
||||||
|
await main();
|
||||||
|
|||||||
@@ -1,33 +1,45 @@
|
|||||||
import { createHash } from "node:crypto";
|
import { createHash } from "node:crypto";
|
||||||
import { readFile, writeFile } from "node:fs/promises";
|
import { readFile, writeFile } from "node:fs/promises";
|
||||||
|
import { pathToFileURL } from "node:url";
|
||||||
|
|
||||||
const request = JSON.parse(await readFile("/input/request.json", "utf8"));
|
export function inspectObject(request, bytes) {
|
||||||
if (
|
if (
|
||||||
request.schema !== "portfolio-privacy-worker-request/v1" ||
|
request?.schema !== "portfolio-privacy-worker-request/v1" ||
|
||||||
request.workerId !== "worker-a" ||
|
request.workerId !== "worker-a" ||
|
||||||
!/^[a-f0-9]{64}$/u.test(request.inputDigest)
|
!/^[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.");
|
throw new Error("Invalid worker request.");
|
||||||
const bytes = await readFile("/input/object");
|
|
||||||
const actual = createHash("sha256").update(bytes).digest("hex");
|
const actual = createHash("sha256").update(bytes).digest("hex");
|
||||||
if (actual !== request.inputDigest) throw new Error("Input digest mismatch.");
|
if (actual !== request.inputDigest) throw new Error("Input digest mismatch.");
|
||||||
const normalizedMembers = [`bytes:${actual}`];
|
const normalizedMembers = [`unsupported:${actual}`];
|
||||||
const normalizedOutputDigest = createHash("sha256")
|
return {
|
||||||
.update(Buffer.from(JSON.stringify(normalizedMembers)))
|
|
||||||
.digest("hex");
|
|
||||||
const output = {
|
|
||||||
schema: "portfolio-privacy-worker-report/v1",
|
schema: "portfolio-privacy-worker-report/v1",
|
||||||
workerId: "worker-a",
|
workerId: "worker-a",
|
||||||
imageDigest: request.imageDigest,
|
imageDigest: request.imageDigest,
|
||||||
toolchain: "bytes-js/v1",
|
toolchain: "scanner-unavailable-js/v1",
|
||||||
inputDigest: actual,
|
inputDigest: actual,
|
||||||
checkId: request.checkId,
|
checkId: request.checkId,
|
||||||
normalizedOutputDigest,
|
normalizedOutputDigest: createHash("sha256")
|
||||||
|
.update(Buffer.from(JSON.stringify(normalizedMembers)))
|
||||||
|
.digest("hex"),
|
||||||
normalizedMembers,
|
normalizedMembers,
|
||||||
verdict: "pass",
|
verdict: "unsupported",
|
||||||
findingsDigest: "0".repeat(64),
|
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), {
|
await writeFile("/output/report.json", JSON.stringify(output), {
|
||||||
flag: "wx",
|
flag: "wx",
|
||||||
mode: 0o400,
|
mode: 0o400,
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href)
|
||||||
|
await main();
|
||||||
|
|||||||
@@ -1,20 +1,23 @@
|
|||||||
import hashlib
|
import hashlib
|
||||||
import json
|
import json
|
||||||
|
|
||||||
with open('/input/request.json', 'r', encoding='utf-8') as handle:
|
def inspect_object(request, payload):
|
||||||
request = json.load(handle)
|
|
||||||
if request.get('schema') != 'portfolio-privacy-worker-request/v1' or request.get('workerId') != 'worker-b':
|
if request.get('schema') != 'portfolio-privacy-worker-request/v1' or request.get('workerId') != 'worker-b':
|
||||||
raise ValueError('Invalid worker request.')
|
raise ValueError('Invalid worker request.')
|
||||||
with open('/input/object', 'rb') as handle:
|
|
||||||
payload = handle.read()
|
|
||||||
actual = hashlib.sha256(payload).hexdigest()
|
actual = hashlib.sha256(payload).hexdigest()
|
||||||
if actual != request.get('inputDigest'):
|
if actual != request.get('inputDigest'):
|
||||||
raise ValueError('Input digest mismatch.')
|
raise ValueError('Input digest mismatch.')
|
||||||
members = ['bytes:' + actual]
|
members = ['unsupported:' + actual]
|
||||||
normalized = json.dumps(members, separators=(',', ':')).encode('utf-8')
|
normalized = json.dumps(members, separators=(',', ':')).encode('utf-8')
|
||||||
output = {'schema': 'portfolio-privacy-worker-report/v1', 'workerId': 'worker-b', 'imageDigest': request['imageDigest'],
|
return {'schema': 'portfolio-privacy-worker-report/v1', 'workerId': 'worker-b', 'imageDigest': request['imageDigest'],
|
||||||
'toolchain': 'bytes-python/v1', 'inputDigest': actual, 'checkId': request['checkId'],
|
'toolchain': 'scanner-unavailable-python/v1', 'inputDigest': actual, 'checkId': request['checkId'],
|
||||||
'normalizedOutputDigest': hashlib.sha256(normalized).hexdigest(), 'normalizedMembers': members,
|
'normalizedOutputDigest': hashlib.sha256(normalized).hexdigest(), 'normalizedMembers': members,
|
||||||
'verdict': 'pass', 'findingsDigest': '0' * 64}
|
'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:
|
with open('/output/report.json', 'x', encoding='utf-8') as handle:
|
||||||
json.dump(output, handle, separators=(',', ':'))
|
json.dump(inspect_object(request, payload), handle, separators=(',', ':'))
|
||||||
|
|||||||
@@ -5,3 +5,17 @@ export interface ArchiveFile {
|
|||||||
|
|
||||||
export function createDeterministicTar(files: readonly ArchiveFile[]): Buffer;
|
export function createDeterministicTar(files: readonly ArchiveFile[]): Buffer;
|
||||||
export function parseDeterministicTar(source: Uint8Array): ArchiveFile[];
|
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 { createHash } from "node:crypto";
|
||||||
import { chmod, lstat, mkdir, open, readFile, readdir, realpath, rename, rm, stat, writeFile } from 'node:fs/promises';
|
import {
|
||||||
import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
|
chmod,
|
||||||
import { pathToFileURL } from 'node:url';
|
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 {
|
||||||
import { validatePublicPath } from '../../../src/security/production-input-root.ts';
|
byteDigest,
|
||||||
import { validateReleaseManifest } from '../../../src/security/release-manifest.ts';
|
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 encoder = new TextEncoder();
|
||||||
const reservedPrefix = '.release/';
|
const reservedPrefix = ".release/";
|
||||||
const safeToken = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/u;
|
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;
|
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) {
|
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 actual = Object.keys(value).sort();
|
||||||
const expected = [...fields].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.`);
|
throw new Error(`${label} contains missing or unknown fields.`);
|
||||||
}
|
}
|
||||||
return value;
|
return value;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function assertToken(value, label) {
|
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;
|
return value;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function assertTimestamp(value, label) {
|
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;
|
return value;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -39,78 +67,222 @@ export function assertDigest(value, label) {
|
|||||||
|
|
||||||
export async function readCanonicalFile(path, label) {
|
export async function readCanonicalFile(path, label) {
|
||||||
const info = await lstat(path);
|
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));
|
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) {
|
export function validateProtectedState(value) {
|
||||||
const state = exactObject(value, ['latestLedgerHeadHash', 'latestLedgerRevision', 'currentManifestDigest', 'publisherFence'], 'protected state');
|
const state = exactObject(
|
||||||
assertDigest(state.latestLedgerHeadHash, 'protected ledger head');
|
value,
|
||||||
if (!Number.isSafeInteger(state.latestLedgerRevision) || state.latestLedgerRevision < 0 ||
|
[
|
||||||
!Number.isSafeInteger(state.publisherFence) || state.publisherFence < 0 ||
|
"latestLedgerHeadHash",
|
||||||
(state.currentManifestDigest !== null && !isSha256Hex(state.currentManifestDigest))) throw new Error('Protected state is invalid.');
|
"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;
|
return state;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function validateAdmission(value, protectedState, now) {
|
export function validateAdmission(value, protectedState, now) {
|
||||||
const admission = exactObject(value, [
|
const admission = exactObject(
|
||||||
'schema', 'manifestDigest', 'manifestPayload', 'expiresAt', 'predecessorManifestDigest', 'latestLedgerHeadHash',
|
value,
|
||||||
'latestLedgerRevision', 'expectedFence', 'signatureVerified',
|
[
|
||||||
], 'verified release admission');
|
"schema",
|
||||||
if (admission.schema !== 'portfolio-verified-release-admission/v1' || admission.signatureVerified !== true) {
|
"manifestDigest",
|
||||||
throw new Error('Release signature was not verified.');
|
"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');
|
assertDigest(admission.manifestDigest, "manifest digest");
|
||||||
assertTimestamp(now, 'trusted time');
|
assertTimestamp(now, "trusted time");
|
||||||
assertTimestamp(admission.expiresAt, 'admission expiry');
|
assertTimestamp(admission.expiresAt, "admission expiry");
|
||||||
if (Date.parse(now) >= Date.parse(admission.expiresAt)) throw new Error('Release admission is expired.');
|
if (Date.parse(now) >= Date.parse(admission.expiresAt))
|
||||||
if (admission.predecessorManifestDigest !== null) assertDigest(admission.predecessorManifestDigest, 'predecessor manifest digest');
|
throw new Error("Release admission is expired.");
|
||||||
|
if (admission.predecessorManifestDigest !== null)
|
||||||
|
assertDigest(
|
||||||
|
admission.predecessorManifestDigest,
|
||||||
|
"predecessor manifest digest",
|
||||||
|
);
|
||||||
const state = validateProtectedState(protectedState);
|
const state = validateProtectedState(protectedState);
|
||||||
if (admission.latestLedgerHeadHash !== state.latestLedgerHeadHash || admission.latestLedgerRevision !== state.latestLedgerRevision) {
|
if (
|
||||||
throw new Error('Release admission is stale relative to the protected ledger.');
|
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) {
|
if (
|
||||||
throw new Error('Release admission predecessor or fence is stale.');
|
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 });
|
const manifest = validateReleaseManifest(admission.manifestPayload, { now });
|
||||||
if (manifest.expiresAt !== admission.expiresAt || manifest.currentManifestDigest !== admission.predecessorManifestDigest ||
|
if (
|
||||||
manifest.predecessorManifestDigest !== admission.predecessorManifestDigest) throw new Error('Admission does not bind the embedded release manifest.');
|
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 };
|
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([
|
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);
|
const state = validateProtectedState(protectedState);
|
||||||
if (typeof verifier !== 'function') throw new Error('A release signature verifier is required.');
|
if (
|
||||||
const result = await verifier({ signedRelease, trustPolicy, now, protectedState });
|
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);
|
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) {
|
export async function enumerateRegularFiles(root) {
|
||||||
const rootPath = await realpath(root);
|
const rootPath = await realpath(root);
|
||||||
const output = [];
|
const output = [];
|
||||||
async function visit(directory, prefix = '') {
|
async function visit(directory, prefix = "") {
|
||||||
const entries = await readdir(directory, { withFileTypes: true });
|
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) {
|
for (const entry of entries) {
|
||||||
const path = join(directory, entry.name);
|
const path = join(directory, entry.name);
|
||||||
const publicPath = prefix === '' ? entry.name : `${prefix}/${entry.name}`;
|
const publicPath = prefix === "" ? entry.name : `${prefix}/${entry.name}`;
|
||||||
if (entry.isSymbolicLink()) throw new Error(`Symlink is forbidden in release closure: ${publicPath}`);
|
if (entry.isSymbolicLink())
|
||||||
|
throw new Error(
|
||||||
|
`Symlink is forbidden in release closure: ${publicPath}`,
|
||||||
|
);
|
||||||
if (entry.isDirectory()) await visit(path, publicPath);
|
if (entry.isDirectory()) await visit(path, publicPath);
|
||||||
else if (entry.isFile()) output.push({ 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);
|
await visit(rootPath);
|
||||||
@@ -119,25 +291,31 @@ export async function enumerateRegularFiles(root) {
|
|||||||
|
|
||||||
function splitTarPath(path) {
|
function splitTarPath(path) {
|
||||||
const bytes = encoder.encode(path);
|
const bytes = encoder.encode(path);
|
||||||
if (bytes.length <= 100) return { name: path, prefix: '' };
|
if (bytes.length <= 100) return { name: path, prefix: "" };
|
||||||
const segments = path.split('/');
|
const segments = path.split("/");
|
||||||
for (let index = segments.length - 1; index > 0; index -= 1) {
|
for (let index = segments.length - 1; index > 0; index -= 1) {
|
||||||
const prefix = segments.slice(0, index).join('/');
|
const prefix = segments.slice(0, index).join("/");
|
||||||
const name = segments.slice(index).join('/');
|
const name = segments.slice(index).join("/");
|
||||||
if (encoder.encode(prefix).length <= 155 && encoder.encode(name).length <= 100) return { name, prefix };
|
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}`);
|
throw new Error(`Release path is too long for deterministic ustar: ${path}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
function writeString(target, offset, length, value) {
|
function writeString(target, offset, length, value) {
|
||||||
const bytes = encoder.encode(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);
|
target.set(bytes, offset);
|
||||||
}
|
}
|
||||||
|
|
||||||
function writeOctal(target, offset, length, value) {
|
function writeOctal(target, offset, length, value) {
|
||||||
const text = value.toString(8).padStart(length - 1, '0');
|
const text = value.toString(8).padStart(length - 1, "0");
|
||||||
if (text.length !== length - 1) throw new Error('Tar numeric field overflow.');
|
if (text.length !== length - 1)
|
||||||
|
throw new Error("Tar numeric field overflow.");
|
||||||
writeString(target, offset, length, `${text}\0`);
|
writeString(target, offset, length, `${text}\0`);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -151,23 +329,26 @@ function tarHeader(path, size) {
|
|||||||
writeOctal(header, 124, 12, size);
|
writeOctal(header, 124, 12, size);
|
||||||
writeOctal(header, 136, 12, 0);
|
writeOctal(header, 136, 12, 0);
|
||||||
header.fill(0x20, 148, 156);
|
header.fill(0x20, 148, 156);
|
||||||
writeString(header, 156, 1, '0');
|
writeString(header, 156, 1, "0");
|
||||||
writeString(header, 257, 6, 'ustar\0');
|
writeString(header, 257, 6, "ustar\0");
|
||||||
writeString(header, 263, 2, '00');
|
writeString(header, 263, 2, "00");
|
||||||
writeString(header, 345, 155, prefix);
|
writeString(header, 345, 155, prefix);
|
||||||
const checksum = header.reduce((sum, byte) => sum + byte, 0);
|
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 `);
|
writeString(header, 148, 8, `${checksumText}\0 `);
|
||||||
return header;
|
return header;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createDeterministicTar(files) {
|
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 seen = new Set();
|
||||||
const chunks = [];
|
const chunks = [];
|
||||||
for (const file of ordered) {
|
for (const file of ordered) {
|
||||||
validatePublicPath(file.path);
|
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);
|
seen.add(file.path);
|
||||||
const bytes = new Uint8Array(file.bytes);
|
const bytes = new Uint8Array(file.bytes);
|
||||||
chunks.push(tarHeader(file.path, bytes.length), bytes);
|
chunks.push(tarHeader(file.path, bytes.length), bytes);
|
||||||
@@ -180,14 +361,20 @@ export function createDeterministicTar(files) {
|
|||||||
|
|
||||||
function readString(bytes, start, length) {
|
function readString(bytes, start, length) {
|
||||||
const end = bytes.indexOf(0, start);
|
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) {
|
function readOctal(bytes, start, length, label) {
|
||||||
const text = readString(bytes, start, length).trim();
|
const text = readString(bytes, start, length).trim();
|
||||||
if (!/^[0-7]+$/u.test(text)) throw new Error(`Invalid tar ${label}.`);
|
if (!/^[0-7]+$/u.test(text)) throw new Error(`Invalid tar ${label}.`);
|
||||||
const value = Number.parseInt(text, 8);
|
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;
|
return value;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -205,31 +392,46 @@ export function parseDeterministicTar(source) {
|
|||||||
if (zeroBlocks === 2) break;
|
if (zeroBlocks === 2) break;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (zeroBlocks !== 0) throw new Error('Malformed tar end marker.');
|
if (zeroBlocks !== 0) throw new Error("Malformed tar end marker.");
|
||||||
const expectedChecksum = readOctal(header, 148, 8, 'checksum');
|
const expectedChecksum = readOctal(header, 148, 8, "checksum");
|
||||||
const checksumHeader = new Uint8Array(header);
|
const checksumHeader = new Uint8Array(header);
|
||||||
checksumHeader.fill(0x20, 148, 156);
|
checksumHeader.fill(0x20, 148, 156);
|
||||||
if (checksumHeader.reduce((sum, byte) => sum + byte, 0) !== expectedChecksum) throw new Error('Tar checksum mismatch.');
|
if (
|
||||||
if (readString(header, 257, 6) !== 'ustar' || String.fromCharCode(header[156] ?? 0) !== '0') throw new Error('Only deterministic regular ustar entries are accepted.');
|
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 name = readString(header, 0, 100);
|
||||||
const prefix = readString(header, 345, 155);
|
const prefix = readString(header, 345, 155);
|
||||||
const path = prefix === '' ? name : `${prefix}/${name}`;
|
const path = prefix === "" ? name : `${prefix}/${name}`;
|
||||||
validatePublicPath(path);
|
validatePublicPath(path);
|
||||||
if (seen.has(path)) throw new Error(`Duplicate archive entry: ${path}`);
|
if (seen.has(path)) throw new Error(`Duplicate archive entry: ${path}`);
|
||||||
seen.add(path);
|
seen.add(path);
|
||||||
const size = readOctal(header, 124, 12, 'size');
|
const size = readOctal(header, 124, 12, "size");
|
||||||
if (offset + size > bytes.length) throw new Error('Truncated tar entry.');
|
if (offset + size > bytes.length) throw new Error("Truncated tar entry.");
|
||||||
files.push({ path, bytes: bytes.slice(offset, offset + size) });
|
files.push({ path, bytes: bytes.slice(offset, offset + size) });
|
||||||
offset += Math.ceil(size / 512) * 512;
|
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;
|
return files;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function packageIndexFor({ admission, manifest, signedBytes, sbomBytes }) {
|
export function packageIndexFor({
|
||||||
const artifacts = [...manifest.artifacts].sort((left, right) => Buffer.compare(Buffer.from(left.path), Buffer.from(right.path)));
|
admission,
|
||||||
|
manifest,
|
||||||
|
signedBytes,
|
||||||
|
sbomBytes,
|
||||||
|
}) {
|
||||||
|
const artifacts = [...manifest.artifacts].sort((left, right) =>
|
||||||
|
Buffer.compare(Buffer.from(left.path), Buffer.from(right.path)),
|
||||||
|
);
|
||||||
return {
|
return {
|
||||||
schema: 'portfolio-release-package/v1',
|
schema: "portfolio-release-package/v1",
|
||||||
buildId: manifest.buildId,
|
buildId: manifest.buildId,
|
||||||
releaseId: `${manifest.buildId}-${admission.manifestDigest}`,
|
releaseId: `${manifest.buildId}-${admission.manifestDigest}`,
|
||||||
manifestDigest: admission.manifestDigest,
|
manifestDigest: admission.manifestDigest,
|
||||||
@@ -238,42 +440,96 @@ export function packageIndexFor({ admission, manifest, signedBytes, sbomBytes })
|
|||||||
expectedFence: admission.expectedFence,
|
expectedFence: admission.expectedFence,
|
||||||
latestLedgerHeadHash: admission.latestLedgerHeadHash,
|
latestLedgerHeadHash: admission.latestLedgerHeadHash,
|
||||||
latestLedgerRevision: admission.latestLedgerRevision,
|
latestLedgerRevision: admission.latestLedgerRevision,
|
||||||
signedManifest: { path: '.release/signed-release-manifest.json', sha256: byteDigest(signedBytes), bytes: signedBytes.length },
|
signedManifest: {
|
||||||
sbom: { path: '.release/sbom.cdx.json', sha256: byteDigest(sbomBytes), bytes: sbomBytes.length },
|
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,
|
artifacts,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function validatePackageFiles(files) {
|
export function validatePackageFiles(files) {
|
||||||
const byPath = new Map(files.map((file) => [file.path, file]));
|
const byPath = new Map(files.map((file) => [file.path, file]));
|
||||||
const indexFile = byPath.get('.release/package-index.json');
|
const indexFile = byPath.get(".release/package-index.json");
|
||||||
if (indexFile === undefined) throw new Error('Release package index is missing.');
|
if (indexFile === undefined)
|
||||||
const index = exactObject(parseCanonicalJson(indexFile.bytes), [
|
throw new Error("Release package index is missing.");
|
||||||
'schema', 'buildId', 'releaseId', 'manifestDigest', 'predecessorManifestDigest', 'expiresAt', 'expectedFence',
|
const index = exactObject(
|
||||||
'latestLedgerHeadHash', 'latestLedgerRevision', 'signedManifest', 'sbom', 'artifacts',
|
parseCanonicalJson(indexFile.bytes),
|
||||||
], 'release package index');
|
[
|
||||||
if (index.schema !== 'portfolio-release-package/v1') throw new Error('Release package schema is invalid.');
|
"schema",
|
||||||
assertToken(index.buildId, 'package buildId');
|
"buildId",
|
||||||
assertToken(index.releaseId, 'package releaseId');
|
"releaseId",
|
||||||
assertDigest(index.manifestDigest, 'package manifest digest');
|
"manifestDigest",
|
||||||
assertTimestamp(index.expiresAt, 'package expiry');
|
"predecessorManifestDigest",
|
||||||
if (!Number.isSafeInteger(index.expectedFence) || index.expectedFence < 1) throw new Error('Package fence is invalid.');
|
"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];
|
const metadata = [index.signedManifest, index.sbom];
|
||||||
for (const record of metadata) {
|
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);
|
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.');
|
if (!Array.isArray(index.artifacts) || index.artifacts.length === 0)
|
||||||
const expectedPaths = new Set(['.release/package-index.json', '.release/signed-release-manifest.json', '.release/sbom.cdx.json']);
|
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) {
|
for (const artifact of index.artifacts) {
|
||||||
exactObject(artifact, ['path', 'sha256', 'bytes', 'encoding'], 'package artifact');
|
exactObject(
|
||||||
if (typeof artifact.path !== 'string' || artifact.path.startsWith(reservedPrefix)) throw new Error('Package artifact uses a reserved path.');
|
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);
|
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);
|
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 };
|
return { index, byPath };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -282,44 +538,64 @@ export async function writeArchiveFiles(files, targetRoot) {
|
|||||||
const root = resolve(targetRoot);
|
const root = resolve(targetRoot);
|
||||||
for (const file of files) {
|
for (const file of files) {
|
||||||
const destination = resolve(root, file.path);
|
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 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) {
|
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) {
|
async function visit(path) {
|
||||||
const info = await lstat(path);
|
const info = await lstat(path);
|
||||||
if (info.isSymbolicLink()) throw new Error('Symlink found in immutable release.');
|
if (info.isSymbolicLink())
|
||||||
if (info.uid !== uid || info.gid !== gid) throw new Error(`Release ownership mismatch at ${path}.`);
|
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()) {
|
if (info.isDirectory()) {
|
||||||
for (const entry of await readdir(path)) await visit(join(path, entry));
|
for (const entry of await readdir(path)) await visit(join(path, entry));
|
||||||
await chmod(path, 0o555);
|
await chmod(path, 0o555);
|
||||||
} else if (info.isFile()) await chmod(path, 0o444);
|
} 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);
|
await visit(root);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function assertReleaseRoot(rootPath) {
|
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);
|
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);
|
const info = await lstat(normalized);
|
||||||
if (!info.isDirectory() || info.isSymbolicLink()) throw new Error('Release root must be a real dedicated directory.');
|
if (!info.isDirectory() || info.isSymbolicLink())
|
||||||
const marker = await readFile(join(normalized, '.portfolio-release-root'), 'utf8').catch((error) => {
|
throw new Error("Release root must be a real dedicated directory.");
|
||||||
if (error?.code === 'ENOENT') throw new Error('Release root sentinel is missing or invalid.');
|
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;
|
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;
|
return normalized;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function atomicCanonicalWrite(path, value, mode = 0o600) {
|
export async function atomicCanonicalWrite(path, value, mode = 0o600) {
|
||||||
const temporary = `${path}.next-${process.pid}`;
|
const temporary = `${path}.next-${process.pid}`;
|
||||||
const handle = await open(temporary, 'wx', mode);
|
const handle = await open(temporary, "wx", mode);
|
||||||
try {
|
try {
|
||||||
await handle.writeFile(canonicalValueBytes(value));
|
await handle.writeFile(canonicalValueBytes(value));
|
||||||
await handle.sync();
|
await handle.sync();
|
||||||
@@ -330,14 +606,15 @@ export async function atomicCanonicalWrite(path, value, mode = 0o600) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function safeRemoveStage(root, stageName) {
|
export async function safeRemoveStage(root, stageName) {
|
||||||
assertToken(stageName, 'stage name');
|
assertToken(stageName, "stage name");
|
||||||
const staging = join(root, 'staging');
|
const staging = join(root, "staging");
|
||||||
const target = join(staging, stageName);
|
const target = join(staging, stageName);
|
||||||
const rel = relative(staging, target);
|
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 });
|
await rm(target, { recursive: true, force: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
export function digestBytes(bytes) {
|
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({
|
function closureDigest(value, schema, label) {
|
||||||
expectedChecks,
|
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,
|
reports,
|
||||||
matcherResults,
|
matcherResults,
|
||||||
candidate,
|
candidate,
|
||||||
}) {
|
} = request;
|
||||||
if (
|
if (!Array.isArray(reports) || !Array.isArray(matcherResults))
|
||||||
!Array.isArray(expectedChecks) ||
|
|
||||||
!Array.isArray(reports) ||
|
|
||||||
!Array.isArray(matcherResults) ||
|
|
||||||
expectedChecks.length === 0
|
|
||||||
)
|
|
||||||
throw new Error("Reconciliation input is invalid.");
|
throw new Error("Reconciliation input is invalid.");
|
||||||
const ordered = [...expectedChecks].sort((a, b) =>
|
const sourceClosureDigest = closureDigest(
|
||||||
Buffer.from(`${a.inputDigest}\0${a.checkId}`).compare(
|
sourceClosure,
|
||||||
Buffer.from(`${b.inputDigest}\0${b.checkId}`),
|
"portfolio-publication-closure/v1",
|
||||||
),
|
"source closure",
|
||||||
|
);
|
||||||
|
const publicObjectClosureDigest = closureDigest(
|
||||||
|
publicObjectClosure,
|
||||||
|
"portfolio-public-object-closure/v1",
|
||||||
|
"public object closure",
|
||||||
);
|
);
|
||||||
if (
|
if (
|
||||||
!stringsEqual(expectedChecks, ordered) ||
|
candidate?.sourceClosureDigest !== sourceClosureDigest ||
|
||||||
new Set(expectedChecks.map((v) => `${v.inputDigest}\0${v.checkId}`))
|
candidate?.publicObjectClosureDigest !== publicObjectClosureDigest
|
||||||
.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.");
|
|
||||||
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(
|
throw new Error(
|
||||||
"Privacy evidence is missing, reordered, or bound to another check.",
|
"Candidate does not bind the exact canonical source/public closure.",
|
||||||
);
|
);
|
||||||
if (a.imageDigest === b.imageDigest || a.toolchain === b.toolchain)
|
}
|
||||||
throw new Error("Privacy workers are not heterogeneous.");
|
if (publicObjectClosure.members.length !== 0) {
|
||||||
if (
|
throw new Error(
|
||||||
a.verdict !== "pass" ||
|
"Verified privacy scanners are unavailable; every non-empty public object closure fails closed.",
|
||||||
b.verdict !== "pass" ||
|
);
|
||||||
match.verdict !== "pass"
|
}
|
||||||
)
|
if (reports.length !== 0 || matcherResults.length !== 0)
|
||||||
throw new Error("Every worker and matcher must independently pass.");
|
throw new Error(
|
||||||
if (
|
"Empty public object closure cannot carry fabricated scan evidence.",
|
||||||
a.normalizedOutputDigest !== b.normalizedOutputDigest ||
|
);
|
||||||
a.normalizedOutputDigest !== match.normalizedOutputDigest ||
|
const checks = [];
|
||||||
!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 = {
|
const evidence = {
|
||||||
schema: "portfolio-scan-evidence/v1",
|
schema: "portfolio-scan-evidence/v1",
|
||||||
|
sourceClosureDigest,
|
||||||
|
publicObjectClosureDigest,
|
||||||
coverageCount: checks.length,
|
coverageCount: checks.length,
|
||||||
passedCount: checks.length,
|
passedCount: checks.length,
|
||||||
checks,
|
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 (
|
if (
|
||||||
process.argv[1] &&
|
process.argv[1] &&
|
||||||
import.meta.url === pathToFileURL(process.argv[1]).href
|
import.meta.url === pathToFileURL(process.argv[1]).href
|
||||||
|
|||||||
@@ -11,6 +11,11 @@ import {
|
|||||||
parseCanonicalJson,
|
parseCanonicalJson,
|
||||||
} from "../../src/security/canonical-digest.ts";
|
} from "../../src/security/canonical-digest.ts";
|
||||||
import { validateReleaseManifest } from "../../src/security/release-manifest.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 = {
|
const rootContract = {
|
||||||
approval: {
|
approval: {
|
||||||
@@ -18,7 +23,7 @@ const rootContract = {
|
|||||||
recovery: ["freeze-publication", "audit-checkpoint", "fresh-approvals"],
|
recovery: ["freeze-publication", "audit-checkpoint", "fresh-approvals"],
|
||||||
},
|
},
|
||||||
projection: {
|
projection: {
|
||||||
scope: "portfolio-approval-projection/v1",
|
scope: APPROVAL_PROJECTION_ATTESTATION_SCOPE,
|
||||||
recovery: ["freeze-publication", "revoke-epoch", "fresh-evidence"],
|
recovery: ["freeze-publication", "revoke-epoch", "fresh-evidence"],
|
||||||
},
|
},
|
||||||
privacyWorkload: {
|
privacyWorkload: {
|
||||||
@@ -62,6 +67,7 @@ const candidateFields = [
|
|||||||
"latestLedgerHeadHash",
|
"latestLedgerHeadHash",
|
||||||
"latestLedgerRevision",
|
"latestLedgerRevision",
|
||||||
"sourceClosureDigest",
|
"sourceClosureDigest",
|
||||||
|
"publicObjectClosureDigest",
|
||||||
"publicRefMap",
|
"publicRefMap",
|
||||||
"lfsOids",
|
"lfsOids",
|
||||||
"releaseArchives",
|
"releaseArchives",
|
||||||
@@ -78,21 +84,6 @@ const signatureFields = [
|
|||||||
"value",
|
"value",
|
||||||
];
|
];
|
||||||
const attestationPayloadFields = {
|
const attestationPayloadFields = {
|
||||||
projection: [
|
|
||||||
"schema",
|
|
||||||
"keyId",
|
|
||||||
"issuerEpoch",
|
|
||||||
"scope",
|
|
||||||
"issuedAt",
|
|
||||||
"expiresAt",
|
|
||||||
"digest",
|
|
||||||
"productionInputRoot",
|
|
||||||
"approvedRecordRoot",
|
|
||||||
"approvedAssetRoot",
|
|
||||||
"approvalProofSetDigest",
|
|
||||||
"latestLedgerHeadHash",
|
|
||||||
"latestLedgerRevision",
|
|
||||||
],
|
|
||||||
privacyWorkload: [
|
privacyWorkload: [
|
||||||
"schema",
|
"schema",
|
||||||
"keyId",
|
"keyId",
|
||||||
@@ -293,6 +284,7 @@ function validateCandidate(value, now) {
|
|||||||
throw new Error("Candidate schema is invalid.");
|
throw new Error("Candidate schema is invalid.");
|
||||||
digest(candidate.latestLedgerHeadHash, "latestLedgerHeadHash");
|
digest(candidate.latestLedgerHeadHash, "latestLedgerHeadHash");
|
||||||
digest(candidate.sourceClosureDigest, "sourceClosureDigest");
|
digest(candidate.sourceClosureDigest, "sourceClosureDigest");
|
||||||
|
digest(candidate.publicObjectClosureDigest, "publicObjectClosureDigest");
|
||||||
digest(candidate.buildProvenanceDigest, "buildProvenanceDigest");
|
digest(candidate.buildProvenanceDigest, "buildProvenanceDigest");
|
||||||
digest(candidate.privacyPolicyDigest, "privacyPolicyDigest");
|
digest(candidate.privacyPolicyDigest, "privacyPolicyDigest");
|
||||||
if (
|
if (
|
||||||
@@ -354,18 +346,30 @@ function validateEvidence(value, expectedDigest) {
|
|||||||
const evidence = object(value, "scan evidence");
|
const evidence = object(value, "scan evidence");
|
||||||
exact(
|
exact(
|
||||||
evidence,
|
evidence,
|
||||||
["schema", "coverageCount", "passedCount", "checks"],
|
[
|
||||||
|
"schema",
|
||||||
|
"sourceClosureDigest",
|
||||||
|
"publicObjectClosureDigest",
|
||||||
|
"coverageCount",
|
||||||
|
"passedCount",
|
||||||
|
"checks",
|
||||||
|
],
|
||||||
"scan evidence",
|
"scan evidence",
|
||||||
);
|
);
|
||||||
if (
|
if (
|
||||||
evidence.schema !== "portfolio-scan-evidence/v1" ||
|
evidence.schema !== "portfolio-scan-evidence/v1" ||
|
||||||
!Number.isSafeInteger(evidence.coverageCount) ||
|
!Number.isSafeInteger(evidence.coverageCount) ||
|
||||||
evidence.coverageCount < 1 ||
|
evidence.coverageCount < 0 ||
|
||||||
evidence.passedCount !== evidence.coverageCount ||
|
evidence.passedCount !== evidence.coverageCount ||
|
||||||
!Array.isArray(evidence.checks) ||
|
!Array.isArray(evidence.checks) ||
|
||||||
evidence.checks.length !== evidence.coverageCount
|
evidence.checks.length !== evidence.coverageCount
|
||||||
)
|
)
|
||||||
throw new Error("Scan evidence coverage is incomplete.");
|
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 = [];
|
const keys = [];
|
||||||
for (const raw of evidence.checks) {
|
for (const raw of evidence.checks) {
|
||||||
const check = object(raw, "scan evidence check");
|
const check = object(raw, "scan evidence check");
|
||||||
@@ -452,6 +456,8 @@ function verifyCandidateAuthority({
|
|||||||
trustPolicy,
|
trustPolicy,
|
||||||
now,
|
now,
|
||||||
protectedState,
|
protectedState,
|
||||||
|
sourceClosure,
|
||||||
|
publicObjectClosure,
|
||||||
}) {
|
}) {
|
||||||
if (trustPolicy.publicationFrozen)
|
if (trustPolicy.publicationFrozen)
|
||||||
throw new Error("Publication is frozen by trust policy.");
|
throw new Error("Publication is frozen by trust policy.");
|
||||||
@@ -462,6 +468,39 @@ function verifyCandidateAuthority({
|
|||||||
now,
|
now,
|
||||||
);
|
);
|
||||||
const state = validateProtectedState(protectedState);
|
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 (
|
if (
|
||||||
record.latestLedgerHeadHash !== state.latestLedgerHeadHash ||
|
record.latestLedgerHeadHash !== state.latestLedgerHeadHash ||
|
||||||
record.latestLedgerRevision !== state.latestLedgerRevision
|
record.latestLedgerRevision !== state.latestLedgerRevision
|
||||||
@@ -474,7 +513,19 @@ function verifyCandidateAuthority({
|
|||||||
throw new Error(
|
throw new Error(
|
||||||
"Candidate predecessor does not equal protected current manifest.",
|
"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(
|
const candidateDigest = canonicalJsonDigest(
|
||||||
"portfolio-candidate-release-manifest/v1",
|
"portfolio-candidate-release-manifest/v1",
|
||||||
record,
|
record,
|
||||||
@@ -483,12 +534,20 @@ function verifyCandidateAuthority({
|
|||||||
"portfolio-scan-evidence/v1",
|
"portfolio-scan-evidence/v1",
|
||||||
evidence,
|
evidence,
|
||||||
);
|
);
|
||||||
const projection = verifyAttestation(
|
const projectionRoot = trustPolicy.roots.projection;
|
||||||
|
const projection = verifyApprovalProjectionAttestation(
|
||||||
attestations.projection,
|
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(
|
const workload = verifyAttestation(
|
||||||
attestations.privacyWorkload,
|
attestations.privacyWorkload,
|
||||||
@@ -516,13 +575,18 @@ function verifyCandidateAuthority({
|
|||||||
["approvedRecordRoot", releaseManifest.approvedRecordRoot],
|
["approvedRecordRoot", releaseManifest.approvedRecordRoot],
|
||||||
["approvedAssetRoot", releaseManifest.approvedAssetRoot],
|
["approvedAssetRoot", releaseManifest.approvedAssetRoot],
|
||||||
["approvalProofSetDigest", releaseManifest.approvalProofSetDigest],
|
["approvalProofSetDigest", releaseManifest.approvalProofSetDigest],
|
||||||
["latestLedgerHeadHash", record.latestLedgerHeadHash],
|
["liveLedgerHeadHash", record.latestLedgerHeadHash],
|
||||||
["latestLedgerRevision", record.latestLedgerRevision],
|
["liveLedgerRevision", record.latestLedgerRevision],
|
||||||
]) {
|
]) {
|
||||||
if (projection[field] !== expected)
|
if (projection[field] !== expected)
|
||||||
throw new Error(`Approval projection mismatch: ${field}.`);
|
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.");
|
throw new Error("Projection attestation digest binding is invalid.");
|
||||||
if (
|
if (
|
||||||
workload.candidateDigest !== candidateDigest ||
|
workload.candidateDigest !== candidateDigest ||
|
||||||
@@ -572,9 +636,9 @@ async function externalPrivateKey(path) {
|
|||||||
return key;
|
return key;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function signReleaseCandidate(input) {
|
async function signReleaseCandidateAt(input, now) {
|
||||||
const trustPolicy = validateTrustPolicy(input.trustPolicy);
|
const trustPolicy = validateTrustPolicy(input.trustPolicy);
|
||||||
const verified = verifyCandidateAuthority({ ...input, trustPolicy });
|
const verified = verifyCandidateAuthority({ ...input, trustPolicy, now });
|
||||||
const key = await externalPrivateKey(input.releaseKeyPath);
|
const key = await externalPrivateKey(input.releaseKeyPath);
|
||||||
const releaseRoot = trustPolicy.roots.release;
|
const releaseRoot = trustPolicy.roots.release;
|
||||||
const bytes = canonicalValueBytes(verified.record);
|
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,
|
signedRelease,
|
||||||
trustPolicy: trustValue,
|
trustPolicy: trustValue,
|
||||||
now,
|
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() {
|
async function main() {
|
||||||
const args = process.argv.slice(2);
|
const args = process.argv.slice(2);
|
||||||
if (args.length !== 4 || args[0] !== "--request" || args[2] !== "--key")
|
if (args.length !== 4 || args[0] !== "--request" || args[2] !== "--key")
|
||||||
|
|||||||
@@ -1,55 +1,110 @@
|
|||||||
#!/usr/bin/env node
|
#!/usr/bin/env node
|
||||||
import { createPublicKey } from 'node:crypto';
|
import { createPublicKey } from "node:crypto";
|
||||||
import { readFile } from 'node:fs/promises';
|
import { readFile } from "node:fs/promises";
|
||||||
import { pathToFileURL } from 'node:url';
|
import { pathToFileURL } from "node:url";
|
||||||
|
|
||||||
import { computeApprovalClosure } from '../../src/security/approval-closure.ts';
|
import {
|
||||||
import { canonicalValueBytes, isSha256Hex, parseCanonicalJson } from '../../src/security/canonical-digest.ts';
|
APPROVAL_PROJECTION_ATTESTATION_SCHEMA,
|
||||||
import { computeProductionInputRoot } from '../../src/security/production-input-root.ts';
|
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 = [
|
const expectedRequestFields = [
|
||||||
'schema', 'now', 'latestLedgerHeadHash', 'latestLedgerRevision', 'trustedKeys', 'contentRecords', 'assetRecords', 'inputDigests',
|
"schema",
|
||||||
|
"latestLedgerHeadHash",
|
||||||
|
"latestLedgerRevision",
|
||||||
|
"trustedKeys",
|
||||||
|
"ledgerProof",
|
||||||
|
"contentRecords",
|
||||||
|
"assetRecords",
|
||||||
|
"inputDigests",
|
||||||
].sort();
|
].sort();
|
||||||
const expectedKeyFields = ['keyId', 'issuerEpoch', 'scope', 'authorizedRoles', 'publicKeyPem'].sort();
|
const expectedKeyFields = [
|
||||||
const expectedInputs = ['PortfolioContent', 'PreparedAssetSet', 'PublicAssetManifest', 'SiteConfig'];
|
"keyId",
|
||||||
|
"issuerEpoch",
|
||||||
|
"scope",
|
||||||
|
"authorizedRoles",
|
||||||
|
"publicKeyPem",
|
||||||
|
].sort();
|
||||||
|
const expectedInputs = [
|
||||||
|
"PortfolioContent",
|
||||||
|
"PreparedAssetSet",
|
||||||
|
"PublicAssetManifest",
|
||||||
|
"SiteConfig",
|
||||||
|
];
|
||||||
|
|
||||||
function exactObject(value, fields, label) {
|
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))
|
||||||
if (JSON.stringify(Object.keys(value).sort()) !== JSON.stringify(fields)) throw new Error(`${label} contains missing or unknown fields.`);
|
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;
|
return value;
|
||||||
}
|
}
|
||||||
|
|
||||||
function requestPath(args) {
|
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];
|
return args[1];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function verifyApprovalRequest(source) {
|
function verifyApprovalRequestAt(source, now) {
|
||||||
const request = exactObject(parseCanonicalJson(source), expectedRequestFields, 'request');
|
const request = exactObject(
|
||||||
if (request.schema !== 'portfolio-approval-verification-request/v1') throw new Error('Request schema is invalid.');
|
parseCanonicalJson(source),
|
||||||
if (!Array.isArray(request.trustedKeys) || !Array.isArray(request.contentRecords) || !Array.isArray(request.assetRecords)) throw new Error('Approval record lists are invalid.');
|
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 trustedKeys = request.trustedKeys.map((candidate) => {
|
||||||
const key = exactObject(candidate, expectedKeyFields, 'trusted key');
|
const key = exactObject(candidate, expectedKeyFields, "trusted key");
|
||||||
if (key.scope !== 'portfolio-approval-decision/v1' || typeof key.keyId !== 'string' ||
|
if (
|
||||||
!Number.isSafeInteger(key.issuerEpoch) || !Array.isArray(key.authorizedRoles) ||
|
key.scope !== "portfolio-approval-decision/v1" ||
|
||||||
key.authorizedRoles.some((role) => typeof role !== 'string') || typeof key.publicKeyPem !== 'string') {
|
typeof key.keyId !== "string" ||
|
||||||
throw new Error('Trusted approval key is invalid.');
|
!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 {
|
return {
|
||||||
keyId: key.keyId, issuerEpoch: key.issuerEpoch, scope: key.scope,
|
keyId: key.keyId,
|
||||||
authorizedRoles: key.authorizedRoles, publicKey: createPublicKey(key.publicKeyPem),
|
issuerEpoch: key.issuerEpoch,
|
||||||
|
scope: key.scope,
|
||||||
|
authorizedRoles: key.authorizedRoles,
|
||||||
|
publicKey: createPublicKey(key.publicKeyPem),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
const inputDigests = exactObject(request.inputDigests, expectedInputs, 'inputDigests');
|
const inputDigests = exactObject(
|
||||||
if (Object.values(inputDigests).some((digest) => !isSha256Hex(digest))) throw new Error('Input digest is invalid.');
|
request.inputDigests,
|
||||||
|
expectedInputs,
|
||||||
|
"inputDigests",
|
||||||
|
);
|
||||||
|
if (Object.values(inputDigests).some((digest) => !isSha256Hex(digest)))
|
||||||
|
throw new Error("Input digest is invalid.");
|
||||||
const closure = computeApprovalClosure({
|
const closure = computeApprovalClosure({
|
||||||
contentRecords: request.contentRecords,
|
contentRecords: request.contentRecords,
|
||||||
assetRecords: request.assetRecords,
|
assetRecords: request.assetRecords,
|
||||||
trust: {
|
trust: {
|
||||||
now: request.now,
|
now,
|
||||||
latestLedgerHeadHash: request.latestLedgerHeadHash,
|
latestLedgerHeadHash: request.latestLedgerHeadHash,
|
||||||
latestLedgerRevision: request.latestLedgerRevision,
|
latestLedgerRevision: request.latestLedgerRevision,
|
||||||
trustedKeys,
|
trustedKeys,
|
||||||
|
ledgerProof: request.ledgerProof,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const productionInputRoot = computeProductionInputRoot({
|
const productionInputRoot = computeProductionInputRoot({
|
||||||
@@ -59,7 +114,9 @@ export function verifyApprovalRequest(source) {
|
|||||||
siteConfigDigest: inputDigests.SiteConfig,
|
siteConfigDigest: inputDigests.SiteConfig,
|
||||||
});
|
});
|
||||||
return {
|
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,
|
approvedRecordRoot: closure.approvedRecordRoot,
|
||||||
approvedAssetRoot: closure.approvedAssetRoot,
|
approvedAssetRoot: closure.approvedAssetRoot,
|
||||||
approvalProofSetDigest: closure.approvalProofSetDigest,
|
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() {
|
async function main() {
|
||||||
const path = requestPath(process.argv.slice(2));
|
const path = requestPath(process.argv.slice(2));
|
||||||
const result = verifyApprovalRequest(await readFile(path));
|
const result = verifyApprovalRequest(await readFile(path));
|
||||||
process.stdout.write(Buffer.from(canonicalValueBytes(result)));
|
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) => {
|
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.stderr.write(`Approval verification failed: ${message}\n`);
|
||||||
process.exitCode = 1;
|
process.exitCode = 1;
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,11 +1,22 @@
|
|||||||
import type { KeyLike } from 'node:crypto';
|
import type { KeyLike } from "node:crypto";
|
||||||
import { canonicalJsonDigest, isSha256Hex, verifyEd25519Envelope, type Sha256Hex } from './canonical-digest.ts';
|
import {
|
||||||
|
canonicalJsonDigest,
|
||||||
|
isSha256Hex,
|
||||||
|
verifyEd25519Envelope,
|
||||||
|
type Sha256Hex,
|
||||||
|
} from "./canonical-digest.ts";
|
||||||
import {
|
import {
|
||||||
type ApprovalEnvelope,
|
type ApprovalEnvelope,
|
||||||
type ApprovalTrustPolicy,
|
type ApprovalTrustPolicy,
|
||||||
|
verifyApprovalLedger,
|
||||||
verifyApprovalEnvelope,
|
verifyApprovalEnvelope,
|
||||||
} from './approval-envelope.ts';
|
} from "./approval-envelope.ts";
|
||||||
import { computeProductionInputRoot } from './production-input-root.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 {
|
export interface ContentApprovalRecord {
|
||||||
recordType: string;
|
recordType: string;
|
||||||
@@ -29,14 +40,19 @@ export interface ApprovalClosure {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface ApprovalProjectionAttestationPayload {
|
export interface ApprovalProjectionAttestationPayload {
|
||||||
schema: 'portfolio-approval-projection-attestation/v1';
|
schema: typeof APPROVAL_PROJECTION_ATTESTATION_SCHEMA;
|
||||||
keyId: string;
|
keyId: string;
|
||||||
issuerEpoch: number;
|
issuerEpoch: number;
|
||||||
scope: 'portfolio-approval-projection-attestation/v1';
|
scope: typeof APPROVAL_PROJECTION_ATTESTATION_SCOPE;
|
||||||
approvedRecordRoot: string;
|
approvedRecordRoot: string;
|
||||||
approvedAssetRoot: string;
|
approvedAssetRoot: string;
|
||||||
approvalProofSetDigest: string;
|
approvalProofSetDigest: string;
|
||||||
inputDigests: { PortfolioContent: string; PublicAssetManifest: string; PreparedAssetSet: string; SiteConfig: string };
|
inputDigests: {
|
||||||
|
PortfolioContent: string;
|
||||||
|
PublicAssetManifest: string;
|
||||||
|
PreparedAssetSet: string;
|
||||||
|
SiteConfig: string;
|
||||||
|
};
|
||||||
productionInputRoot: string;
|
productionInputRoot: string;
|
||||||
liveLedgerHeadHash: string;
|
liveLedgerHeadHash: string;
|
||||||
liveLedgerRevision: number;
|
liveLedgerRevision: number;
|
||||||
@@ -47,16 +63,20 @@ export interface ApprovalProjectionAttestationPayload {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface ApprovalProjectionAttestationEnvelope {
|
export interface ApprovalProjectionAttestationEnvelope {
|
||||||
schema: 'portfolio-approval-projection-attestation-envelope/v1';
|
schema: "portfolio-approval-projection-attestation-envelope/v1";
|
||||||
payload: ApprovalProjectionAttestationPayload;
|
payload: ApprovalProjectionAttestationPayload;
|
||||||
signature: { algorithm: 'Ed25519'; encoding: 'base64url-nopad'; value: string };
|
signature: {
|
||||||
|
algorithm: "Ed25519";
|
||||||
|
encoding: "base64url-nopad";
|
||||||
|
value: string;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const encoder = new TextEncoder();
|
const encoder = new TextEncoder();
|
||||||
function utf8TupleCompare(left: string[], right: string[]): number {
|
function utf8TupleCompare(left: string[], right: string[]): number {
|
||||||
for (let field = 0; field < Math.max(left.length, right.length); field += 1) {
|
for (let field = 0; field < Math.max(left.length, right.length); field += 1) {
|
||||||
const a = encoder.encode(left[field] ?? '');
|
const a = encoder.encode(left[field] ?? "");
|
||||||
const b = encoder.encode(right[field] ?? '');
|
const b = encoder.encode(right[field] ?? "");
|
||||||
for (let index = 0; index < Math.min(a.length, b.length); index += 1) {
|
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);
|
if (a[index] !== b[index]) return (a[index] ?? 0) - (b[index] ?? 0);
|
||||||
}
|
}
|
||||||
@@ -70,51 +90,122 @@ export function computeApprovalClosure(input: {
|
|||||||
assetRecords: AssetApprovalRecord[];
|
assetRecords: AssetApprovalRecord[];
|
||||||
trust: ApprovalTrustPolicy;
|
trust: ApprovalTrustPolicy;
|
||||||
}): ApprovalClosure {
|
}): ApprovalClosure {
|
||||||
|
const latestLedger = verifyApprovalLedger(input.trust);
|
||||||
const ids = new Set<string>();
|
const ids = new Set<string>();
|
||||||
const expiries: string[] = [];
|
const expiries: string[] = [];
|
||||||
const contentTuples = input.contentRecords.map((record) => {
|
const contentTuples = input.contentRecords
|
||||||
|
.map((record) => {
|
||||||
const typedId = record.publicRecordId;
|
const typedId = record.publicRecordId;
|
||||||
if (ids.has(typedId)) throw new Error('Duplicate content approval ID.');
|
if (ids.has(typedId)) throw new Error("Duplicate content approval ID.");
|
||||||
ids.add(typedId);
|
ids.add(typedId);
|
||||||
if (!isSha256Hex(record.approvedPublicRecordHash)) throw new Error('Content record hash is invalid.');
|
if (!isSha256Hex(record.approvedPublicRecordHash))
|
||||||
|
throw new Error("Content record hash is invalid.");
|
||||||
const payload = verifyApprovalEnvelope(record.envelope, input.trust);
|
const payload = verifyApprovalEnvelope(record.envelope, input.trust);
|
||||||
if (payload.recordClass !== 'content' || payload.recordType !== record.recordType ||
|
if (
|
||||||
|
payload.recordClass !== "content" ||
|
||||||
|
payload.recordType !== record.recordType ||
|
||||||
payload.publicRecordIdOrAssetId !== record.publicRecordId ||
|
payload.publicRecordIdOrAssetId !== record.publicRecordId ||
|
||||||
payload.approvedPublicRecordHashOrManifestRecordHash !== record.approvedPublicRecordHash ||
|
payload.approvedPublicRecordHashOrManifestRecordHash !==
|
||||||
payload.sanitizedOutputHashOrNull !== null) {
|
record.approvedPublicRecordHash ||
|
||||||
throw new Error('Content approval does not bind the exact emitted record.');
|
payload.sanitizedOutputHashOrNull !== null
|
||||||
|
) {
|
||||||
|
throw new Error(
|
||||||
|
"Content approval does not bind the exact emitted record.",
|
||||||
|
);
|
||||||
}
|
}
|
||||||
expiries.push(payload.expiresAt);
|
expiries.push(payload.expiresAt);
|
||||||
const envelopeDigest = canonicalJsonDigest('portfolio-approval-envelope-digest/v1', record.envelope);
|
const envelopeDigest = canonicalJsonDigest(
|
||||||
return [record.recordType, record.publicRecordId, record.approvedPublicRecordHash, envelopeDigest] as const;
|
"portfolio-approval-envelope-digest/v1",
|
||||||
}).sort((a, b) => utf8TupleCompare([a[0], a[1]], [b[0], b[1]]));
|
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 assetTuples = input.assetRecords
|
||||||
|
.map((record) => {
|
||||||
const typedId = record.assetId;
|
const typedId = record.assetId;
|
||||||
if (ids.has(typedId)) throw new Error('Duplicate asset approval ID.');
|
if (ids.has(typedId)) throw new Error("Duplicate asset approval ID.");
|
||||||
ids.add(typedId);
|
ids.add(typedId);
|
||||||
if (!isSha256Hex(record.approvedManifestRecordHash) || !isSha256Hex(record.sanitizedOutputHash)) throw new Error('Asset record hash is invalid.');
|
if (
|
||||||
|
!isSha256Hex(record.approvedManifestRecordHash) ||
|
||||||
|
!isSha256Hex(record.sanitizedOutputHash)
|
||||||
|
)
|
||||||
|
throw new Error("Asset record hash is invalid.");
|
||||||
const payload = verifyApprovalEnvelope(record.envelope, input.trust);
|
const payload = verifyApprovalEnvelope(record.envelope, input.trust);
|
||||||
if (payload.recordClass !== 'asset' || payload.recordType !== '' ||
|
if (
|
||||||
|
payload.recordClass !== "asset" ||
|
||||||
|
payload.recordType !== "" ||
|
||||||
payload.publicRecordIdOrAssetId !== record.assetId ||
|
payload.publicRecordIdOrAssetId !== record.assetId ||
|
||||||
payload.approvedPublicRecordHashOrManifestRecordHash !== record.approvedManifestRecordHash ||
|
payload.approvedPublicRecordHashOrManifestRecordHash !==
|
||||||
payload.sanitizedOutputHashOrNull !== record.sanitizedOutputHash) {
|
record.approvedManifestRecordHash ||
|
||||||
throw new Error('Asset approval does not bind the exact emitted record and bytes.');
|
payload.sanitizedOutputHashOrNull !== record.sanitizedOutputHash
|
||||||
|
) {
|
||||||
|
throw new Error(
|
||||||
|
"Asset approval does not bind the exact emitted record and bytes.",
|
||||||
|
);
|
||||||
}
|
}
|
||||||
expiries.push(payload.expiresAt);
|
expiries.push(payload.expiresAt);
|
||||||
const envelopeDigest = canonicalJsonDigest('portfolio-approval-envelope-digest/v1', record.envelope);
|
const envelopeDigest = canonicalJsonDigest(
|
||||||
return [record.assetId, record.approvedManifestRecordHash, record.sanitizedOutputHash, envelopeDigest] as const;
|
"portfolio-approval-envelope-digest/v1",
|
||||||
}).sort((a, b) => utf8TupleCompare([a[0]], [b[0]]));
|
record.envelope,
|
||||||
|
);
|
||||||
|
return [
|
||||||
|
record.assetId,
|
||||||
|
record.approvedManifestRecordHash,
|
||||||
|
record.sanitizedOutputHash,
|
||||||
|
envelopeDigest,
|
||||||
|
] as const;
|
||||||
|
})
|
||||||
|
.sort((a, b) => utf8TupleCompare([a[0]], [b[0]]));
|
||||||
|
|
||||||
const proofList = [
|
const proofList = [
|
||||||
...contentTuples.map((tuple) => ['content', tuple[0], tuple[1], tuple[3]] as const),
|
...contentTuples.map(
|
||||||
...assetTuples.map((tuple) => ['asset', '', tuple[0], tuple[3]] as const),
|
(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]]));
|
].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 {
|
return {
|
||||||
approvedRecordRoot: canonicalJsonDigest('portfolio-record-closure/v1', { records: contentTuples }),
|
approvedRecordRoot: canonicalJsonDigest("portfolio-record-closure/v1", {
|
||||||
approvedAssetRoot: canonicalJsonDigest('portfolio-asset-closure/v1', { assets: assetTuples }),
|
records: contentTuples,
|
||||||
approvalProofSetDigest: canonicalJsonDigest('portfolio-approval-proof-set/v1', { envelopes: proofList }),
|
}),
|
||||||
|
approvedAssetRoot: canonicalJsonDigest("portfolio-asset-closure/v1", {
|
||||||
|
assets: assetTuples,
|
||||||
|
}),
|
||||||
|
approvalProofSetDigest: canonicalJsonDigest(
|
||||||
|
"portfolio-approval-proof-set/v1",
|
||||||
|
{ envelopes: proofList },
|
||||||
|
),
|
||||||
earliestApprovalExpiry: expiries.sort()[0] ?? null,
|
earliestApprovalExpiry: expiries.sort()[0] ?? null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -125,43 +216,129 @@ export function verifyApprovalProjectionAttestation(
|
|||||||
now: string;
|
now: string;
|
||||||
latestLedgerHeadHash: string;
|
latestLedgerHeadHash: string;
|
||||||
latestLedgerRevision: number;
|
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 {
|
): 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<string, unknown>;
|
const envelope = envelopeValue as Record<string, unknown>;
|
||||||
if (JSON.stringify(Object.keys(envelope).sort()) !== JSON.stringify(['payload', 'schema', 'signature'])) throw new Error('Projection envelope contains unknown fields.');
|
if (
|
||||||
if (envelope.schema !== 'portfolio-approval-projection-attestation-envelope/v1') throw new Error('Projection envelope schema is invalid.');
|
JSON.stringify(Object.keys(envelope).sort()) !==
|
||||||
if (envelope.payload === null || typeof envelope.payload !== 'object' || Array.isArray(envelope.payload)) throw new Error('Projection payload is invalid.');
|
JSON.stringify(["payload", "schema", "signature"])
|
||||||
const payload = envelope.payload as unknown as ApprovalProjectionAttestationPayload;
|
)
|
||||||
|
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 = [
|
const payloadFields = [
|
||||||
'schema', 'keyId', 'issuerEpoch', 'scope', 'approvedRecordRoot', 'approvedAssetRoot', 'approvalProofSetDigest',
|
"schema",
|
||||||
'inputDigests', 'productionInputRoot', 'liveLedgerHeadHash', 'liveLedgerRevision', 'policyVersion', 'issuedAt',
|
"keyId",
|
||||||
'expiresAt', 'earliestApprovalExpiry',
|
"issuerEpoch",
|
||||||
|
"scope",
|
||||||
|
"approvedRecordRoot",
|
||||||
|
"approvedAssetRoot",
|
||||||
|
"approvalProofSetDigest",
|
||||||
|
"inputDigests",
|
||||||
|
"productionInputRoot",
|
||||||
|
"liveLedgerHeadHash",
|
||||||
|
"liveLedgerRevision",
|
||||||
|
"policyVersion",
|
||||||
|
"issuedAt",
|
||||||
|
"expiresAt",
|
||||||
|
"earliestApprovalExpiry",
|
||||||
].sort();
|
].sort();
|
||||||
if (JSON.stringify(Object.keys(payload).sort()) !== JSON.stringify(payloadFields)) throw new Error('Projection payload contains missing or unknown fields.');
|
if (
|
||||||
if (payload.schema !== 'portfolio-approval-projection-attestation/v1' || payload.scope !== 'portfolio-approval-projection-attestation/v1') throw new Error('Projection payload schema/scope is invalid.');
|
JSON.stringify(Object.keys(payload).sort()) !==
|
||||||
if (trust.trustedKey.revoked || trust.trustedKey.scope !== payload.scope || trust.trustedKey.keyId !== payload.keyId || trust.trustedKey.issuerEpoch !== payload.issuerEpoch) {
|
JSON.stringify(payloadFields)
|
||||||
throw new Error('Projection key, scope, or issuer epoch is not trusted.');
|
)
|
||||||
|
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]) {
|
for (const digest of [
|
||||||
if (!isSha256Hex(digest)) throw new Error('Projection digest field is invalid.');
|
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();
|
const inputKeys = Object.keys(payload.inputDigests).sort();
|
||||||
if (JSON.stringify(inputKeys) !== JSON.stringify(['PortfolioContent', 'PreparedAssetSet', 'PublicAssetManifest', 'SiteConfig']) ||
|
if (
|
||||||
Object.values(payload.inputDigests).some((digest) => !isSha256Hex(digest))) throw new Error('Projection input digest closure is invalid.');
|
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({
|
const expectedRoot = computeProductionInputRoot({
|
||||||
portfolioContentDigest: payload.inputDigests.PortfolioContent,
|
portfolioContentDigest: payload.inputDigests.PortfolioContent,
|
||||||
publicAssetManifestDigest: payload.inputDigests.PublicAssetManifest,
|
publicAssetManifestDigest: payload.inputDigests.PublicAssetManifest,
|
||||||
preparedAssetSetDigest: payload.inputDigests.PreparedAssetSet,
|
preparedAssetSetDigest: payload.inputDigests.PreparedAssetSet,
|
||||||
siteConfigDigest: payload.inputDigests.SiteConfig,
|
siteConfigDigest: payload.inputDigests.SiteConfig,
|
||||||
});
|
});
|
||||||
if (expectedRoot !== payload.productionInputRoot) throw new Error('Projection does not bind the exact four-input root.');
|
if (expectedRoot !== payload.productionInputRoot)
|
||||||
if (payload.liveLedgerHeadHash !== trust.latestLedgerHeadHash || payload.liveLedgerRevision !== trust.latestLedgerRevision) throw new Error('Projection live-ledger state is stale.');
|
throw new Error("Projection does not bind the exact four-input root.");
|
||||||
for (const [label, value] of [['now', trust.now], ['issuedAt', payload.issuedAt], ['expiresAt', payload.expiresAt], ['earliestApprovalExpiry', payload.earliestApprovalExpiry]] as const) {
|
if (
|
||||||
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.`);
|
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) ||
|
if (
|
||||||
Date.parse(payload.expiresAt) > Date.parse(payload.earliestApprovalExpiry)) throw new Error('Projection is outside its trusted expiry bounds.');
|
Date.parse(payload.issuedAt) > Date.parse(trust.now) ||
|
||||||
return verifyEd25519Envelope(envelope as unknown as ApprovalProjectionAttestationEnvelope, trust.trustedKey);
|
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,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,23 +1,24 @@
|
|||||||
import type { KeyLike } from 'node:crypto';
|
import type { KeyLike } from "node:crypto";
|
||||||
import {
|
import {
|
||||||
|
canonicalJsonDigest,
|
||||||
assertWellFormedNfcString,
|
assertWellFormedNfcString,
|
||||||
isSha256Hex,
|
isSha256Hex,
|
||||||
parseCanonicalJson,
|
parseCanonicalJson,
|
||||||
verifyEd25519Envelope,
|
verifyEd25519Envelope,
|
||||||
} from './canonical-digest.ts';
|
} from "./canonical-digest.ts";
|
||||||
|
|
||||||
export interface ApprovalPayload {
|
export interface ApprovalPayload {
|
||||||
schema: 'portfolio-approval-payload/v1';
|
schema: "portfolio-approval-payload/v1";
|
||||||
keyId: string;
|
keyId: string;
|
||||||
issuerEpoch: number;
|
issuerEpoch: number;
|
||||||
authorizedRole: string;
|
authorizedRole: string;
|
||||||
evidenceFingerprints: string[];
|
evidenceFingerprints: string[];
|
||||||
recordClass: 'content' | 'asset';
|
recordClass: "content" | "asset";
|
||||||
recordType: string;
|
recordType: string;
|
||||||
publicRecordIdOrAssetId: string;
|
publicRecordIdOrAssetId: string;
|
||||||
approvedPublicRecordHashOrManifestRecordHash: string;
|
approvedPublicRecordHashOrManifestRecordHash: string;
|
||||||
sanitizedOutputHashOrNull: string | null;
|
sanitizedOutputHashOrNull: string | null;
|
||||||
decision: 'approve' | 'reject' | 'revoke';
|
decision: "approve" | "reject" | "revoke";
|
||||||
issuedAt: string;
|
issuedAt: string;
|
||||||
expiresAt: string;
|
expiresAt: string;
|
||||||
previousLedgerHeadHash: string;
|
previousLedgerHeadHash: string;
|
||||||
@@ -26,15 +27,19 @@ export interface ApprovalPayload {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface ApprovalEnvelope {
|
export interface ApprovalEnvelope {
|
||||||
schema: 'portfolio-approval-envelope/v1';
|
schema: "portfolio-approval-envelope/v1";
|
||||||
payload: ApprovalPayload;
|
payload: ApprovalPayload;
|
||||||
signature: { algorithm: 'Ed25519'; encoding: 'base64url-nopad'; value: string };
|
signature: {
|
||||||
|
algorithm: "Ed25519";
|
||||||
|
encoding: "base64url-nopad";
|
||||||
|
value: string;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ApprovalTrustKey {
|
export interface ApprovalTrustKey {
|
||||||
keyId: string;
|
keyId: string;
|
||||||
issuerEpoch: number;
|
issuerEpoch: number;
|
||||||
scope: 'portfolio-approval-decision/v1';
|
scope: "portfolio-approval-decision/v1";
|
||||||
authorizedRoles: string[];
|
authorizedRoles: string[];
|
||||||
publicKey: KeyLike;
|
publicKey: KeyLike;
|
||||||
revoked?: boolean;
|
revoked?: boolean;
|
||||||
@@ -45,99 +50,273 @@ export interface ApprovalTrustPolicy {
|
|||||||
latestLedgerHeadHash: string;
|
latestLedgerHeadHash: string;
|
||||||
latestLedgerRevision: number;
|
latestLedgerRevision: number;
|
||||||
trustedKeys: ApprovalTrustKey[];
|
trustedKeys: ApprovalTrustKey[];
|
||||||
|
ledgerProof: ApprovalLedgerProof;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ApprovalLedgerProof {
|
||||||
|
schema: "portfolio-approval-ledger-proof/v1";
|
||||||
|
genesisHeadHash: string;
|
||||||
|
entries: ApprovalEnvelope[];
|
||||||
}
|
}
|
||||||
|
|
||||||
const payloadKeys = [
|
const payloadKeys = [
|
||||||
'schema', 'keyId', 'issuerEpoch', 'authorizedRole', 'evidenceFingerprints', 'recordClass', 'recordType',
|
"schema",
|
||||||
'publicRecordIdOrAssetId', 'approvedPublicRecordHashOrManifestRecordHash', 'sanitizedOutputHashOrNull',
|
"keyId",
|
||||||
'decision', 'issuedAt', 'expiresAt', 'previousLedgerHeadHash', 'ledgerRevision', 'policyVersion',
|
"issuerEpoch",
|
||||||
|
"authorizedRole",
|
||||||
|
"evidenceFingerprints",
|
||||||
|
"recordClass",
|
||||||
|
"recordType",
|
||||||
|
"publicRecordIdOrAssetId",
|
||||||
|
"approvedPublicRecordHashOrManifestRecordHash",
|
||||||
|
"sanitizedOutputHashOrNull",
|
||||||
|
"decision",
|
||||||
|
"issuedAt",
|
||||||
|
"expiresAt",
|
||||||
|
"previousLedgerHeadHash",
|
||||||
|
"ledgerRevision",
|
||||||
|
"policyVersion",
|
||||||
] as const;
|
] as const;
|
||||||
const envelopeKeys = ['schema', 'payload', 'signature'] as const;
|
const envelopeKeys = ["schema", "payload", "signature"] as const;
|
||||||
const signatureKeys = ['algorithm', 'encoding', 'value'] 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 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;
|
const safeToken = /^[A-Za-z0-9][A-Za-z0-9._/-]{0,127}$/u;
|
||||||
|
|
||||||
function record(value: unknown, label: string): Record<string, unknown> {
|
function record(value: unknown, label: string): Record<string, unknown> {
|
||||||
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<string, unknown>;
|
return value as Record<string, unknown>;
|
||||||
}
|
}
|
||||||
|
|
||||||
function exactKeys(value: Record<string, unknown>, keys: readonly string[], label: string): void {
|
function exactKeys(
|
||||||
|
value: Record<string, unknown>,
|
||||||
|
keys: readonly string[],
|
||||||
|
label: string,
|
||||||
|
): void {
|
||||||
const actual = Object.keys(value).sort();
|
const actual = Object.keys(value).sort();
|
||||||
const expected = [...keys].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.`);
|
throw new Error(`${label} contains missing or unknown fields.`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function validTimestamp(value: unknown, label: string): asserts value is string {
|
function validTimestamp(
|
||||||
if (typeof value !== 'string' || !utcTimestamp.test(value) || Number.isNaN(Date.parse(value))) {
|
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.`);
|
throw new Error(`${label} must be a UTC RFC 3339 timestamp.`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function validateApprovalEnvelope(value: unknown): ApprovalEnvelope {
|
export function validateApprovalEnvelope(value: unknown): ApprovalEnvelope {
|
||||||
const outer = record(value, 'approval envelope');
|
const outer = record(value, "approval envelope");
|
||||||
exactKeys(outer, envelopeKeys, 'approval envelope');
|
exactKeys(outer, envelopeKeys, "approval envelope");
|
||||||
if (outer.schema !== 'portfolio-approval-envelope/v1') throw new Error('Approval envelope schema is invalid.');
|
if (outer.schema !== "portfolio-approval-envelope/v1")
|
||||||
const payload = record(outer.payload, 'approval payload');
|
throw new Error("Approval envelope schema is invalid.");
|
||||||
exactKeys(payload, payloadKeys, 'approval payload');
|
const payload = record(outer.payload, "approval payload");
|
||||||
const signature = record(outer.signature, 'approval signature');
|
exactKeys(payload, payloadKeys, "approval payload");
|
||||||
exactKeys(signature, signatureKeys, 'approval signature');
|
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.');
|
if (payload.schema !== "portfolio-approval-payload/v1")
|
||||||
for (const key of ['keyId', 'authorizedRole', 'publicRecordIdOrAssetId', 'policyVersion'] as const) {
|
throw new Error("Approval payload schema is invalid.");
|
||||||
|
for (const key of [
|
||||||
|
"keyId",
|
||||||
|
"authorizedRole",
|
||||||
|
"publicRecordIdOrAssetId",
|
||||||
|
"policyVersion",
|
||||||
|
] as const) {
|
||||||
const field = payload[key];
|
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);
|
assertWellFormedNfcString(field, key);
|
||||||
}
|
}
|
||||||
if (!Number.isSafeInteger(payload.issuerEpoch) || (payload.issuerEpoch as number) < 1) throw new Error('issuerEpoch is invalid.');
|
if (
|
||||||
if (!Number.isSafeInteger(payload.ledgerRevision) || (payload.ledgerRevision as number) < 0) throw new Error('ledgerRevision is invalid.');
|
!Number.isSafeInteger(payload.issuerEpoch) ||
|
||||||
if (payload.recordClass !== 'content' && payload.recordClass !== 'asset') throw new Error('recordClass is invalid.');
|
(payload.issuerEpoch as number) < 1
|
||||||
if (typeof payload.recordType !== 'string') throw new Error('recordType is invalid.');
|
)
|
||||||
assertWellFormedNfcString(payload.recordType, 'recordType');
|
throw new Error("issuerEpoch is invalid.");
|
||||||
if (payload.recordClass === 'content' && !safeToken.test(payload.recordType)) throw new Error('Content recordType is required.');
|
if (
|
||||||
if (payload.recordClass === 'asset' && payload.recordType !== '') throw new Error('Asset recordType must be empty.');
|
!Number.isSafeInteger(payload.ledgerRevision) ||
|
||||||
if (!isSha256Hex(payload.approvedPublicRecordHashOrManifestRecordHash) || !isSha256Hex(payload.previousLedgerHeadHash)) {
|
(payload.ledgerRevision as number) < 0
|
||||||
throw new Error('Approval hash field is invalid.');
|
)
|
||||||
|
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 ||
|
if (
|
||||||
|
!Array.isArray(payload.evidenceFingerprints) ||
|
||||||
|
payload.evidenceFingerprints.length === 0 ||
|
||||||
payload.evidenceFingerprints.some((item) => !isSha256Hex(item)) ||
|
payload.evidenceFingerprints.some((item) => !isSha256Hex(item)) ||
|
||||||
new Set(payload.evidenceFingerprints).size !== payload.evidenceFingerprints.length) {
|
new Set(payload.evidenceFingerprints).size !==
|
||||||
throw new Error('evidenceFingerprints must be a unique, non-empty digest list.');
|
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 (
|
||||||
if (payload.recordClass === 'asset' && !isSha256Hex(payload.sanitizedOutputHashOrNull)) throw new Error('Asset approval must bind sanitized bytes.');
|
payload.recordClass === "content" &&
|
||||||
if (!['approve', 'reject', 'revoke'].includes(payload.decision as string)) throw new Error('Decision is invalid.');
|
payload.sanitizedOutputHashOrNull !== null
|
||||||
validTimestamp(payload.issuedAt, 'issuedAt');
|
)
|
||||||
validTimestamp(payload.expiresAt, 'expiresAt');
|
throw new Error("Content approval cannot bind sanitized bytes.");
|
||||||
if (Date.parse(payload.issuedAt) >= Date.parse(payload.expiresAt)) throw new Error('Approval expiry must follow issue time.');
|
if (
|
||||||
if (signature.algorithm !== 'Ed25519' || signature.encoding !== 'base64url-nopad' || typeof signature.value !== 'string') {
|
payload.recordClass === "asset" &&
|
||||||
throw new Error('Approval signature metadata is invalid.');
|
!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;
|
return outer as unknown as ApprovalEnvelope;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function parseApprovalEnvelopeJson(source: string | Uint8Array): ApprovalEnvelope {
|
export function parseApprovalEnvelopeJson(
|
||||||
|
source: string | Uint8Array,
|
||||||
|
): ApprovalEnvelope {
|
||||||
return validateApprovalEnvelope(parseCanonicalJson(source));
|
return validateApprovalEnvelope(parseCanonicalJson(source));
|
||||||
}
|
}
|
||||||
|
|
||||||
export function verifyApprovalEnvelope(envelopeValue: unknown, trust: ApprovalTrustPolicy): ApprovalPayload {
|
function verifyEnvelopeSignature(
|
||||||
|
envelopeValue: unknown,
|
||||||
|
trust: ApprovalTrustPolicy,
|
||||||
|
): ApprovalEnvelope {
|
||||||
const envelope = validateApprovalEnvelope(envelopeValue);
|
const envelope = validateApprovalEnvelope(envelopeValue);
|
||||||
validTimestamp(trust.now, 'trusted now');
|
const matchingKeys = trust.trustedKeys.filter(
|
||||||
if (!isSha256Hex(trust.latestLedgerHeadHash) || !Number.isSafeInteger(trust.latestLedgerRevision)) {
|
(candidate) =>
|
||||||
throw new Error('Protected live-ledger state is invalid.');
|
candidate.keyId === envelope.payload.keyId &&
|
||||||
}
|
candidate.issuerEpoch === envelope.payload.issuerEpoch,
|
||||||
const matchingKeys = trust.trustedKeys.filter((candidate) =>
|
);
|
||||||
candidate.keyId === envelope.payload.keyId && candidate.issuerEpoch === envelope.payload.issuerEpoch);
|
if (matchingKeys.length !== 1)
|
||||||
if (matchingKeys.length !== 1) throw new Error('Approval trust policy must contain exactly one matching key epoch.');
|
throw new Error(
|
||||||
|
"Approval trust policy must contain exactly one matching key epoch.",
|
||||||
|
);
|
||||||
const key = matchingKeys[0];
|
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 (
|
||||||
if (!key.authorizedRoles.includes(envelope.payload.authorizedRole)) throw new Error('Approval role is not authorized.');
|
key === undefined ||
|
||||||
if (envelope.payload.ledgerRevision >= trust.latestLedgerRevision) throw new Error('Approval is not anchored before the protected live-ledger revision.');
|
key.revoked ||
|
||||||
if (Date.parse(trust.now) < Date.parse(envelope.payload.issuedAt) || Date.parse(trust.now) >= Date.parse(envelope.payload.expiresAt)) {
|
key.scope !== "portfolio-approval-decision/v1"
|
||||||
throw new Error('Approval is not current at the trusted time.');
|
)
|
||||||
|
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;
|
||||||
}
|
}
|
||||||
if (envelope.payload.decision !== 'approve') throw new Error('Only a current approval decision can enter the production closure.');
|
|
||||||
return verifyEd25519Envelope(envelope, key);
|
function typedDecisionId(payload: ApprovalPayload): string {
|
||||||
|
return `${payload.recordClass}\0${payload.recordType}\0${payload.publicRecordIdOrAssetId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function verifyApprovalLedger(
|
||||||
|
trust: ApprovalTrustPolicy,
|
||||||
|
): Map<string, ApprovalEnvelope> {
|
||||||
|
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<string, ApprovalEnvelope>();
|
||||||
|
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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,101 +1,314 @@
|
|||||||
import { generateKeyPairSync, sign } from 'node:crypto';
|
import { generateKeyPairSync, sign } from "node:crypto";
|
||||||
import { describe, expect, test } from 'vitest';
|
import { describe, expect, test } from "vitest";
|
||||||
|
|
||||||
import { canonicalJsonDigest, canonicalValueBytes } from '../../src/security/canonical-digest.ts';
|
import {
|
||||||
import { computePreparedAssetSetDigest, computeProductionInputRoot } from '../../src/security/production-input-root.ts';
|
canonicalJsonDigest,
|
||||||
import { validateReleaseManifest } from '../../src/security/release-manifest.ts';
|
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.
|
// @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);
|
const h = (c: string) => c.repeat(64);
|
||||||
|
|
||||||
describe('production input and private-source release manifest', () => {
|
describe("production input and private-source release manifest", () => {
|
||||||
test('hashes an exact manifest-bound PreparedAssetSet and rejects missing or extra members', () => {
|
test("hashes an exact manifest-bound PreparedAssetSet and rejects missing or extra members", () => {
|
||||||
const bytes = new TextEncoder().encode('image');
|
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 member = {
|
||||||
const manifest = [{ id: 'asset-one', fileName: 'asset-one.webp', sha256: '6105d6cc76af400325e94d588ce511be5bfdbb73b437dc51eca43917d7a43e3d', mediaType: 'image/webp', width: 10, height: 20, durationMs: null }];
|
assetId: "asset-one",
|
||||||
expect(computePreparedAssetSetDigest([member], manifest)).toMatch(/^[a-f0-9]{64}$/);
|
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([], 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', () => {
|
test("matches the fixed four-label input-root vector", () => {
|
||||||
expect(computeProductionInputRoot({
|
expect(
|
||||||
portfolioContentDigest: h('a'), publicAssetManifestDigest: h('b'),
|
computeProductionInputRoot({
|
||||||
preparedAssetSetDigest: h('c'), siteConfigDigest: h('d'),
|
portfolioContentDigest: h("a"),
|
||||||
})).toBe('38707dd72ded582a346048ff66a1625cdaca91f6536dd27c935bc3b8af262ae8');
|
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 = {
|
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 = {
|
const manifest = {
|
||||||
schema: 'portfolio-release-manifest/v1', launchMode: 'private-source-initial-launch', buildId: 'release-2026-09-02',
|
schema: "portfolio-release-manifest/v1",
|
||||||
inputDigests, productionInputRoot: computeProductionInputRoot({
|
launchMode: "private-source-initial-launch",
|
||||||
portfolioContentDigest: h('a'), publicAssetManifestDigest: h('b'), preparedAssetSetDigest: h('c'), siteConfigDigest: h('d'),
|
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' }],
|
artifacts: [
|
||||||
deployFiles: ['index.html'], sourceCommit: h('f'), sourceTree: h('0'),
|
{ path: "index.html", sha256: h("e"), bytes: 42, encoding: "identity" },
|
||||||
approvedRecordRoot: h('1'), approvedAssetRoot: h('2'), approvalProofSetDigest: h('3'),
|
],
|
||||||
approvalProjectionAttestationDigest: h('4'), scanEvidenceDigest: h('5'),
|
deployFiles: ["index.html"],
|
||||||
policyVersions: { approval: '1', privacy: '1', release: '1' }, toolVersions: { node: '22.22.1' },
|
sourceCommit: h("f"),
|
||||||
predecessorManifestDigest: null, currentManifestDigest: null,
|
sourceTree: h("0"),
|
||||||
issuedAt: '2026-09-02T00:00:00Z', expiresAt: '2026-09-03T00:00:00Z', minimumBoundExpiry: '2026-09-03T00:00:00Z',
|
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(
|
||||||
expect(canonicalJsonDigest('portfolio-release-manifest/v1', manifest)).toMatch(/^[a-f0-9]{64}$/);
|
validateReleaseManifest(manifest, { now: "2026-09-02T01:00:00Z" }),
|
||||||
|
).toEqual(manifest);
|
||||||
|
expect(
|
||||||
|
canonicalJsonDigest("portfolio-release-manifest/v1", manifest),
|
||||||
|
).toMatch(/^[a-f0-9]{64}$/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test.each([
|
test.each([
|
||||||
['public Git target', { publicGitTarget: 'https://example.com/repo.git' }],
|
["public Git target", { publicGitTarget: "https://example.com/repo.git" }],
|
||||||
['extra deploy file', { deployFiles: ['index.html', 'extra.txt'] }],
|
["extra deploy file", { deployFiles: ["index.html", "extra.txt"] }],
|
||||||
['case-fold collision', { artifacts: [
|
[
|
||||||
{ path: 'A.js', sha256: h('e'), bytes: 1, encoding: 'identity' },
|
"case-fold collision",
|
||||||
{ path: 'a.js', sha256: h('f'), bytes: 1, encoding: 'identity' },
|
{
|
||||||
], deployFiles: ['A.js', 'a.js'] }],
|
artifacts: [
|
||||||
['expired manifest', { expiresAt: '2026-09-02T00:30:00Z' }],
|
{ path: "A.js", sha256: h("e"), bytes: 1, encoding: "identity" },
|
||||||
])('rejects %s', (_label, override) => {
|
{ path: "a.js", sha256: h("f"), bytes: 1, encoding: "identity" },
|
||||||
const inputDigests = { PortfolioContent: h('a'), PublicAssetManifest: h('b'), PreparedAssetSet: h('c'), SiteConfig: h('d') };
|
],
|
||||||
const baseline = {
|
deployFiles: ["A.js", "a.js"],
|
||||||
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'],
|
["expired manifest", { expiresAt: "2026-09-02T00:30:00Z" }],
|
||||||
sourceCommit: h('f'), sourceTree: h('0'), approvedRecordRoot: h('1'), approvedAssetRoot: h('2'), approvalProofSetDigest: h('3'),
|
])("rejects %s", (_label, override) => {
|
||||||
approvalProjectionAttestationDigest: h('4'), scanEvidenceDigest: h('5'), policyVersions: { approval: '1', privacy: '1', release: '1' },
|
const inputDigests = {
|
||||||
toolVersions: { node: '22' }, predecessorManifestDigest: null, currentManifestDigest: null,
|
PortfolioContent: h("a"),
|
||||||
issuedAt: '2026-09-02T00:00:00Z', expiresAt: '2026-09-03T00:00:00Z', minimumBoundExpiry: '2026-09-03T00:00:00Z',
|
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', () => {
|
test("approval verifier requires an authenticated exact ledger and rejects replay after revoke", () => {
|
||||||
const keys = generateKeyPairSync('ed25519');
|
const keys = generateKeyPairSync("ed25519");
|
||||||
const approvalPayload = {
|
const approvalPayload = {
|
||||||
schema: 'portfolio-approval-payload/v1', keyId: 'approval-key', issuerEpoch: 1, authorizedRole: 'approver',
|
schema: "portfolio-approval-payload/v1",
|
||||||
evidenceFingerprints: [h('1')], recordClass: 'content', recordType: 'profile', publicRecordIdOrAssetId: 'profile-main',
|
keyId: "approval-key",
|
||||||
approvedPublicRecordHashOrManifestRecordHash: h('2'), sanitizedOutputHashOrNull: null, decision: 'approve',
|
issuerEpoch: 1,
|
||||||
issuedAt: '2026-09-01T00:00:00Z', expiresAt: '2026-09-04T00:00:00Z', previousLedgerHeadHash: h('8'),
|
authorizedRole: "approver",
|
||||||
ledgerRevision: 1, policyVersion: 'policy-1',
|
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 = {
|
const envelope = {
|
||||||
schema: 'portfolio-approval-envelope/v1', payload: approvalPayload,
|
schema: "portfolio-approval-envelope/v1",
|
||||||
signature: { algorithm: 'Ed25519', encoding: 'base64url-nopad', value: sign(null, canonicalValueBytes(approvalPayload), keys.privateKey).toString('base64url') },
|
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 = {
|
const request = {
|
||||||
schema: 'portfolio-approval-verification-request/v1', now: '2026-09-02T00:00:00Z',
|
schema: "portfolio-approval-verification-request/v1",
|
||||||
latestLedgerHeadHash: h('9'), latestLedgerRevision: 2,
|
latestLedgerHeadHash: head,
|
||||||
trustedKeys: [{ keyId: 'approval-key', issuerEpoch: 1, scope: 'portfolio-approval-decision/v1', authorizedRoles: ['approver'], publicKeyPem: keys.publicKey.export({ type: 'spki', format: 'pem' }) }],
|
latestLedgerRevision: 1,
|
||||||
contentRecords: [{ recordType: 'profile', publicRecordId: 'profile-main', approvedPublicRecordHash: h('2'), envelope }],
|
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: [],
|
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([
|
expect(Object.keys(result).sort()).toEqual([
|
||||||
'approvalProofSetDigest', 'approvedAssetRoot', 'approvedRecordRoot', 'earliestApprovalExpiry',
|
"approvalProofSetDigest",
|
||||||
'latestLedgerHeadHash', 'latestLedgerRevision', 'productionInputRoot', 'schema',
|
"approvedAssetRoot",
|
||||||
|
"approvedRecordRoot",
|
||||||
|
"earliestApprovalExpiry",
|
||||||
|
"latestLedgerHeadHash",
|
||||||
|
"latestLedgerRevision",
|
||||||
|
"productionInputRoot",
|
||||||
|
"projectionSchema",
|
||||||
|
"projectionScope",
|
||||||
|
"schema",
|
||||||
]);
|
]);
|
||||||
const serialized = new TextDecoder().decode(canonicalValueBytes(result));
|
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);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -6,9 +6,11 @@ import {
|
|||||||
symlink,
|
symlink,
|
||||||
writeFile,
|
writeFile,
|
||||||
} from "node:fs/promises";
|
} from "node:fs/promises";
|
||||||
|
import { createHash } from "node:crypto";
|
||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
import { describe, expect, test } from "vitest";
|
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
|
// @ts-expect-error executable release modules intentionally have no browser declarations
|
||||||
import { enumerateClosure } from "../../scripts/release/enumerate-closure.mjs";
|
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";
|
import * as privacyRunner from "../../scripts/release/run-privacy-workers.mjs";
|
||||||
// @ts-expect-error executable release modules intentionally have no browser declarations
|
// @ts-expect-error executable release modules intentionally have no browser declarations
|
||||||
import { reconcileScan } from "../../scripts/release/reconcile-scan.mjs";
|
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 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(
|
function report(
|
||||||
workerId: "worker-a" | "worker-b",
|
workerId: "worker-a" | "worker-b",
|
||||||
@@ -56,6 +72,124 @@ function matcher(override: Record<string, unknown> = {}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe("isolated privacy closure and reconciliation", () => {
|
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 () => {
|
test("enumerates an exact byte-sorted closure and rejects unsafe filesystem members", async () => {
|
||||||
const root = await mkdtemp(join(tmpdir(), "portfolio-closure-"));
|
const root = await mkdtemp(join(tmpdir(), "portfolio-closure-"));
|
||||||
await mkdir(join(root, "nested"));
|
await mkdir(join(root, "nested"));
|
||||||
@@ -142,14 +276,25 @@ describe("isolated privacy closure and reconciliation", () => {
|
|||||||
).toThrowError(expect.not.stringContaining(secret));
|
).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({
|
const result = reconcileScan({
|
||||||
expectedChecks: [{ inputDigest: h("1"), checkId: "bytes" }],
|
sourceClosure: source,
|
||||||
reports: [report("worker-a"), report("worker-b")],
|
publicObjectClosure,
|
||||||
matcherResults: [matcher()],
|
reports: [],
|
||||||
|
matcherResults: [],
|
||||||
candidate: {
|
candidate: {
|
||||||
schema: "portfolio-candidate-release-manifest/v1",
|
schema: "portfolio-candidate-release-manifest/v1",
|
||||||
buildId: "r1",
|
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}$/);
|
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 () => {
|
test("writes canonical evidence and candidate objects content-addressed without overwrite", async () => {
|
||||||
|
const source = sourceClosure();
|
||||||
|
const publicObjectClosure = publicClosure();
|
||||||
const result = reconcileScan({
|
const result = reconcileScan({
|
||||||
expectedChecks: [{ inputDigest: h("1"), checkId: "bytes" }],
|
sourceClosure: source,
|
||||||
reports: [report("worker-a"), report("worker-b")],
|
publicObjectClosure,
|
||||||
matcherResults: [matcher()],
|
reports: [],
|
||||||
|
matcherResults: [],
|
||||||
candidate: {
|
candidate: {
|
||||||
schema: "portfolio-candidate-release-manifest/v1",
|
schema: "portfolio-candidate-release-manifest/v1",
|
||||||
buildId: "r1",
|
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-"));
|
const root = await mkdtemp(join(tmpdir(), "portfolio-evidence-"));
|
||||||
|
|||||||
79
tests/security/release-authority.test.ts
Normal file
79
tests/security/release-authority.test.ts
Normal file
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -9,19 +9,22 @@ import {
|
|||||||
canonicalValueBytes,
|
canonicalValueBytes,
|
||||||
} from "../../src/security/canonical-digest.ts";
|
} from "../../src/security/canonical-digest.ts";
|
||||||
import { computeProductionInputRoot } from "../../src/security/production-input-root.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
|
// @ts-expect-error executable release modules intentionally have no browser declarations
|
||||||
import * as releaseSigner from "../../scripts/release/sign-release.mjs";
|
import * as releaseSigner from "../../scripts/release/sign-release.mjs";
|
||||||
|
|
||||||
const {
|
const {
|
||||||
signReleaseCandidate,
|
signReleaseCandidate,
|
||||||
|
signReleaseCandidateForTest,
|
||||||
validateTrustPolicy,
|
validateTrustPolicy,
|
||||||
verifySignedReleaseEnvelope,
|
verifySignedReleaseEnvelope,
|
||||||
|
verifySignedReleaseEnvelopeForTest,
|
||||||
} = releaseSigner;
|
} = releaseSigner;
|
||||||
|
|
||||||
const h = (c: string) => c.repeat(64);
|
const h = (c: string) => c.repeat(64);
|
||||||
const roles = [
|
const roles = [
|
||||||
["approval", "portfolio-approval-decision/v1"],
|
["approval", "portfolio-approval-decision/v1"],
|
||||||
["projection", "portfolio-approval-projection/v1"],
|
["projection", APPROVAL_PROJECTION_ATTESTATION_SCOPE],
|
||||||
["privacyWorkload", "portfolio-privacy-workload/v1"],
|
["privacyWorkload", "portfolio-privacy-workload/v1"],
|
||||||
["policy", "portfolio-policy-attestation/v1"],
|
["policy", "portfolio-policy-attestation/v1"],
|
||||||
["toolImage", "portfolio-tool-image-provenance/v1"],
|
["toolImage", "portfolio-tool-image-provenance/v1"],
|
||||||
@@ -91,6 +94,9 @@ function envelope(
|
|||||||
...payload,
|
...payload,
|
||||||
};
|
};
|
||||||
return {
|
return {
|
||||||
|
...(role === "projection"
|
||||||
|
? { schema: "portfolio-approval-projection-attestation-envelope/v1" }
|
||||||
|
: {}),
|
||||||
payload: body,
|
payload: body,
|
||||||
signature: {
|
signature: {
|
||||||
algorithm: "Ed25519",
|
algorithm: "Ed25519",
|
||||||
@@ -142,12 +148,27 @@ function fixture() {
|
|||||||
expiresAt: "2026-09-02T12:00:00Z",
|
expiresAt: "2026-09-02T12:00:00Z",
|
||||||
minimumBoundExpiry: "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 = {
|
const candidate = {
|
||||||
schema: "portfolio-candidate-release-manifest/v1",
|
schema: "portfolio-candidate-release-manifest/v1",
|
||||||
releaseManifest,
|
releaseManifest,
|
||||||
latestLedgerHeadHash: h("1"),
|
latestLedgerHeadHash: h("1"),
|
||||||
latestLedgerRevision: 7,
|
latestLedgerRevision: 7,
|
||||||
sourceClosureDigest: h("4"),
|
sourceClosureDigest: canonicalJsonDigest(
|
||||||
|
"portfolio-publication-closure/v1",
|
||||||
|
sourceClosure,
|
||||||
|
),
|
||||||
|
publicObjectClosureDigest: canonicalJsonDigest(
|
||||||
|
"portfolio-public-object-closure/v1",
|
||||||
|
publicObjectClosure,
|
||||||
|
),
|
||||||
publicRefMap: {},
|
publicRefMap: {},
|
||||||
lfsOids: [],
|
lfsOids: [],
|
||||||
releaseArchives: [],
|
releaseArchives: [],
|
||||||
@@ -157,40 +178,40 @@ function fixture() {
|
|||||||
};
|
};
|
||||||
const evidence = {
|
const evidence = {
|
||||||
schema: "portfolio-scan-evidence/v1",
|
schema: "portfolio-scan-evidence/v1",
|
||||||
coverageCount: 1,
|
sourceClosureDigest: candidate.sourceClosureDigest,
|
||||||
passedCount: 1,
|
publicObjectClosureDigest: candidate.publicObjectClosureDigest,
|
||||||
checks: [
|
coverageCount: 0,
|
||||||
{
|
passedCount: 0,
|
||||||
inputDigest: h("5"),
|
checks: [],
|
||||||
checkId: "bytes",
|
|
||||||
normalizedOutputDigest: h("6"),
|
|
||||||
normalizedMembers: [`bytes:${h("7")}`],
|
|
||||||
workerReports: [h("8"), h("9")],
|
|
||||||
matcherResult: h("a"),
|
|
||||||
ruleSetDigest: h("b"),
|
|
||||||
appliedRuleIds: ["denylist-v1"],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
};
|
||||||
const evidenceDigest = canonicalJsonDigest(
|
const evidenceDigest = canonicalJsonDigest(
|
||||||
"portfolio-scan-evidence/v1",
|
"portfolio-scan-evidence/v1",
|
||||||
evidence,
|
evidence,
|
||||||
);
|
);
|
||||||
candidate.releaseManifest.scanEvidenceDigest = evidenceDigest;
|
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(
|
const candidateDigest = canonicalJsonDigest(
|
||||||
"portfolio-candidate-release-manifest/v1",
|
"portfolio-candidate-release-manifest/v1",
|
||||||
candidate,
|
candidate,
|
||||||
);
|
);
|
||||||
const attestations = {
|
const attestations = {
|
||||||
projection: envelope("projection", roles[1][1], keyPairs, {
|
projection,
|
||||||
digest: h("2"),
|
|
||||||
productionInputRoot: releaseManifest.productionInputRoot,
|
|
||||||
approvedRecordRoot: h("9"),
|
|
||||||
approvedAssetRoot: h("e"),
|
|
||||||
approvalProofSetDigest: h("f"),
|
|
||||||
latestLedgerHeadHash: h("1"),
|
|
||||||
latestLedgerRevision: 7,
|
|
||||||
}),
|
|
||||||
privacyWorkload: envelope("privacyWorkload", roles[2][1], keyPairs, {
|
privacyWorkload: envelope("privacyWorkload", roles[2][1], keyPairs, {
|
||||||
digest: h("2"),
|
digest: h("2"),
|
||||||
candidateDigest,
|
candidateDigest,
|
||||||
@@ -207,6 +228,8 @@ function fixture() {
|
|||||||
};
|
};
|
||||||
return {
|
return {
|
||||||
keyPairs,
|
keyPairs,
|
||||||
|
sourceClosure,
|
||||||
|
publicObjectClosure,
|
||||||
candidate,
|
candidate,
|
||||||
evidence,
|
evidence,
|
||||||
candidateDigest,
|
candidateDigest,
|
||||||
@@ -217,6 +240,54 @@ function fixture() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe("external scoped release signing", () => {
|
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 () => {
|
test("signs exact canonical candidate bytes with an external Ed25519 key", async () => {
|
||||||
const f = fixture();
|
const f = fixture();
|
||||||
const dir = await mkdtemp(join(tmpdir(), "portfolio-release-key-"));
|
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" }),
|
f.keyPairs.release!.privateKey.export({ type: "pkcs8", format: "pem" }),
|
||||||
{ mode: 0o400 },
|
{ mode: 0o400 },
|
||||||
);
|
);
|
||||||
const result = await signReleaseCandidate({
|
const result = await signReleaseCandidateForTest({
|
||||||
...f,
|
...f,
|
||||||
releaseKeyPath: keyPath,
|
releaseKeyPath: keyPath,
|
||||||
now: "2026-09-02T01:00:00Z",
|
now: "2026-09-02T01:00:00Z",
|
||||||
@@ -247,7 +318,7 @@ describe("external scoped release signing", () => {
|
|||||||
),
|
),
|
||||||
).toBe(true);
|
).toBe(true);
|
||||||
expect(
|
expect(
|
||||||
verifySignedReleaseEnvelope({
|
verifySignedReleaseEnvelopeForTest({
|
||||||
signedRelease: result,
|
signedRelease: result,
|
||||||
trustPolicy: f.trustPolicy,
|
trustPolicy: f.trustPolicy,
|
||||||
now: "2026-09-02T01:00:00Z",
|
now: "2026-09-02T01:00:00Z",
|
||||||
@@ -285,14 +356,14 @@ describe("external scoped release signing", () => {
|
|||||||
currentManifestDigest: null,
|
currentManifestDigest: null,
|
||||||
publisherFence: 4,
|
publisherFence: 4,
|
||||||
};
|
};
|
||||||
const signedRelease = await signReleaseCandidate({
|
const signedRelease = await signReleaseCandidateForTest({
|
||||||
...f,
|
...f,
|
||||||
releaseKeyPath: keyPath,
|
releaseKeyPath: keyPath,
|
||||||
now: "2026-09-02T01:00:00Z",
|
now: "2026-09-02T01:00:00Z",
|
||||||
protectedState: state,
|
protectedState: state,
|
||||||
});
|
});
|
||||||
expect(() =>
|
expect(() =>
|
||||||
verifySignedReleaseEnvelope({
|
verifySignedReleaseEnvelopeForTest({
|
||||||
signedRelease,
|
signedRelease,
|
||||||
trustPolicy: f.trustPolicy,
|
trustPolicy: f.trustPolicy,
|
||||||
now: "2026-09-02T01:00:00Z",
|
now: "2026-09-02T01:00:00Z",
|
||||||
@@ -300,7 +371,7 @@ describe("external scoped release signing", () => {
|
|||||||
}),
|
}),
|
||||||
).toThrow(/stale/i);
|
).toThrow(/stale/i);
|
||||||
expect(() =>
|
expect(() =>
|
||||||
verifySignedReleaseEnvelope({
|
verifySignedReleaseEnvelopeForTest({
|
||||||
signedRelease: {
|
signedRelease: {
|
||||||
...signedRelease,
|
...signedRelease,
|
||||||
payload: { ...signedRelease.payload, latestLedgerRevision: 8 },
|
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 () => {
|
test("refuses repository-resident keys and non-external key material", async () => {
|
||||||
const f = fixture();
|
const f = fixture();
|
||||||
await expect(
|
await expect(
|
||||||
signReleaseCandidate({
|
signReleaseCandidateForTest({
|
||||||
...f,
|
...f,
|
||||||
releaseKeyPath: join(process.cwd(), "release.pem"),
|
releaseKeyPath: join(process.cwd(), "release.pem"),
|
||||||
now: "2026-09-02T01:00:00Z",
|
now: "2026-09-02T01:00:00Z",
|
||||||
@@ -424,7 +495,7 @@ describe("external scoped release signing", () => {
|
|||||||
{ mode: 0o400 },
|
{ mode: 0o400 },
|
||||||
);
|
);
|
||||||
await expect(
|
await expect(
|
||||||
signReleaseCandidate({
|
signReleaseCandidateForTest({
|
||||||
...f,
|
...f,
|
||||||
releaseKeyPath: keyPath,
|
releaseKeyPath: keyPath,
|
||||||
now: "2026-09-02T01:00:00Z",
|
now: "2026-09-02T01:00:00Z",
|
||||||
|
|||||||
@@ -1,86 +1,242 @@
|
|||||||
import { generateKeyPairSync, sign } from 'node:crypto';
|
import { generateKeyPairSync, sign } from "node:crypto";
|
||||||
import { describe, expect, test } from 'vitest';
|
import { describe, expect, test } from "vitest";
|
||||||
|
|
||||||
import { computeApprovalClosure, verifyApprovalProjectionAttestation } from '../../src/security/approval-closure.ts';
|
import {
|
||||||
import { type ApprovalPayload } from '../../src/security/approval-envelope.ts';
|
computeApprovalClosure,
|
||||||
import { canonicalJsonDigest, canonicalValueBytes } from '../../src/security/canonical-digest.ts';
|
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 h = (c: string) => c.repeat(64);
|
||||||
const keys = generateKeyPairSync('ed25519');
|
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 }],
|
|
||||||
};
|
|
||||||
function envelope(payload: ApprovalPayload) {
|
function envelope(payload: ApprovalPayload) {
|
||||||
return {
|
return {
|
||||||
schema: 'portfolio-approval-envelope/v1' as const,
|
schema: "portfolio-approval-envelope/v1" as const,
|
||||||
payload,
|
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 {
|
return {
|
||||||
schema: 'portfolio-approval-payload/v1', keyId: 'key', issuerEpoch: 1, authorizedRole: 'approver',
|
schema: "portfolio-approval-payload/v1",
|
||||||
evidenceFingerprints: [h('1')], recordClass, recordType: recordClass === 'content' ? 'profile' : '',
|
keyId: "key",
|
||||||
publicRecordIdOrAssetId: id, approvedPublicRecordHashOrManifestRecordHash: h(recordClass === 'content' ? '2' : '3'),
|
issuerEpoch: 1,
|
||||||
sanitizedOutputHashOrNull: recordClass === 'asset' ? h('4') : null, decision: 'approve',
|
authorizedRole: "approver",
|
||||||
issuedAt: '2026-09-01T00:00:00Z', expiresAt: '2026-10-01T00:00:00Z', previousLedgerHeadHash: h('8'),
|
evidenceFingerprints: [h("1")],
|
||||||
ledgerRevision: 2, policyVersion: 'p1',
|
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', () => {
|
function ledgerTrust(entries: ReturnType<typeof envelope>[]) {
|
||||||
test('computes exact sorted record, asset, and proof roots', () => {
|
let head = h("0");
|
||||||
const content = base('content', 'profile-main');
|
entries.forEach((entry, index) => {
|
||||||
const asset = base('asset', 'asset-main');
|
const envelopeDigest = canonicalJsonDigest(
|
||||||
|
"portfolio-approval-envelope-digest/v1",
|
||||||
|
entry,
|
||||||
|
);
|
||||||
|
head = canonicalJsonDigest("portfolio-approval-ledger-entry/v1", {
|
||||||
|
previousLedgerHeadHash: head,
|
||||||
|
ledgerRevision: index + 1,
|
||||||
|
envelopeDigest,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
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({
|
const result = computeApprovalClosure({
|
||||||
contentRecords: [{ recordType: 'profile', publicRecordId: 'profile-main', approvedPublicRecordHash: h('2'), envelope: envelope(content) }],
|
contentRecords: [
|
||||||
assetRecords: [{ assetId: 'asset-main', approvedManifestRecordHash: h('3'), sanitizedOutputHash: h('4'), envelope: envelope(asset) }],
|
{
|
||||||
|
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", 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,
|
trust,
|
||||||
});
|
}),
|
||||||
const contentEnvelopeDigest = canonicalJsonDigest('portfolio-approval-envelope-digest/v1', envelope(content));
|
).toThrow();
|
||||||
const assetEnvelopeDigest = canonicalJsonDigest('portfolio-approval-envelope-digest/v1', envelope(asset));
|
expect(() =>
|
||||||
expect(result.approvedRecordRoot).toBe(canonicalJsonDigest('portfolio-record-closure/v1', {
|
computeApprovalClosure({
|
||||||
records: [['profile', 'profile-main', h('2'), contentEnvelopeDigest]],
|
contentRecords: [record],
|
||||||
}));
|
assetRecords: [],
|
||||||
expect(result.approvedAssetRoot).toBe(canonicalJsonDigest('portfolio-asset-closure/v1', {
|
trust: { ...trust, latestLedgerRevision: 0 },
|
||||||
assets: [['asset-main', h('3'), h('4'), assetEnvelopeDigest]],
|
}),
|
||||||
}));
|
).toThrow();
|
||||||
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', () => {
|
test("verifies a scoped projection attestation against protected state and trusted time", () => {
|
||||||
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('verifies a scoped projection attestation against protected state and trusted time', () => {
|
|
||||||
const payload = {
|
const payload = {
|
||||||
schema: 'portfolio-approval-projection-attestation/v1' as const,
|
schema: "portfolio-approval-projection-attestation/v1" as const,
|
||||||
keyId: 'projection-key', issuerEpoch: 4, scope: 'portfolio-approval-projection-attestation/v1' as const,
|
keyId: "projection-key",
|
||||||
approvedRecordRoot: h('1'), approvedAssetRoot: h('2'), approvalProofSetDigest: h('3'),
|
issuerEpoch: 4,
|
||||||
inputDigests: { PortfolioContent: h('4'), PublicAssetManifest: h('5'), PreparedAssetSet: h('6'), SiteConfig: h('7') },
|
scope: "portfolio-approval-projection-attestation/v1" as const,
|
||||||
productionInputRoot: 'e19fd4083ba973074449e5ecc9364c2b339eadea32e61d8dd2f0d8e3d6e91c85',
|
approvedRecordRoot: h("1"),
|
||||||
liveLedgerHeadHash: h('8'), liveLedgerRevision: 42, policyVersion: 'projection-policy-1',
|
approvedAssetRoot: h("2"),
|
||||||
issuedAt: '2026-09-02T00:00:00Z', expiresAt: '2026-09-03T00:00:00Z', earliestApprovalExpiry: '2026-09-04T00:00:00Z',
|
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 = {
|
const attestation = {
|
||||||
schema: 'portfolio-approval-projection-attestation-envelope/v1' as const,
|
schema: "portfolio-approval-projection-attestation-envelope/v1" as const,
|
||||||
payload,
|
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, {
|
expect(
|
||||||
now: '2026-09-02T01:00:00Z', latestLedgerHeadHash: h('8'), latestLedgerRevision: 42,
|
verifyApprovalProjectionAttestation(attestation, {
|
||||||
trustedKey: { keyId: 'projection-key', issuerEpoch: 4, scope: 'portfolio-approval-projection-attestation/v1', publicKey: projectionKeys.publicKey },
|
now: "2026-09-02T01:00:00Z",
|
||||||
}).liveLedgerRevision).toBe(42);
|
latestLedgerHeadHash: h("8"),
|
||||||
expect(() => verifyApprovalProjectionAttestation(attestation, {
|
latestLedgerRevision: 42,
|
||||||
now: '2026-09-02T01:00:00Z', latestLedgerHeadHash: h('9'), latestLedgerRevision: 42,
|
trustedKey: {
|
||||||
trustedKey: { keyId: 'projection-key', issuerEpoch: 4, scope: 'portfolio-approval-projection-attestation/v1', publicKey: projectionKeys.publicKey },
|
keyId: "projection-key",
|
||||||
})).toThrow();
|
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();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,82 +1,137 @@
|
|||||||
import { generateKeyPairSync, sign } from 'node:crypto';
|
import { generateKeyPairSync, sign } from "node:crypto";
|
||||||
import { describe, expect, test } from 'vitest';
|
import { describe, expect, test } from "vitest";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
type ApprovalPayload,
|
type ApprovalPayload,
|
||||||
parseApprovalEnvelopeJson,
|
parseApprovalEnvelopeJson,
|
||||||
verifyApprovalEnvelope,
|
verifyApprovalEnvelope,
|
||||||
} from '../../src/security/approval-envelope.ts';
|
} from "../../src/security/approval-envelope.ts";
|
||||||
import { canonicalValueBytes } from '../../src/security/canonical-digest.ts';
|
import {
|
||||||
|
canonicalJsonDigest,
|
||||||
|
canonicalValueBytes,
|
||||||
|
} from "../../src/security/canonical-digest.ts";
|
||||||
|
|
||||||
const hash = (character: string) => character.repeat(64);
|
const hash = (character: string) => character.repeat(64);
|
||||||
const payload = (): ApprovalPayload => ({
|
const payload = (): ApprovalPayload => ({
|
||||||
schema: 'portfolio-approval-payload/v1',
|
schema: "portfolio-approval-payload/v1",
|
||||||
keyId: 'approval-2026',
|
keyId: "approval-2026",
|
||||||
issuerEpoch: 3,
|
issuerEpoch: 3,
|
||||||
authorizedRole: 'portfolio-approver',
|
authorizedRole: "portfolio-approver",
|
||||||
evidenceFingerprints: [hash('a')],
|
evidenceFingerprints: [hash("a")],
|
||||||
recordClass: 'content',
|
recordClass: "content",
|
||||||
recordType: 'profile',
|
recordType: "profile",
|
||||||
publicRecordIdOrAssetId: 'profile-main',
|
publicRecordIdOrAssetId: "profile-main",
|
||||||
approvedPublicRecordHashOrManifestRecordHash: hash('b'),
|
approvedPublicRecordHashOrManifestRecordHash: hash("b"),
|
||||||
sanitizedOutputHashOrNull: null,
|
sanitizedOutputHashOrNull: null,
|
||||||
decision: 'approve',
|
decision: "approve",
|
||||||
issuedAt: '2026-09-01T08:00:00Z',
|
issuedAt: "2026-09-01T08:00:00Z",
|
||||||
expiresAt: '2026-10-01T08:00:00Z',
|
expiresAt: "2026-10-01T08:00:00Z",
|
||||||
previousLedgerHeadHash: hash('c'),
|
previousLedgerHeadHash: hash("0"),
|
||||||
ledgerRevision: 12,
|
ledgerRevision: 1,
|
||||||
policyVersion: 'approval-policy-1',
|
policyVersion: "approval-policy-1",
|
||||||
});
|
});
|
||||||
|
|
||||||
function signedEnvelope(overrides: Partial<ApprovalPayload> = {}) {
|
function signedEnvelope(overrides: Partial<ApprovalPayload> = {}) {
|
||||||
const keys = generateKeyPairSync('ed25519');
|
const keys = generateKeyPairSync("ed25519");
|
||||||
const signedPayload = { ...payload(), ...overrides };
|
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 {
|
return {
|
||||||
envelope: {
|
envelope: {
|
||||||
schema: 'portfolio-approval-envelope/v1' as const,
|
schema: "portfolio-approval-envelope/v1" as const,
|
||||||
payload: signedPayload,
|
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,
|
publicKey: keys.publicKey,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
describe('approval envelopes', () => {
|
function trustFor(
|
||||||
test('verifies an exact, current, scoped Ed25519 approval', () => {
|
envelope: ReturnType<typeof signedEnvelope>["envelope"],
|
||||||
|
publicKey: ReturnType<typeof signedEnvelope>["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 { envelope, publicKey } = signedEnvelope();
|
||||||
const verified = verifyApprovalEnvelope(envelope, {
|
const verified = verifyApprovalEnvelope(
|
||||||
now: '2026-09-02T00:00:00Z',
|
envelope,
|
||||||
latestLedgerHeadHash: hash('d'),
|
trustFor(envelope, publicKey),
|
||||||
latestLedgerRevision: 13,
|
);
|
||||||
trustedKeys: [{
|
expect(verified.publicRecordIdOrAssetId).toBe("profile-main");
|
||||||
keyId: 'approval-2026', issuerEpoch: 3, scope: 'portfolio-approval-decision/v1',
|
|
||||||
authorizedRoles: ['portfolio-approver'], publicKey,
|
|
||||||
}],
|
|
||||||
});
|
|
||||||
expect(verified.publicRecordIdOrAssetId).toBe('profile-main');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test.each([
|
test.each([
|
||||||
['expired', { expiresAt: '2026-09-01T09:00:00Z' }],
|
["expired", { expiresAt: "2026-09-01T09:00:00Z" }],
|
||||||
['non-UTC timestamp', { issuedAt: '2026-09-01T10:00:00+02:00' }],
|
["non-UTC timestamp", { issuedAt: "2026-09-01T10:00:00+02:00" }],
|
||||||
['wrong epoch', { issuerEpoch: 2 }],
|
["wrong epoch", { issuerEpoch: 2 }],
|
||||||
['rejection', { decision: 'reject' }],
|
["rejection", { decision: "reject" }],
|
||||||
] as const)('rejects a %s envelope', (_label, override) => {
|
] as const)("rejects a %s envelope", (_label, override) => {
|
||||||
const { envelope, publicKey } = signedEnvelope(override);
|
const { envelope, publicKey } = signedEnvelope(override);
|
||||||
expect(() => verifyApprovalEnvelope(envelope, {
|
expect(() =>
|
||||||
now: '2026-09-02T00:00:00Z', latestLedgerHeadHash: hash('d'), latestLedgerRevision: 13,
|
verifyApprovalEnvelope(envelope, trustFor(envelope, publicKey)),
|
||||||
trustedKeys: [{ keyId: 'approval-2026', issuerEpoch: 3, scope: 'portfolio-approval-decision/v1', authorizedRoles: ['portfolio-approver'], publicKey }],
|
).toThrow();
|
||||||
})).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();
|
const { envelope } = signedEnvelope();
|
||||||
expect(() => parseApprovalEnvelopeJson(JSON.stringify({ ...envelope, extra: true }))).toThrow();
|
expect(() =>
|
||||||
expect(() => parseApprovalEnvelopeJson('{"schema":"portfolio-approval-envelope/v1","schema":"portfolio-approval-envelope/v1"}')).toThrow();
|
parseApprovalEnvelopeJson(JSON.stringify({ ...envelope, extra: true })),
|
||||||
expect(() => parseApprovalEnvelopeJson(JSON.stringify({
|
).toThrow();
|
||||||
|
expect(() =>
|
||||||
|
parseApprovalEnvelopeJson(
|
||||||
|
'{"schema":"portfolio-approval-envelope/v1","schema":"portfolio-approval-envelope/v1"}',
|
||||||
|
),
|
||||||
|
).toThrow();
|
||||||
|
expect(() =>
|
||||||
|
parseApprovalEnvelopeJson(
|
||||||
|
JSON.stringify({
|
||||||
...envelope,
|
...envelope,
|
||||||
signature: { ...envelope.signature, value: `${envelope.signature.value}=` },
|
signature: {
|
||||||
}))).toThrow();
|
...envelope.signature,
|
||||||
|
value: `${envelope.signature.value}=`,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
).toThrow();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user