feat: isolate privacy scanning and release signing
This commit is contained in:
11
containers/privacy-coordinator/Dockerfile
Normal file
11
containers/privacy-coordinator/Dockerfile
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
ARG BASE_IMAGE
|
||||||
|
FROM ${BASE_IMAGE}
|
||||||
|
LABEL org.opencontainers.image.title="portfolio-privacy-coordinator" \
|
||||||
|
org.opencontainers.image.description="Trusted closure reconciliation without raw decoder authority"
|
||||||
|
WORKDIR /opt/app
|
||||||
|
COPY package.json package-lock.json /opt/app/
|
||||||
|
RUN npm ci --omit=dev --ignore-scripts
|
||||||
|
COPY scripts/release/reconcile-scan.mjs /opt/app/scripts/release/reconcile-scan.mjs
|
||||||
|
COPY src/security /opt/app/src/security
|
||||||
|
USER 65532:65532
|
||||||
|
ENTRYPOINT ["node", "/opt/app/scripts/release/reconcile-scan.mjs", "--request", "/input/request.json"]
|
||||||
8
containers/privacy-matcher/Dockerfile
Normal file
8
containers/privacy-matcher/Dockerfile
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
ARG BASE_IMAGE
|
||||||
|
FROM ${BASE_IMAGE}
|
||||||
|
LABEL org.opencontainers.image.title="portfolio-privacy-matcher" \
|
||||||
|
org.opencontainers.image.description="Decoder-free independent privacy evidence matcher"
|
||||||
|
WORKDIR /opt/matcher
|
||||||
|
COPY containers/privacy-matcher/matcher.mjs /opt/matcher/matcher.mjs
|
||||||
|
USER 65532:65532
|
||||||
|
ENTRYPOINT ["node", "/opt/matcher/matcher.mjs"]
|
||||||
49
containers/privacy-matcher/matcher.mjs
Normal file
49
containers/privacy-matcher/matcher.mjs
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
import { createHash } from "node:crypto";
|
||||||
|
import { readFile, writeFile } from "node:fs/promises";
|
||||||
|
|
||||||
|
const request = JSON.parse(await readFile("/input/request.json", "utf8"));
|
||||||
|
const [a, b] = request.reports ?? [];
|
||||||
|
if (
|
||||||
|
!a ||
|
||||||
|
!b ||
|
||||||
|
a.workerId !== "worker-a" ||
|
||||||
|
b.workerId !== "worker-b" ||
|
||||||
|
a.imageDigest === b.imageDigest ||
|
||||||
|
a.toolchain === b.toolchain ||
|
||||||
|
a.inputDigest !== b.inputDigest ||
|
||||||
|
a.checkId !== b.checkId ||
|
||||||
|
a.normalizedOutputDigest !== b.normalizedOutputDigest ||
|
||||||
|
JSON.stringify(a.normalizedMembers) !== JSON.stringify(b.normalizedMembers) ||
|
||||||
|
a.verdict !== "pass" ||
|
||||||
|
b.verdict !== "pass"
|
||||||
|
)
|
||||||
|
throw new Error("Independent worker evidence does not agree.");
|
||||||
|
const rules = request.rules;
|
||||||
|
if (!Array.isArray(rules) || rules.length === 0)
|
||||||
|
throw new Error("Matcher rules are required.");
|
||||||
|
const appliedRuleIds = rules
|
||||||
|
.map((rule) => rule.id)
|
||||||
|
.sort((left, right) => Buffer.from(left).compare(Buffer.from(right)));
|
||||||
|
if (
|
||||||
|
new Set(appliedRuleIds).size !== appliedRuleIds.length ||
|
||||||
|
rules.some((rule) => !Array.isArray(rule.deniedMemberDigests))
|
||||||
|
)
|
||||||
|
throw new Error("Matcher rules are invalid.");
|
||||||
|
const denied = new Set(rules.flatMap((rule) => rule.deniedMemberDigests));
|
||||||
|
if (a.normalizedMembers.some((member) => denied.has(member)))
|
||||||
|
throw new Error("Privacy rule matched normalized evidence.");
|
||||||
|
const result = {
|
||||||
|
schema: "portfolio-privacy-matcher-result/v1",
|
||||||
|
inputDigest: a.inputDigest,
|
||||||
|
checkId: a.checkId,
|
||||||
|
normalizedOutputDigest: a.normalizedOutputDigest,
|
||||||
|
normalizedMembers: a.normalizedMembers,
|
||||||
|
ruleSetDigest: request.ruleSetDigest,
|
||||||
|
appliedRuleIds,
|
||||||
|
verdict: "pass",
|
||||||
|
workerImageDigests: [a.imageDigest, b.imageDigest],
|
||||||
|
};
|
||||||
|
await writeFile("/output/result.json", JSON.stringify(result), {
|
||||||
|
flag: "wx",
|
||||||
|
mode: 0o400,
|
||||||
|
});
|
||||||
8
containers/privacy-worker-a/Dockerfile
Normal file
8
containers/privacy-worker-a/Dockerfile
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
ARG BASE_IMAGE
|
||||||
|
FROM ${BASE_IMAGE}
|
||||||
|
LABEL org.opencontainers.image.title="portfolio-privacy-worker-a" \
|
||||||
|
org.opencontainers.image.description="Independent byte-oriented Node privacy scanner"
|
||||||
|
WORKDIR /opt/scanner
|
||||||
|
COPY containers/privacy-worker-a/worker.mjs /opt/scanner/worker.mjs
|
||||||
|
USER 65532:65532
|
||||||
|
ENTRYPOINT ["node", "/opt/scanner/worker.mjs"]
|
||||||
33
containers/privacy-worker-a/worker.mjs
Normal file
33
containers/privacy-worker-a/worker.mjs
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import { createHash } from "node:crypto";
|
||||||
|
import { readFile, writeFile } from "node:fs/promises";
|
||||||
|
|
||||||
|
const request = JSON.parse(await readFile("/input/request.json", "utf8"));
|
||||||
|
if (
|
||||||
|
request.schema !== "portfolio-privacy-worker-request/v1" ||
|
||||||
|
request.workerId !== "worker-a" ||
|
||||||
|
!/^[a-f0-9]{64}$/u.test(request.inputDigest)
|
||||||
|
)
|
||||||
|
throw new Error("Invalid worker request.");
|
||||||
|
const bytes = await readFile("/input/object");
|
||||||
|
const actual = createHash("sha256").update(bytes).digest("hex");
|
||||||
|
if (actual !== request.inputDigest) throw new Error("Input digest mismatch.");
|
||||||
|
const normalizedMembers = [`bytes:${actual}`];
|
||||||
|
const normalizedOutputDigest = createHash("sha256")
|
||||||
|
.update(Buffer.from(JSON.stringify(normalizedMembers)))
|
||||||
|
.digest("hex");
|
||||||
|
const output = {
|
||||||
|
schema: "portfolio-privacy-worker-report/v1",
|
||||||
|
workerId: "worker-a",
|
||||||
|
imageDigest: request.imageDigest,
|
||||||
|
toolchain: "bytes-js/v1",
|
||||||
|
inputDigest: actual,
|
||||||
|
checkId: request.checkId,
|
||||||
|
normalizedOutputDigest,
|
||||||
|
normalizedMembers,
|
||||||
|
verdict: "pass",
|
||||||
|
findingsDigest: "0".repeat(64),
|
||||||
|
};
|
||||||
|
await writeFile("/output/report.json", JSON.stringify(output), {
|
||||||
|
flag: "wx",
|
||||||
|
mode: 0o400,
|
||||||
|
});
|
||||||
8
containers/privacy-worker-b/Dockerfile
Normal file
8
containers/privacy-worker-b/Dockerfile
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
ARG BASE_IMAGE
|
||||||
|
FROM ${BASE_IMAGE}
|
||||||
|
LABEL org.opencontainers.image.title="portfolio-privacy-worker-b" \
|
||||||
|
org.opencontainers.image.description="Independent byte-oriented Python privacy scanner"
|
||||||
|
WORKDIR /opt/scanner
|
||||||
|
COPY containers/privacy-worker-b/worker.py /opt/scanner/worker.py
|
||||||
|
USER 65532:65532
|
||||||
|
ENTRYPOINT ["python3", "/opt/scanner/worker.py"]
|
||||||
20
containers/privacy-worker-b/worker.py
Normal file
20
containers/privacy-worker-b/worker.py
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
|
||||||
|
with open('/input/request.json', 'r', encoding='utf-8') as handle:
|
||||||
|
request = json.load(handle)
|
||||||
|
if request.get('schema') != 'portfolio-privacy-worker-request/v1' or request.get('workerId') != 'worker-b':
|
||||||
|
raise ValueError('Invalid worker request.')
|
||||||
|
with open('/input/object', 'rb') as handle:
|
||||||
|
payload = handle.read()
|
||||||
|
actual = hashlib.sha256(payload).hexdigest()
|
||||||
|
if actual != request.get('inputDigest'):
|
||||||
|
raise ValueError('Input digest mismatch.')
|
||||||
|
members = ['bytes:' + actual]
|
||||||
|
normalized = json.dumps(members, separators=(',', ':')).encode('utf-8')
|
||||||
|
output = {'schema': 'portfolio-privacy-worker-report/v1', 'workerId': 'worker-b', 'imageDigest': request['imageDigest'],
|
||||||
|
'toolchain': 'bytes-python/v1', 'inputDigest': actual, 'checkId': request['checkId'],
|
||||||
|
'normalizedOutputDigest': hashlib.sha256(normalized).hexdigest(), 'normalizedMembers': members,
|
||||||
|
'verdict': 'pass', 'findingsDigest': '0' * 64}
|
||||||
|
with open('/output/report.json', 'x', encoding='utf-8') as handle:
|
||||||
|
json.dump(output, handle, separators=(',', ':'))
|
||||||
136
scripts/release/enumerate-closure.mjs
Normal file
136
scripts/release/enumerate-closure.mjs
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
import { createHash } from "node:crypto";
|
||||||
|
import { lstat, readFile, readdir, realpath } from "node:fs/promises";
|
||||||
|
import { relative, resolve, sep } from "node:path";
|
||||||
|
import { pathToFileURL } from "node:url";
|
||||||
|
|
||||||
|
import {
|
||||||
|
canonicalJsonDigest,
|
||||||
|
canonicalValueBytes,
|
||||||
|
} from "../../src/security/canonical-digest.ts";
|
||||||
|
|
||||||
|
const validClass =
|
||||||
|
/^(?:git-object|git-ref|lfs|source|build|precompressed|release-archive)$/u;
|
||||||
|
const byteCompare = (left, right) =>
|
||||||
|
Buffer.from(left).compare(Buffer.from(right));
|
||||||
|
|
||||||
|
function normalizedRelativePath(root, path) {
|
||||||
|
const value = relative(root, path).split(sep).join("/");
|
||||||
|
if (
|
||||||
|
value === "" ||
|
||||||
|
value.startsWith("../") ||
|
||||||
|
value.startsWith("/") ||
|
||||||
|
value.includes("/../") ||
|
||||||
|
value !== value.normalize("NFC")
|
||||||
|
) {
|
||||||
|
throw new Error(
|
||||||
|
"Closure member path is outside the root or non-canonical.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function walk(root, current, members, memberClass) {
|
||||||
|
const entries = await readdir(current, { withFileTypes: true });
|
||||||
|
entries.sort((a, b) => byteCompare(a.name, b.name));
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (
|
||||||
|
entry.name !== entry.name.normalize("NFC") ||
|
||||||
|
entry.name === "." ||
|
||||||
|
entry.name === ".."
|
||||||
|
)
|
||||||
|
throw new Error("Closure path is non-canonical.");
|
||||||
|
const path = resolve(current, entry.name);
|
||||||
|
const info = await lstat(path);
|
||||||
|
if (info.isSymbolicLink())
|
||||||
|
throw new Error("Closure cannot contain a symlink.");
|
||||||
|
if (info.isDirectory()) {
|
||||||
|
await walk(root, path, members, memberClass);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!info.isFile())
|
||||||
|
throw new Error(
|
||||||
|
"Closure cannot contain device, socket, FIFO, or other special entries.",
|
||||||
|
);
|
||||||
|
if (info.nlink !== 1)
|
||||||
|
throw new Error("Closure cannot contain hard-link aliases.");
|
||||||
|
const bytes = await readFile(path);
|
||||||
|
members.push({
|
||||||
|
class: memberClass,
|
||||||
|
path: normalizedRelativePath(root, path),
|
||||||
|
bytes: bytes.byteLength,
|
||||||
|
sha256: createHash("sha256").update(bytes).digest("hex"),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function enumerateClosure(inputs) {
|
||||||
|
if (!Array.isArray(inputs) || inputs.length === 0)
|
||||||
|
throw new Error("At least one explicit closure root is required.");
|
||||||
|
const members = [];
|
||||||
|
const roots = new Set();
|
||||||
|
for (const input of inputs) {
|
||||||
|
if (
|
||||||
|
input === null ||
|
||||||
|
typeof input !== "object" ||
|
||||||
|
!validClass.test(input.class) ||
|
||||||
|
typeof input.root !== "string"
|
||||||
|
) {
|
||||||
|
throw new Error("Closure root descriptor is invalid.");
|
||||||
|
}
|
||||||
|
const root = await realpath(input.root);
|
||||||
|
if (roots.has(`${input.class}\0${root}`))
|
||||||
|
throw new Error("Duplicate closure root.");
|
||||||
|
roots.add(`${input.class}\0${root}`);
|
||||||
|
const info = await lstat(root);
|
||||||
|
if (!info.isDirectory() || info.isSymbolicLink())
|
||||||
|
throw new Error("Closure root must be a real directory.");
|
||||||
|
await walk(root, root, members, input.class);
|
||||||
|
}
|
||||||
|
members.sort((a, b) =>
|
||||||
|
byteCompare(`${a.class}\0${a.path}`, `${b.class}\0${b.path}`),
|
||||||
|
);
|
||||||
|
const identities = new Set();
|
||||||
|
const folded = new Set();
|
||||||
|
for (const member of members) {
|
||||||
|
const identity = `${member.class}\0${member.path}`;
|
||||||
|
const fold = identity.toLocaleLowerCase("en-US");
|
||||||
|
if (identities.has(identity) || folded.has(fold))
|
||||||
|
throw new Error("Closure contains a duplicate or case-fold collision.");
|
||||||
|
identities.add(identity);
|
||||||
|
folded.add(fold);
|
||||||
|
}
|
||||||
|
const value = { schema: "portfolio-publication-closure/v1", members };
|
||||||
|
return {
|
||||||
|
...value,
|
||||||
|
digest: canonicalJsonDigest("portfolio-publication-closure/v1", value),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const descriptors = process.argv.slice(2).map((argument) => {
|
||||||
|
const separator = argument.indexOf("=");
|
||||||
|
if (separator < 1)
|
||||||
|
throw new Error("Usage: enumerate-closure.mjs <class>=<directory> [...]");
|
||||||
|
return {
|
||||||
|
class: argument.slice(0, separator),
|
||||||
|
root: argument.slice(separator + 1),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
process.stdout.write(
|
||||||
|
Buffer.from(canonicalValueBytes(await enumerateClosure(descriptors))),
|
||||||
|
);
|
||||||
|
process.stdout.write("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
process.argv[1] &&
|
||||||
|
import.meta.url === pathToFileURL(process.argv[1]).href
|
||||||
|
) {
|
||||||
|
main().catch((error) => {
|
||||||
|
process.stderr.write(
|
||||||
|
`Closure enumeration failed: ${error instanceof Error ? error.message : "unknown error"}\n`,
|
||||||
|
);
|
||||||
|
process.exitCode = 1;
|
||||||
|
});
|
||||||
|
}
|
||||||
282
scripts/release/reconcile-scan.mjs
Normal file
282
scripts/release/reconcile-scan.mjs
Normal file
@@ -0,0 +1,282 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
import { mkdir, open, readFile } from "node:fs/promises";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { pathToFileURL } from "node:url";
|
||||||
|
|
||||||
|
import {
|
||||||
|
canonicalJsonDigest,
|
||||||
|
canonicalValueBytes,
|
||||||
|
isSha256Hex,
|
||||||
|
parseCanonicalJson,
|
||||||
|
} from "../../src/security/canonical-digest.ts";
|
||||||
|
|
||||||
|
const workerFields = [
|
||||||
|
"schema",
|
||||||
|
"workerId",
|
||||||
|
"imageDigest",
|
||||||
|
"toolchain",
|
||||||
|
"inputDigest",
|
||||||
|
"checkId",
|
||||||
|
"normalizedOutputDigest",
|
||||||
|
"normalizedMembers",
|
||||||
|
"verdict",
|
||||||
|
"findingsDigest",
|
||||||
|
];
|
||||||
|
const matcherFields = [
|
||||||
|
"schema",
|
||||||
|
"inputDigest",
|
||||||
|
"checkId",
|
||||||
|
"normalizedOutputDigest",
|
||||||
|
"normalizedMembers",
|
||||||
|
"ruleSetDigest",
|
||||||
|
"appliedRuleIds",
|
||||||
|
"verdict",
|
||||||
|
"workerImageDigests",
|
||||||
|
];
|
||||||
|
const safeToken = /^[A-Za-z0-9][A-Za-z0-9._/-]{0,127}$/u;
|
||||||
|
|
||||||
|
function object(value, label) {
|
||||||
|
if (value === null || typeof value !== "object" || Array.isArray(value))
|
||||||
|
throw new Error(`${label} must be an object.`);
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
function exact(value, fields, label) {
|
||||||
|
if (
|
||||||
|
JSON.stringify(Object.keys(value).sort()) !==
|
||||||
|
JSON.stringify([...fields].sort())
|
||||||
|
)
|
||||||
|
throw new Error(`${label} contains missing or extra fields.`);
|
||||||
|
}
|
||||||
|
function stringsEqual(left, right) {
|
||||||
|
return JSON.stringify(left) === JSON.stringify(right);
|
||||||
|
}
|
||||||
|
function validateMembers(value) {
|
||||||
|
if (
|
||||||
|
!Array.isArray(value) ||
|
||||||
|
value.some(
|
||||||
|
(member) =>
|
||||||
|
typeof member !== "string" ||
|
||||||
|
member !== member.normalize("NFC") ||
|
||||||
|
!/^[a-z][a-z0-9-]*:[a-f0-9]{64}$/u.test(member),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
throw new Error("Normalized members must contain typed digests only.");
|
||||||
|
const sorted = [...value].sort((a, b) =>
|
||||||
|
Buffer.from(a).compare(Buffer.from(b)),
|
||||||
|
);
|
||||||
|
if (!stringsEqual(value, sorted) || new Set(value).size !== value.length)
|
||||||
|
throw new Error("Normalized members must be unique and byte sorted.");
|
||||||
|
}
|
||||||
|
function validateWorker(value, expectedWorker) {
|
||||||
|
const report = object(value, "worker report");
|
||||||
|
exact(report, workerFields, "worker report");
|
||||||
|
if (
|
||||||
|
report.schema !== "portfolio-privacy-worker-report/v1" ||
|
||||||
|
report.workerId !== expectedWorker ||
|
||||||
|
!isSha256Hex(report.imageDigest) ||
|
||||||
|
!safeToken.test(report.toolchain) ||
|
||||||
|
!isSha256Hex(report.inputDigest) ||
|
||||||
|
!safeToken.test(report.checkId) ||
|
||||||
|
!isSha256Hex(report.normalizedOutputDigest) ||
|
||||||
|
!isSha256Hex(report.findingsDigest) ||
|
||||||
|
!["pass", "fail", "unsupported"].includes(report.verdict)
|
||||||
|
)
|
||||||
|
throw new Error("Worker report is invalid.");
|
||||||
|
validateMembers(report.normalizedMembers);
|
||||||
|
return report;
|
||||||
|
}
|
||||||
|
function validateMatcher(value) {
|
||||||
|
const result = object(value, "matcher result");
|
||||||
|
exact(result, matcherFields, "matcher result");
|
||||||
|
if (
|
||||||
|
result.schema !== "portfolio-privacy-matcher-result/v1" ||
|
||||||
|
!isSha256Hex(result.inputDigest) ||
|
||||||
|
!safeToken.test(result.checkId) ||
|
||||||
|
!isSha256Hex(result.normalizedOutputDigest) ||
|
||||||
|
!isSha256Hex(result.ruleSetDigest) ||
|
||||||
|
!["pass", "fail"].includes(result.verdict) ||
|
||||||
|
!Array.isArray(result.workerImageDigests) ||
|
||||||
|
result.workerImageDigests.length !== 2 ||
|
||||||
|
result.workerImageDigests.some((v) => !isSha256Hex(v))
|
||||||
|
)
|
||||||
|
throw new Error("Matcher result is invalid.");
|
||||||
|
validateMembers(result.normalizedMembers);
|
||||||
|
if (
|
||||||
|
!Array.isArray(result.appliedRuleIds) ||
|
||||||
|
result.appliedRuleIds.length === 0 ||
|
||||||
|
result.appliedRuleIds.some((value) => !safeToken.test(value)) ||
|
||||||
|
new Set(result.appliedRuleIds).size !== result.appliedRuleIds.length ||
|
||||||
|
!stringsEqual(
|
||||||
|
result.appliedRuleIds,
|
||||||
|
[...result.appliedRuleIds].sort((a, b) =>
|
||||||
|
Buffer.from(a).compare(Buffer.from(b)),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
throw new Error("Matcher rule IDs must be unique and byte sorted.");
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
async function writeOnce(root, digest, bytes) {
|
||||||
|
await mkdir(root, { recursive: true });
|
||||||
|
const path = join(root, digest);
|
||||||
|
try {
|
||||||
|
const handle = await open(path, "wx", 0o400);
|
||||||
|
try {
|
||||||
|
await handle.writeFile(bytes);
|
||||||
|
await handle.sync();
|
||||||
|
} finally {
|
||||||
|
await handle.close();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (error && error.code === "EEXIST") {
|
||||||
|
const existing = await readFile(path);
|
||||||
|
if (Buffer.compare(existing, Buffer.from(bytes)) !== 0)
|
||||||
|
throw new Error(
|
||||||
|
"Content-addressed object is immutable and differs from existing bytes.",
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function reconcileScan({
|
||||||
|
expectedChecks,
|
||||||
|
reports,
|
||||||
|
matcherResults,
|
||||||
|
candidate,
|
||||||
|
}) {
|
||||||
|
if (
|
||||||
|
!Array.isArray(expectedChecks) ||
|
||||||
|
!Array.isArray(reports) ||
|
||||||
|
!Array.isArray(matcherResults) ||
|
||||||
|
expectedChecks.length === 0
|
||||||
|
)
|
||||||
|
throw new Error("Reconciliation input is invalid.");
|
||||||
|
const ordered = [...expectedChecks].sort((a, b) =>
|
||||||
|
Buffer.from(`${a.inputDigest}\0${a.checkId}`).compare(
|
||||||
|
Buffer.from(`${b.inputDigest}\0${b.checkId}`),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
!stringsEqual(expectedChecks, ordered) ||
|
||||||
|
new Set(expectedChecks.map((v) => `${v.inputDigest}\0${v.checkId}`))
|
||||||
|
.size !== expectedChecks.length
|
||||||
|
)
|
||||||
|
throw new Error("Expected check closure must be unique and byte sorted.");
|
||||||
|
if (
|
||||||
|
reports.length !== expectedChecks.length * 2 ||
|
||||||
|
matcherResults.length !== expectedChecks.length
|
||||||
|
)
|
||||||
|
throw new Error("Missing or extra privacy evidence.");
|
||||||
|
const checks = [];
|
||||||
|
expectedChecks.forEach((expected, index) => {
|
||||||
|
if (!isSha256Hex(expected.inputDigest) || !safeToken.test(expected.checkId))
|
||||||
|
throw new Error("Expected check is invalid.");
|
||||||
|
const a = validateWorker(reports[index * 2], "worker-a");
|
||||||
|
const b = validateWorker(reports[index * 2 + 1], "worker-b");
|
||||||
|
const match = validateMatcher(matcherResults[index]);
|
||||||
|
for (const item of [a, b, match])
|
||||||
|
if (
|
||||||
|
item.inputDigest !== expected.inputDigest ||
|
||||||
|
item.checkId !== expected.checkId
|
||||||
|
)
|
||||||
|
throw new Error(
|
||||||
|
"Privacy evidence is missing, reordered, or bound to another check.",
|
||||||
|
);
|
||||||
|
if (a.imageDigest === b.imageDigest || a.toolchain === b.toolchain)
|
||||||
|
throw new Error("Privacy workers are not heterogeneous.");
|
||||||
|
if (
|
||||||
|
a.verdict !== "pass" ||
|
||||||
|
b.verdict !== "pass" ||
|
||||||
|
match.verdict !== "pass"
|
||||||
|
)
|
||||||
|
throw new Error("Every worker and matcher must independently pass.");
|
||||||
|
if (
|
||||||
|
a.normalizedOutputDigest !== b.normalizedOutputDigest ||
|
||||||
|
a.normalizedOutputDigest !== match.normalizedOutputDigest ||
|
||||||
|
!stringsEqual(a.normalizedMembers, b.normalizedMembers) ||
|
||||||
|
!stringsEqual(a.normalizedMembers, match.normalizedMembers) ||
|
||||||
|
!stringsEqual(match.workerImageDigests, [a.imageDigest, b.imageDigest])
|
||||||
|
)
|
||||||
|
throw new Error("Independent normalized evidence disagrees.");
|
||||||
|
checks.push({
|
||||||
|
inputDigest: expected.inputDigest,
|
||||||
|
checkId: expected.checkId,
|
||||||
|
normalizedOutputDigest: a.normalizedOutputDigest,
|
||||||
|
normalizedMembers: a.normalizedMembers,
|
||||||
|
workerReports: [
|
||||||
|
canonicalJsonDigest("portfolio-privacy-worker-report/v1", a),
|
||||||
|
canonicalJsonDigest("portfolio-privacy-worker-report/v1", b),
|
||||||
|
],
|
||||||
|
matcherResult: canonicalJsonDigest(
|
||||||
|
"portfolio-privacy-matcher-result/v1",
|
||||||
|
match,
|
||||||
|
),
|
||||||
|
ruleSetDigest: match.ruleSetDigest,
|
||||||
|
appliedRuleIds: match.appliedRuleIds,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
const evidence = {
|
||||||
|
schema: "portfolio-scan-evidence/v1",
|
||||||
|
coverageCount: checks.length,
|
||||||
|
passedCount: checks.length,
|
||||||
|
checks,
|
||||||
|
};
|
||||||
|
const evidenceBytes = canonicalValueBytes(evidence);
|
||||||
|
const evidenceDigest = canonicalJsonDigest(
|
||||||
|
"portfolio-scan-evidence/v1",
|
||||||
|
evidence,
|
||||||
|
);
|
||||||
|
const candidateRecord = object(candidate, "candidate");
|
||||||
|
if (candidateRecord.schema !== "portfolio-candidate-release-manifest/v1")
|
||||||
|
throw new Error("Candidate schema is invalid.");
|
||||||
|
const candidateBytes = canonicalValueBytes(candidateRecord);
|
||||||
|
const candidateDigest = canonicalJsonDigest(
|
||||||
|
"portfolio-candidate-release-manifest/v1",
|
||||||
|
candidateRecord,
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
evidence,
|
||||||
|
evidenceBytes,
|
||||||
|
evidenceDigest,
|
||||||
|
candidate: candidateRecord,
|
||||||
|
candidateBytes,
|
||||||
|
candidateDigest,
|
||||||
|
async writeContentAddressed(root) {
|
||||||
|
await writeOnce(root, evidenceDigest, evidenceBytes);
|
||||||
|
await writeOnce(root, candidateDigest, candidateBytes);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
if (process.argv.length !== 4 || process.argv[2] !== "--request")
|
||||||
|
throw new Error(
|
||||||
|
"Usage: reconcile-scan.mjs --request <canonical-request.json>",
|
||||||
|
);
|
||||||
|
const request = parseCanonicalJson(await readFile(process.argv[3]));
|
||||||
|
const result = reconcileScan(request);
|
||||||
|
process.stdout.write(
|
||||||
|
Buffer.from(
|
||||||
|
canonicalValueBytes({
|
||||||
|
schema: "portfolio-reconciliation-result/v1",
|
||||||
|
evidenceDigest: result.evidenceDigest,
|
||||||
|
candidateDigest: result.candidateDigest,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
process.stdout.write("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
process.argv[1] &&
|
||||||
|
import.meta.url === pathToFileURL(process.argv[1]).href
|
||||||
|
) {
|
||||||
|
main().catch((error) => {
|
||||||
|
process.stderr.write(
|
||||||
|
`Privacy reconciliation failed: ${error instanceof Error ? error.message : "unknown error"}\n`,
|
||||||
|
);
|
||||||
|
process.exitCode = 1;
|
||||||
|
});
|
||||||
|
}
|
||||||
115
scripts/release/run-privacy-workers.mjs
Normal file
115
scripts/release/run-privacy-workers.mjs
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
import { pathToFileURL } from "node:url";
|
||||||
|
|
||||||
|
const safeImage = /^[A-Za-z0-9._/-]+@sha256:[a-f0-9]{64}$/u;
|
||||||
|
const safePath = /^\/[A-Za-z0-9._/-]+$/u;
|
||||||
|
|
||||||
|
export function buildWorkerCommand({
|
||||||
|
runtime,
|
||||||
|
image,
|
||||||
|
inputPath,
|
||||||
|
outputPath,
|
||||||
|
worker,
|
||||||
|
}) {
|
||||||
|
if (runtime !== "docker" && runtime !== "podman")
|
||||||
|
throw new Error("Only Docker or Podman isolation runtimes are supported.");
|
||||||
|
if (!safeImage.test(image))
|
||||||
|
throw new Error(
|
||||||
|
"Worker image must be pinned by a lowercase SHA-256 digest.",
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
!safePath.test(inputPath) ||
|
||||||
|
!safePath.test(outputPath) ||
|
||||||
|
inputPath.includes("..") ||
|
||||||
|
outputPath.includes("..")
|
||||||
|
)
|
||||||
|
throw new Error("Worker mounts must be explicit absolute safe paths.");
|
||||||
|
if (
|
||||||
|
worker !== "worker-a" &&
|
||||||
|
worker !== "worker-b" &&
|
||||||
|
worker !== "matcher" &&
|
||||||
|
worker !== "coordinator"
|
||||||
|
)
|
||||||
|
throw new Error("Unknown isolated workload.");
|
||||||
|
return [
|
||||||
|
runtime,
|
||||||
|
"run",
|
||||||
|
"--rm",
|
||||||
|
"--pull=never",
|
||||||
|
"--network=none",
|
||||||
|
"--read-only",
|
||||||
|
"--cap-drop=ALL",
|
||||||
|
"--security-opt=no-new-privileges",
|
||||||
|
"--user=65532:65532",
|
||||||
|
"--memory=256m",
|
||||||
|
"--memory-swap=256m",
|
||||||
|
"--cpus=1",
|
||||||
|
"--pids-limit=64",
|
||||||
|
"--tmpfs=/tmp:rw,noexec,nosuid,nodev,size=64m",
|
||||||
|
`--mount=type=bind,src=${inputPath},dst=/input,readonly`,
|
||||||
|
`--mount=type=bind,src=${outputPath},dst=/output`,
|
||||||
|
image,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateScanObjectDescriptor(value) {
|
||||||
|
if (
|
||||||
|
value === null ||
|
||||||
|
typeof value !== "object" ||
|
||||||
|
!/^[a-f0-9]{64}$/u.test(value.inputDigest) ||
|
||||||
|
!/^[A-Za-z0-9][A-Za-z0-9._/-]{0,127}$/u.test(value.checkId) ||
|
||||||
|
typeof value.mediaType !== "string"
|
||||||
|
)
|
||||||
|
throw new Error("Scan object descriptor is invalid.");
|
||||||
|
if (value.encrypted === true)
|
||||||
|
throw new Error("Encrypted containers are unsupported and fail closed.");
|
||||||
|
if (value.malformed === true)
|
||||||
|
throw new Error("Malformed containers fail closed.");
|
||||||
|
if (
|
||||||
|
!Number.isSafeInteger(value.archiveDepth) ||
|
||||||
|
value.archiveDepth < 0 ||
|
||||||
|
value.archiveDepth > 3
|
||||||
|
)
|
||||||
|
throw new Error("Nested archive depth exceeds the limit.");
|
||||||
|
if (
|
||||||
|
!Number.isSafeInteger(value.expandedBytes) ||
|
||||||
|
value.expandedBytes < 0 ||
|
||||||
|
value.expandedBytes > 64 * 1024 * 1024
|
||||||
|
)
|
||||||
|
throw new Error("Expanded archive size exceeds the limit.");
|
||||||
|
if (!Array.isArray(value.members))
|
||||||
|
throw new Error("Container member inventory is required.");
|
||||||
|
for (const member of value.members) {
|
||||||
|
if (
|
||||||
|
member === null ||
|
||||||
|
typeof member !== "object" ||
|
||||||
|
typeof member.path !== "string" ||
|
||||||
|
member.path.startsWith("/") ||
|
||||||
|
member.path.includes("\\") ||
|
||||||
|
member.path.split("/").some((part) => part === ".." || part === ".") ||
|
||||||
|
member.path !== member.path.normalize("NFC") ||
|
||||||
|
!["file", "directory"].includes(member.entryType)
|
||||||
|
) {
|
||||||
|
throw new Error(
|
||||||
|
"Archive traversal, symlink, hard-link, device, or non-canonical member rejected.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function liveContainerStatus() {
|
||||||
|
return {
|
||||||
|
schema: "portfolio-live-container-validation/v1",
|
||||||
|
status: "pending",
|
||||||
|
reason:
|
||||||
|
"Requires locally cached, provenance-verified pinned images; network pulls are prohibited.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
process.argv[1] &&
|
||||||
|
import.meta.url === pathToFileURL(process.argv[1]).href
|
||||||
|
) {
|
||||||
|
process.stdout.write(`${JSON.stringify(liveContainerStatus())}\n`);
|
||||||
|
}
|
||||||
700
scripts/release/sign-release.mjs
Normal file
700
scripts/release/sign-release.mjs
Normal file
@@ -0,0 +1,700 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
import { createPrivateKey, createPublicKey, sign, verify } from "node:crypto";
|
||||||
|
import { readFile, realpath, stat as fileStat } from "node:fs/promises";
|
||||||
|
import { isAbsolute, relative, resolve } from "node:path";
|
||||||
|
import { pathToFileURL } from "node:url";
|
||||||
|
|
||||||
|
import {
|
||||||
|
canonicalJsonDigest,
|
||||||
|
canonicalValueBytes,
|
||||||
|
isSha256Hex,
|
||||||
|
parseCanonicalJson,
|
||||||
|
} from "../../src/security/canonical-digest.ts";
|
||||||
|
import { validateReleaseManifest } from "../../src/security/release-manifest.ts";
|
||||||
|
|
||||||
|
const rootContract = {
|
||||||
|
approval: {
|
||||||
|
scope: "portfolio-approval-decision/v1",
|
||||||
|
recovery: ["freeze-publication", "audit-checkpoint", "fresh-approvals"],
|
||||||
|
},
|
||||||
|
projection: {
|
||||||
|
scope: "portfolio-approval-projection/v1",
|
||||||
|
recovery: ["freeze-publication", "revoke-epoch", "fresh-evidence"],
|
||||||
|
},
|
||||||
|
privacyWorkload: {
|
||||||
|
scope: "portfolio-privacy-workload/v1",
|
||||||
|
recovery: ["freeze-publication", "revoke-epoch", "fresh-evidence"],
|
||||||
|
},
|
||||||
|
policy: {
|
||||||
|
scope: "portfolio-policy-attestation/v1",
|
||||||
|
recovery: ["freeze-publication", "revoke-epoch", "fresh-evidence"],
|
||||||
|
},
|
||||||
|
toolImage: {
|
||||||
|
scope: "portfolio-tool-image-provenance/v1",
|
||||||
|
recovery: ["freeze-publication", "revoke-epoch", "fresh-evidence"],
|
||||||
|
},
|
||||||
|
release: {
|
||||||
|
scope: "portfolio-release-signing/v1",
|
||||||
|
recovery: ["freeze-publication", "revoke-epoch", "fresh-evidence"],
|
||||||
|
},
|
||||||
|
publisher: {
|
||||||
|
scope: "portfolio-publisher-fencing/v1",
|
||||||
|
recovery: [
|
||||||
|
"freeze-publication",
|
||||||
|
"rotate-credential",
|
||||||
|
"advance-fence",
|
||||||
|
"reconcile-staging",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const rootFields = [
|
||||||
|
"keyId",
|
||||||
|
"issuerEpoch",
|
||||||
|
"scope",
|
||||||
|
"publicKeyPem",
|
||||||
|
"revokedEpochs",
|
||||||
|
"compromiseResponse",
|
||||||
|
];
|
||||||
|
const policyFields = ["schema", "policyVersion", "publicationFrozen", "roots"];
|
||||||
|
const candidateFields = [
|
||||||
|
"schema",
|
||||||
|
"releaseManifest",
|
||||||
|
"latestLedgerHeadHash",
|
||||||
|
"latestLedgerRevision",
|
||||||
|
"sourceClosureDigest",
|
||||||
|
"publicRefMap",
|
||||||
|
"lfsOids",
|
||||||
|
"releaseArchives",
|
||||||
|
"buildProvenanceDigest",
|
||||||
|
"privacyPolicyDigest",
|
||||||
|
"toolImageDigests",
|
||||||
|
];
|
||||||
|
const signatureFields = [
|
||||||
|
"algorithm",
|
||||||
|
"encoding",
|
||||||
|
"keyId",
|
||||||
|
"issuerEpoch",
|
||||||
|
"scope",
|
||||||
|
"value",
|
||||||
|
];
|
||||||
|
const attestationPayloadFields = {
|
||||||
|
projection: [
|
||||||
|
"schema",
|
||||||
|
"keyId",
|
||||||
|
"issuerEpoch",
|
||||||
|
"scope",
|
||||||
|
"issuedAt",
|
||||||
|
"expiresAt",
|
||||||
|
"digest",
|
||||||
|
"productionInputRoot",
|
||||||
|
"approvedRecordRoot",
|
||||||
|
"approvedAssetRoot",
|
||||||
|
"approvalProofSetDigest",
|
||||||
|
"latestLedgerHeadHash",
|
||||||
|
"latestLedgerRevision",
|
||||||
|
],
|
||||||
|
privacyWorkload: [
|
||||||
|
"schema",
|
||||||
|
"keyId",
|
||||||
|
"issuerEpoch",
|
||||||
|
"scope",
|
||||||
|
"issuedAt",
|
||||||
|
"expiresAt",
|
||||||
|
"digest",
|
||||||
|
"candidateDigest",
|
||||||
|
"evidenceDigest",
|
||||||
|
],
|
||||||
|
policy: [
|
||||||
|
"schema",
|
||||||
|
"keyId",
|
||||||
|
"issuerEpoch",
|
||||||
|
"scope",
|
||||||
|
"issuedAt",
|
||||||
|
"expiresAt",
|
||||||
|
"digest",
|
||||||
|
"policyVersion",
|
||||||
|
],
|
||||||
|
toolImage: [
|
||||||
|
"schema",
|
||||||
|
"keyId",
|
||||||
|
"issuerEpoch",
|
||||||
|
"scope",
|
||||||
|
"issuedAt",
|
||||||
|
"expiresAt",
|
||||||
|
"digest",
|
||||||
|
"imageDigests",
|
||||||
|
],
|
||||||
|
};
|
||||||
|
const signedFields = ["schema", "payload", "payloadDigest", "signature"];
|
||||||
|
const timestamp = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,3})?Z$/u;
|
||||||
|
const safeToken = /^[A-Za-z0-9][A-Za-z0-9._/-]{0,127}$/u;
|
||||||
|
|
||||||
|
function object(value, label) {
|
||||||
|
if (value === null || typeof value !== "object" || Array.isArray(value))
|
||||||
|
throw new Error(`${label} must be an object.`);
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
function exact(value, fields, label) {
|
||||||
|
if (
|
||||||
|
JSON.stringify(Object.keys(value).sort()) !==
|
||||||
|
JSON.stringify([...fields].sort())
|
||||||
|
)
|
||||||
|
throw new Error(`${label} contains missing or unknown fields.`);
|
||||||
|
}
|
||||||
|
function digest(value, label) {
|
||||||
|
if (!isSha256Hex(value))
|
||||||
|
throw new Error(`${label} is not a canonical SHA-256 digest.`);
|
||||||
|
}
|
||||||
|
function compare(left, right) {
|
||||||
|
return Buffer.from(left).compare(Buffer.from(right));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateTrustPolicy(value) {
|
||||||
|
const policy = object(value, "trust policy");
|
||||||
|
exact(policy, policyFields, "trust policy");
|
||||||
|
if (
|
||||||
|
policy.schema !== "portfolio-trust-policy/v1" ||
|
||||||
|
typeof policy.policyVersion !== "string" ||
|
||||||
|
!safeToken.test(policy.policyVersion) ||
|
||||||
|
typeof policy.publicationFrozen !== "boolean"
|
||||||
|
)
|
||||||
|
throw new Error("Trust policy metadata is invalid.");
|
||||||
|
const roots = object(policy.roots, "trust roots");
|
||||||
|
exact(roots, Object.keys(rootContract), "trust roots");
|
||||||
|
const seenKeys = new Set();
|
||||||
|
const seenPublicKeys = new Set();
|
||||||
|
for (const [role, contract] of Object.entries(rootContract)) {
|
||||||
|
const root = object(roots[role], `${role} root`);
|
||||||
|
exact(root, rootFields, `${role} root`);
|
||||||
|
if (
|
||||||
|
typeof root.keyId !== "string" ||
|
||||||
|
!safeToken.test(root.keyId) ||
|
||||||
|
seenKeys.has(root.keyId) ||
|
||||||
|
!Number.isSafeInteger(root.issuerEpoch) ||
|
||||||
|
root.issuerEpoch < 1
|
||||||
|
)
|
||||||
|
throw new Error(`${role} key ID or issuer epoch is invalid.`);
|
||||||
|
seenKeys.add(root.keyId);
|
||||||
|
if (root.scope !== contract.scope)
|
||||||
|
throw new Error(`${role} trust-root scope is invalid.`);
|
||||||
|
if (typeof root.publicKeyPem !== "string")
|
||||||
|
throw new Error(`${role} public key is invalid.`);
|
||||||
|
const key = createPublicKey(root.publicKeyPem);
|
||||||
|
if (key.asymmetricKeyType !== "ed25519")
|
||||||
|
throw new Error(`${role} key must be Ed25519.`);
|
||||||
|
const publicFingerprint = key
|
||||||
|
.export({ type: "spki", format: "der" })
|
||||||
|
.toString("base64");
|
||||||
|
if (seenPublicKeys.has(publicFingerprint))
|
||||||
|
throw new Error("Trust roots must use distinct protected keys.");
|
||||||
|
seenPublicKeys.add(publicFingerprint);
|
||||||
|
if (
|
||||||
|
!Array.isArray(root.revokedEpochs) ||
|
||||||
|
root.revokedEpochs.some(
|
||||||
|
(epoch) => !Number.isSafeInteger(epoch) || epoch < 1,
|
||||||
|
) ||
|
||||||
|
new Set(root.revokedEpochs).size !== root.revokedEpochs.length
|
||||||
|
)
|
||||||
|
throw new Error(`${role} revocation list is invalid.`);
|
||||||
|
if (root.revokedEpochs.includes(root.issuerEpoch))
|
||||||
|
throw new Error(`${role} current issuer epoch is revoked.`);
|
||||||
|
if (
|
||||||
|
!Array.isArray(root.compromiseResponse) ||
|
||||||
|
contract.recovery.some(
|
||||||
|
(action) => !root.compromiseResponse.includes(action),
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
const detail =
|
||||||
|
role === "approval"
|
||||||
|
? "checkpoint audit and fresh approvals"
|
||||||
|
: role === "publisher"
|
||||||
|
? "fence advance and staging reconciliation"
|
||||||
|
: "fresh evidence";
|
||||||
|
throw new Error(`${role} compromise response lacks ${detail}.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return policy;
|
||||||
|
}
|
||||||
|
|
||||||
|
function verifyAttestation(value, root, expectedSchema, role, now) {
|
||||||
|
const envelope = object(value, "attestation");
|
||||||
|
exact(envelope, ["payload", "signature"], "attestation");
|
||||||
|
const payload = object(envelope.payload, "attestation payload");
|
||||||
|
exact(payload, attestationPayloadFields[role], "attestation payload");
|
||||||
|
const signature = object(envelope.signature, "attestation signature");
|
||||||
|
exact(signature, ["algorithm", "encoding", "value"], "attestation signature");
|
||||||
|
if (
|
||||||
|
payload.schema !== expectedSchema ||
|
||||||
|
payload.keyId !== root.keyId ||
|
||||||
|
payload.issuerEpoch !== root.issuerEpoch ||
|
||||||
|
payload.scope !== root.scope
|
||||||
|
)
|
||||||
|
throw new Error(
|
||||||
|
"Attestation schema, scope, key, or issuer epoch is not trusted.",
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
typeof payload.issuedAt !== "string" ||
|
||||||
|
typeof payload.expiresAt !== "string" ||
|
||||||
|
!timestamp.test(payload.issuedAt) ||
|
||||||
|
!timestamp.test(payload.expiresAt) ||
|
||||||
|
Date.parse(payload.issuedAt) > Date.parse(now) ||
|
||||||
|
Date.parse(now) >= Date.parse(payload.expiresAt)
|
||||||
|
)
|
||||||
|
throw new Error("Attestation is not current at trusted time.");
|
||||||
|
for (const field of [
|
||||||
|
"digest",
|
||||||
|
"productionInputRoot",
|
||||||
|
"approvedRecordRoot",
|
||||||
|
"approvedAssetRoot",
|
||||||
|
"approvalProofSetDigest",
|
||||||
|
"latestLedgerHeadHash",
|
||||||
|
"candidateDigest",
|
||||||
|
"evidenceDigest",
|
||||||
|
])
|
||||||
|
if (field in payload) digest(payload[field], `attestation ${field}`);
|
||||||
|
if (
|
||||||
|
"latestLedgerRevision" in payload &&
|
||||||
|
(!Number.isSafeInteger(payload.latestLedgerRevision) ||
|
||||||
|
payload.latestLedgerRevision < 0)
|
||||||
|
)
|
||||||
|
throw new Error("Attestation ledger revision is invalid.");
|
||||||
|
if (
|
||||||
|
"imageDigests" in payload &&
|
||||||
|
(!Array.isArray(payload.imageDigests) ||
|
||||||
|
payload.imageDigests.length < 2 ||
|
||||||
|
payload.imageDigests.some((item) => !isSha256Hex(item)))
|
||||||
|
)
|
||||||
|
throw new Error("Attested image digests are invalid.");
|
||||||
|
if (
|
||||||
|
signature.algorithm !== "Ed25519" ||
|
||||||
|
signature.encoding !== "base64url-nopad" ||
|
||||||
|
typeof signature.value !== "string" ||
|
||||||
|
!/^[A-Za-z0-9_-]{86}$/u.test(signature.value)
|
||||||
|
)
|
||||||
|
throw new Error("Attestation signature metadata is invalid.");
|
||||||
|
const bytes = Buffer.from(signature.value, "base64url");
|
||||||
|
if (
|
||||||
|
bytes.toString("base64url") !== signature.value ||
|
||||||
|
!verify(
|
||||||
|
null,
|
||||||
|
canonicalValueBytes(payload),
|
||||||
|
createPublicKey(root.publicKeyPem),
|
||||||
|
bytes,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
throw new Error("Attestation signature is invalid.");
|
||||||
|
return payload;
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateCandidate(value, now) {
|
||||||
|
const candidate = object(value, "candidate");
|
||||||
|
exact(candidate, candidateFields, "candidate");
|
||||||
|
if (candidate.schema !== "portfolio-candidate-release-manifest/v1")
|
||||||
|
throw new Error("Candidate schema is invalid.");
|
||||||
|
digest(candidate.latestLedgerHeadHash, "latestLedgerHeadHash");
|
||||||
|
digest(candidate.sourceClosureDigest, "sourceClosureDigest");
|
||||||
|
digest(candidate.buildProvenanceDigest, "buildProvenanceDigest");
|
||||||
|
digest(candidate.privacyPolicyDigest, "privacyPolicyDigest");
|
||||||
|
if (
|
||||||
|
!Number.isSafeInteger(candidate.latestLedgerRevision) ||
|
||||||
|
candidate.latestLedgerRevision < 0
|
||||||
|
)
|
||||||
|
throw new Error("latestLedgerRevision is invalid.");
|
||||||
|
const refs = object(candidate.publicRefMap, "publicRefMap");
|
||||||
|
for (const [name, hash] of Object.entries(refs))
|
||||||
|
if (!safeToken.test(name) || !/^[a-f0-9]{40}(?:[a-f0-9]{24})?$/u.test(hash))
|
||||||
|
throw new Error("Public ref map is invalid.");
|
||||||
|
if (
|
||||||
|
!Array.isArray(candidate.lfsOids) ||
|
||||||
|
candidate.lfsOids.some((oid) => !/^sha256:[a-f0-9]{64}$/u.test(oid)) ||
|
||||||
|
JSON.stringify(candidate.lfsOids) !==
|
||||||
|
JSON.stringify([...candidate.lfsOids].sort(compare)) ||
|
||||||
|
new Set(candidate.lfsOids).size !== candidate.lfsOids.length
|
||||||
|
)
|
||||||
|
throw new Error("LFS closure is invalid.");
|
||||||
|
if (!Array.isArray(candidate.releaseArchives))
|
||||||
|
throw new Error("Release archive closure is invalid.");
|
||||||
|
for (const archive of candidate.releaseArchives) {
|
||||||
|
const record = object(archive, "release archive");
|
||||||
|
exact(record, ["path", "sha256"], "release archive");
|
||||||
|
if (
|
||||||
|
typeof record.path !== "string" ||
|
||||||
|
record.path.startsWith("/") ||
|
||||||
|
record.path.includes("..")
|
||||||
|
)
|
||||||
|
throw new Error("Release archive path is invalid.");
|
||||||
|
digest(record.sha256, "release archive digest");
|
||||||
|
}
|
||||||
|
const archiveKeys = candidate.releaseArchives.map(
|
||||||
|
(archive) => `${archive.path}\0${archive.sha256}`,
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
new Set(archiveKeys).size !== archiveKeys.length ||
|
||||||
|
JSON.stringify(archiveKeys) !==
|
||||||
|
JSON.stringify([...archiveKeys].sort(compare))
|
||||||
|
)
|
||||||
|
throw new Error("Release archive closure must be unique and byte sorted.");
|
||||||
|
if (
|
||||||
|
!Array.isArray(candidate.toolImageDigests) ||
|
||||||
|
candidate.toolImageDigests.length < 2 ||
|
||||||
|
candidate.toolImageDigests.some((value) => !isSha256Hex(value)) ||
|
||||||
|
new Set(candidate.toolImageDigests).size !==
|
||||||
|
candidate.toolImageDigests.length ||
|
||||||
|
JSON.stringify(candidate.toolImageDigests) !==
|
||||||
|
JSON.stringify([...candidate.toolImageDigests].sort(compare))
|
||||||
|
)
|
||||||
|
throw new Error("Tool image closure must contain unique, sorted digests.");
|
||||||
|
const releaseManifest = validateReleaseManifest(candidate.releaseManifest, {
|
||||||
|
now,
|
||||||
|
});
|
||||||
|
return { candidate, releaseManifest };
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateEvidence(value, expectedDigest) {
|
||||||
|
const evidence = object(value, "scan evidence");
|
||||||
|
exact(
|
||||||
|
evidence,
|
||||||
|
["schema", "coverageCount", "passedCount", "checks"],
|
||||||
|
"scan evidence",
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
evidence.schema !== "portfolio-scan-evidence/v1" ||
|
||||||
|
!Number.isSafeInteger(evidence.coverageCount) ||
|
||||||
|
evidence.coverageCount < 1 ||
|
||||||
|
evidence.passedCount !== evidence.coverageCount ||
|
||||||
|
!Array.isArray(evidence.checks) ||
|
||||||
|
evidence.checks.length !== evidence.coverageCount
|
||||||
|
)
|
||||||
|
throw new Error("Scan evidence coverage is incomplete.");
|
||||||
|
const keys = [];
|
||||||
|
for (const raw of evidence.checks) {
|
||||||
|
const check = object(raw, "scan evidence check");
|
||||||
|
exact(
|
||||||
|
check,
|
||||||
|
[
|
||||||
|
"inputDigest",
|
||||||
|
"checkId",
|
||||||
|
"normalizedOutputDigest",
|
||||||
|
"normalizedMembers",
|
||||||
|
"workerReports",
|
||||||
|
"matcherResult",
|
||||||
|
"ruleSetDigest",
|
||||||
|
"appliedRuleIds",
|
||||||
|
],
|
||||||
|
"scan evidence check",
|
||||||
|
);
|
||||||
|
digest(check.inputDigest, "scan input digest");
|
||||||
|
digest(check.normalizedOutputDigest, "normalized output digest");
|
||||||
|
digest(check.matcherResult, "matcher result digest");
|
||||||
|
digest(check.ruleSetDigest, "rule set digest");
|
||||||
|
if (
|
||||||
|
!safeToken.test(check.checkId) ||
|
||||||
|
!Array.isArray(check.normalizedMembers) ||
|
||||||
|
check.normalizedMembers.some(
|
||||||
|
(item) => !/^[a-z][a-z0-9-]*:[a-f0-9]{64}$/u.test(item),
|
||||||
|
) ||
|
||||||
|
!Array.isArray(check.workerReports) ||
|
||||||
|
check.workerReports.length !== 2 ||
|
||||||
|
check.workerReports.some((item) => !isSha256Hex(item))
|
||||||
|
)
|
||||||
|
throw new Error("Scan evidence check is invalid.");
|
||||||
|
if (
|
||||||
|
!Array.isArray(check.appliedRuleIds) ||
|
||||||
|
check.appliedRuleIds.length === 0 ||
|
||||||
|
check.appliedRuleIds.some((item) => !safeToken.test(item)) ||
|
||||||
|
new Set(check.appliedRuleIds).size !== check.appliedRuleIds.length
|
||||||
|
)
|
||||||
|
throw new Error("Applied matcher rule IDs are invalid.");
|
||||||
|
keys.push(`${check.inputDigest}\0${check.checkId}`);
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
new Set(keys).size !== keys.length ||
|
||||||
|
JSON.stringify(keys) !== JSON.stringify([...keys].sort(compare))
|
||||||
|
)
|
||||||
|
throw new Error("Scan evidence checks must be unique and byte sorted.");
|
||||||
|
if (
|
||||||
|
canonicalJsonDigest("portfolio-scan-evidence/v1", evidence) !==
|
||||||
|
expectedDigest
|
||||||
|
)
|
||||||
|
throw new Error("Scan evidence bytes were substituted.");
|
||||||
|
return evidence;
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateProtectedState(state) {
|
||||||
|
const value = object(state, "protected state");
|
||||||
|
exact(
|
||||||
|
value,
|
||||||
|
[
|
||||||
|
"latestLedgerHeadHash",
|
||||||
|
"latestLedgerRevision",
|
||||||
|
"currentManifestDigest",
|
||||||
|
"publisherFence",
|
||||||
|
],
|
||||||
|
"protected state",
|
||||||
|
);
|
||||||
|
digest(value.latestLedgerHeadHash, "protected ledger head");
|
||||||
|
if (
|
||||||
|
!Number.isSafeInteger(value.latestLedgerRevision) ||
|
||||||
|
value.latestLedgerRevision < 0 ||
|
||||||
|
!Number.isSafeInteger(value.publisherFence) ||
|
||||||
|
value.publisherFence < 0 ||
|
||||||
|
(value.currentManifestDigest !== null &&
|
||||||
|
!isSha256Hex(value.currentManifestDigest))
|
||||||
|
)
|
||||||
|
throw new Error("Protected state is invalid.");
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function verifyCandidateAuthority({
|
||||||
|
candidate,
|
||||||
|
evidence,
|
||||||
|
attestations,
|
||||||
|
trustPolicy,
|
||||||
|
now,
|
||||||
|
protectedState,
|
||||||
|
}) {
|
||||||
|
if (trustPolicy.publicationFrozen)
|
||||||
|
throw new Error("Publication is frozen by trust policy.");
|
||||||
|
if (typeof now !== "string" || !timestamp.test(now))
|
||||||
|
throw new Error("Trusted current time is invalid.");
|
||||||
|
const { candidate: record, releaseManifest } = validateCandidate(
|
||||||
|
candidate,
|
||||||
|
now,
|
||||||
|
);
|
||||||
|
const state = validateProtectedState(protectedState);
|
||||||
|
if (
|
||||||
|
record.latestLedgerHeadHash !== state.latestLedgerHeadHash ||
|
||||||
|
record.latestLedgerRevision !== state.latestLedgerRevision
|
||||||
|
)
|
||||||
|
throw new Error("Candidate is stale relative to the protected ledger.");
|
||||||
|
if (
|
||||||
|
releaseManifest.currentManifestDigest !== state.currentManifestDigest ||
|
||||||
|
releaseManifest.predecessorManifestDigest !== state.currentManifestDigest
|
||||||
|
)
|
||||||
|
throw new Error(
|
||||||
|
"Candidate predecessor does not equal protected current manifest.",
|
||||||
|
);
|
||||||
|
validateEvidence(evidence, releaseManifest.scanEvidenceDigest);
|
||||||
|
const candidateDigest = canonicalJsonDigest(
|
||||||
|
"portfolio-candidate-release-manifest/v1",
|
||||||
|
record,
|
||||||
|
);
|
||||||
|
const evidenceDigest = canonicalJsonDigest(
|
||||||
|
"portfolio-scan-evidence/v1",
|
||||||
|
evidence,
|
||||||
|
);
|
||||||
|
const projection = verifyAttestation(
|
||||||
|
attestations.projection,
|
||||||
|
trustPolicy.roots.projection,
|
||||||
|
"portfolio-approval-projection-attestation/v1",
|
||||||
|
"projection",
|
||||||
|
now,
|
||||||
|
);
|
||||||
|
const workload = verifyAttestation(
|
||||||
|
attestations.privacyWorkload,
|
||||||
|
trustPolicy.roots.privacyWorkload,
|
||||||
|
"portfolio-privacy-workload-attestation/v1",
|
||||||
|
"privacyWorkload",
|
||||||
|
now,
|
||||||
|
);
|
||||||
|
const policy = verifyAttestation(
|
||||||
|
attestations.policy,
|
||||||
|
trustPolicy.roots.policy,
|
||||||
|
"portfolio-policy-attestation/v1",
|
||||||
|
"policy",
|
||||||
|
now,
|
||||||
|
);
|
||||||
|
const tools = verifyAttestation(
|
||||||
|
attestations.toolImage,
|
||||||
|
trustPolicy.roots.toolImage,
|
||||||
|
"portfolio-tool-image-provenance/v1",
|
||||||
|
"toolImage",
|
||||||
|
now,
|
||||||
|
);
|
||||||
|
for (const [field, expected] of [
|
||||||
|
["productionInputRoot", releaseManifest.productionInputRoot],
|
||||||
|
["approvedRecordRoot", releaseManifest.approvedRecordRoot],
|
||||||
|
["approvedAssetRoot", releaseManifest.approvedAssetRoot],
|
||||||
|
["approvalProofSetDigest", releaseManifest.approvalProofSetDigest],
|
||||||
|
["latestLedgerHeadHash", record.latestLedgerHeadHash],
|
||||||
|
["latestLedgerRevision", record.latestLedgerRevision],
|
||||||
|
]) {
|
||||||
|
if (projection[field] !== expected)
|
||||||
|
throw new Error(`Approval projection mismatch: ${field}.`);
|
||||||
|
}
|
||||||
|
if (projection.digest !== releaseManifest.approvalProjectionAttestationDigest)
|
||||||
|
throw new Error("Projection attestation digest binding is invalid.");
|
||||||
|
if (
|
||||||
|
workload.candidateDigest !== candidateDigest ||
|
||||||
|
workload.evidenceDigest !== evidenceDigest
|
||||||
|
)
|
||||||
|
throw new Error(
|
||||||
|
"Privacy workload does not bind exact candidate and evidence bytes.",
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
policy.digest !== record.privacyPolicyDigest ||
|
||||||
|
policy.policyVersion !== trustPolicy.policyVersion
|
||||||
|
)
|
||||||
|
throw new Error("Privacy policy attestation is stale or substituted.");
|
||||||
|
if (
|
||||||
|
!Array.isArray(tools.imageDigests) ||
|
||||||
|
JSON.stringify(tools.imageDigests) !==
|
||||||
|
JSON.stringify(record.toolImageDigests)
|
||||||
|
)
|
||||||
|
throw new Error("Tool-image provenance does not bind exact images.");
|
||||||
|
return { record, releaseManifest, state, candidateDigest, evidenceDigest };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function externalPrivateKey(path) {
|
||||||
|
if (typeof path !== "string" || !isAbsolute(path))
|
||||||
|
throw new Error("Release key path must be an external absolute path.");
|
||||||
|
const workspace = await realpath(process.cwd());
|
||||||
|
const lexicalRelative = relative(workspace, resolve(path));
|
||||||
|
if (
|
||||||
|
lexicalRelative === "" ||
|
||||||
|
(!lexicalRelative.startsWith("..") && !isAbsolute(lexicalRelative))
|
||||||
|
)
|
||||||
|
throw new Error(
|
||||||
|
"Release signing key must live outside the repository/workspace.",
|
||||||
|
);
|
||||||
|
const canonicalPath = await realpath(path);
|
||||||
|
const rel = relative(workspace, canonicalPath);
|
||||||
|
if (rel === "" || (!rel.startsWith("..") && !isAbsolute(rel)))
|
||||||
|
throw new Error(
|
||||||
|
"Release signing key must live outside the repository/workspace.",
|
||||||
|
);
|
||||||
|
const info = await fileStat(canonicalPath);
|
||||||
|
if (!info.isFile() || (info.mode & 0o077) !== 0)
|
||||||
|
throw new Error("External release key must be a private owner-only file.");
|
||||||
|
const key = createPrivateKey(await readFile(canonicalPath));
|
||||||
|
if (key.asymmetricKeyType !== "ed25519")
|
||||||
|
throw new Error("Release key must be Ed25519.");
|
||||||
|
return key;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function signReleaseCandidate(input) {
|
||||||
|
const trustPolicy = validateTrustPolicy(input.trustPolicy);
|
||||||
|
const verified = verifyCandidateAuthority({ ...input, trustPolicy });
|
||||||
|
const key = await externalPrivateKey(input.releaseKeyPath);
|
||||||
|
const releaseRoot = trustPolicy.roots.release;
|
||||||
|
const bytes = canonicalValueBytes(verified.record);
|
||||||
|
const signatureBytes = sign(null, bytes, key);
|
||||||
|
if (
|
||||||
|
!verify(
|
||||||
|
null,
|
||||||
|
bytes,
|
||||||
|
createPublicKey(releaseRoot.publicKeyPem),
|
||||||
|
signatureBytes,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
throw new Error(
|
||||||
|
"External key does not match the active release trust root.",
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
schema: "portfolio-signed-release-manifest/v1",
|
||||||
|
payload: verified.record,
|
||||||
|
payloadDigest: verified.candidateDigest,
|
||||||
|
signature: {
|
||||||
|
algorithm: "Ed25519",
|
||||||
|
encoding: "base64url-nopad",
|
||||||
|
keyId: releaseRoot.keyId,
|
||||||
|
issuerEpoch: releaseRoot.issuerEpoch,
|
||||||
|
scope: releaseRoot.scope,
|
||||||
|
value: signatureBytes.toString("base64url"),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function verifySignedReleaseEnvelope({
|
||||||
|
signedRelease,
|
||||||
|
trustPolicy: trustValue,
|
||||||
|
now,
|
||||||
|
protectedState,
|
||||||
|
}) {
|
||||||
|
const trustPolicy = validateTrustPolicy(trustValue);
|
||||||
|
const envelope = object(signedRelease, "signed release");
|
||||||
|
exact(envelope, signedFields, "signed release");
|
||||||
|
if (envelope.schema !== "portfolio-signed-release-manifest/v1")
|
||||||
|
throw new Error("Signed release schema is invalid.");
|
||||||
|
const signature = object(envelope.signature, "release signature");
|
||||||
|
exact(signature, signatureFields, "release signature");
|
||||||
|
const root = trustPolicy.roots.release;
|
||||||
|
if (
|
||||||
|
signature.algorithm !== "Ed25519" ||
|
||||||
|
signature.encoding !== "base64url-nopad" ||
|
||||||
|
signature.keyId !== root.keyId ||
|
||||||
|
signature.issuerEpoch !== root.issuerEpoch ||
|
||||||
|
signature.scope !== root.scope ||
|
||||||
|
typeof signature.value !== "string"
|
||||||
|
)
|
||||||
|
throw new Error(
|
||||||
|
"Release signature key, epoch, scope, or encoding is invalid.",
|
||||||
|
);
|
||||||
|
const { candidate, releaseManifest } = validateCandidate(
|
||||||
|
envelope.payload,
|
||||||
|
now,
|
||||||
|
);
|
||||||
|
const payloadDigest = canonicalJsonDigest(
|
||||||
|
"portfolio-candidate-release-manifest/v1",
|
||||||
|
candidate,
|
||||||
|
);
|
||||||
|
if (envelope.payloadDigest !== payloadDigest)
|
||||||
|
throw new Error("Signed release payload digest is invalid.");
|
||||||
|
const bytes = Buffer.from(signature.value, "base64url");
|
||||||
|
if (
|
||||||
|
bytes.toString("base64url") !== signature.value ||
|
||||||
|
!verify(
|
||||||
|
null,
|
||||||
|
canonicalValueBytes(candidate),
|
||||||
|
createPublicKey(root.publicKeyPem),
|
||||||
|
bytes,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
throw new Error("Release signature is invalid.");
|
||||||
|
const state = validateProtectedState(protectedState);
|
||||||
|
if (
|
||||||
|
candidate.latestLedgerHeadHash !== state.latestLedgerHeadHash ||
|
||||||
|
candidate.latestLedgerRevision !== state.latestLedgerRevision ||
|
||||||
|
releaseManifest.currentManifestDigest !== state.currentManifestDigest ||
|
||||||
|
releaseManifest.predecessorManifestDigest !== state.currentManifestDigest
|
||||||
|
)
|
||||||
|
throw new Error("Signed release is stale relative to protected state.");
|
||||||
|
return {
|
||||||
|
schema: "portfolio-verified-release-admission/v1",
|
||||||
|
manifestDigest: payloadDigest,
|
||||||
|
manifestPayload: releaseManifest,
|
||||||
|
expiresAt: releaseManifest.expiresAt,
|
||||||
|
predecessorManifestDigest: releaseManifest.predecessorManifestDigest,
|
||||||
|
latestLedgerHeadHash: candidate.latestLedgerHeadHash,
|
||||||
|
latestLedgerRevision: candidate.latestLedgerRevision,
|
||||||
|
expectedFence: state.publisherFence + 1,
|
||||||
|
signatureVerified: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const args = process.argv.slice(2);
|
||||||
|
if (args.length !== 4 || args[0] !== "--request" || args[2] !== "--key")
|
||||||
|
throw new Error(
|
||||||
|
"Usage: sign-release.mjs --request <canonical-request.json> --key <external-key.pem>",
|
||||||
|
);
|
||||||
|
const request = parseCanonicalJson(await readFile(resolve(args[1])));
|
||||||
|
const signed = await signReleaseCandidate({
|
||||||
|
...object(request, "sign request"),
|
||||||
|
releaseKeyPath: resolve(args[3]),
|
||||||
|
});
|
||||||
|
process.stdout.write(Buffer.from(canonicalValueBytes(signed)));
|
||||||
|
process.stdout.write("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
process.argv[1] &&
|
||||||
|
import.meta.url === pathToFileURL(process.argv[1]).href
|
||||||
|
) {
|
||||||
|
main().catch((error) => {
|
||||||
|
process.stderr.write(
|
||||||
|
`Release signing failed: ${error instanceof Error ? error.message : "unknown error"}\n`,
|
||||||
|
);
|
||||||
|
process.exitCode = 1;
|
||||||
|
});
|
||||||
|
}
|
||||||
71
scripts/release/trust-policy.schema.json
Normal file
71
scripts/release/trust-policy.schema.json
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
|
"$id": "https://portfolio.invalid/schemas/trust-policy-v1.json",
|
||||||
|
"title": "Portfolio release trust policy",
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"required": ["schema", "policyVersion", "publicationFrozen", "roots"],
|
||||||
|
"properties": {
|
||||||
|
"schema": { "const": "portfolio-trust-policy/v1" },
|
||||||
|
"policyVersion": {
|
||||||
|
"type": "string",
|
||||||
|
"pattern": "^[A-Za-z0-9][A-Za-z0-9._/-]{0,127}$"
|
||||||
|
},
|
||||||
|
"publicationFrozen": { "type": "boolean" },
|
||||||
|
"roots": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"required": [
|
||||||
|
"approval",
|
||||||
|
"projection",
|
||||||
|
"privacyWorkload",
|
||||||
|
"policy",
|
||||||
|
"toolImage",
|
||||||
|
"release",
|
||||||
|
"publisher"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"approval": { "$ref": "#/$defs/root" },
|
||||||
|
"projection": { "$ref": "#/$defs/root" },
|
||||||
|
"privacyWorkload": { "$ref": "#/$defs/root" },
|
||||||
|
"policy": { "$ref": "#/$defs/root" },
|
||||||
|
"toolImage": { "$ref": "#/$defs/root" },
|
||||||
|
"release": { "$ref": "#/$defs/root" },
|
||||||
|
"publisher": { "$ref": "#/$defs/root" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"$defs": {
|
||||||
|
"root": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"required": [
|
||||||
|
"keyId",
|
||||||
|
"issuerEpoch",
|
||||||
|
"scope",
|
||||||
|
"publicKeyPem",
|
||||||
|
"revokedEpochs",
|
||||||
|
"compromiseResponse"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"keyId": {
|
||||||
|
"type": "string",
|
||||||
|
"pattern": "^[A-Za-z0-9][A-Za-z0-9._/-]{0,127}$"
|
||||||
|
},
|
||||||
|
"issuerEpoch": { "type": "integer", "minimum": 1 },
|
||||||
|
"scope": { "type": "string" },
|
||||||
|
"publicKeyPem": { "type": "string" },
|
||||||
|
"revokedEpochs": {
|
||||||
|
"type": "array",
|
||||||
|
"uniqueItems": true,
|
||||||
|
"items": { "type": "integer", "minimum": 1 }
|
||||||
|
},
|
||||||
|
"compromiseResponse": {
|
||||||
|
"type": "array",
|
||||||
|
"uniqueItems": true,
|
||||||
|
"items": { "type": "string" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
240
tests/security/privacy-workers.test.ts
Normal file
240
tests/security/privacy-workers.test.ts
Normal file
@@ -0,0 +1,240 @@
|
|||||||
|
import {
|
||||||
|
chmod,
|
||||||
|
mkdtemp,
|
||||||
|
mkdir,
|
||||||
|
readFile,
|
||||||
|
symlink,
|
||||||
|
writeFile,
|
||||||
|
} from "node:fs/promises";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { describe, expect, test } from "vitest";
|
||||||
|
|
||||||
|
// @ts-expect-error executable release modules intentionally have no browser declarations
|
||||||
|
import { enumerateClosure } from "../../scripts/release/enumerate-closure.mjs";
|
||||||
|
// @ts-expect-error executable release modules intentionally have no browser declarations
|
||||||
|
import * as privacyRunner from "../../scripts/release/run-privacy-workers.mjs";
|
||||||
|
// @ts-expect-error executable release modules intentionally have no browser declarations
|
||||||
|
import { reconcileScan } from "../../scripts/release/reconcile-scan.mjs";
|
||||||
|
|
||||||
|
const { buildWorkerCommand, validateScanObjectDescriptor } = privacyRunner;
|
||||||
|
|
||||||
|
const h = (c: string) => c.repeat(64);
|
||||||
|
|
||||||
|
function report(
|
||||||
|
workerId: "worker-a" | "worker-b",
|
||||||
|
override: Record<string, unknown> = {},
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
schema: "portfolio-privacy-worker-report/v1",
|
||||||
|
workerId,
|
||||||
|
imageDigest: workerId === "worker-a" ? h("a") : h("b"),
|
||||||
|
toolchain: workerId === "worker-a" ? "bytes-js/v1" : "bytes-python/v1",
|
||||||
|
inputDigest: h("1"),
|
||||||
|
checkId: "bytes",
|
||||||
|
normalizedOutputDigest: h("2"),
|
||||||
|
normalizedMembers: ["text:" + h("3")],
|
||||||
|
verdict: "pass",
|
||||||
|
findingsDigest: h("0"),
|
||||||
|
...override,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function matcher(override: Record<string, unknown> = {}) {
|
||||||
|
return {
|
||||||
|
schema: "portfolio-privacy-matcher-result/v1",
|
||||||
|
inputDigest: h("1"),
|
||||||
|
checkId: "bytes",
|
||||||
|
normalizedOutputDigest: h("2"),
|
||||||
|
normalizedMembers: ["text:" + h("3")],
|
||||||
|
ruleSetDigest: h("4"),
|
||||||
|
appliedRuleIds: ["denylist-v1"],
|
||||||
|
verdict: "pass",
|
||||||
|
workerImageDigests: [h("a"), h("b")],
|
||||||
|
...override,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("isolated privacy closure and reconciliation", () => {
|
||||||
|
test("enumerates an exact byte-sorted closure and rejects unsafe filesystem members", async () => {
|
||||||
|
const root = await mkdtemp(join(tmpdir(), "portfolio-closure-"));
|
||||||
|
await mkdir(join(root, "nested"));
|
||||||
|
await writeFile(join(root, "z.txt"), "z");
|
||||||
|
await writeFile(join(root, "nested", "a.txt"), "a");
|
||||||
|
const closure = await enumerateClosure([{ class: "build", root }]);
|
||||||
|
expect(
|
||||||
|
closure.members.map((member: { path: string }) => member.path),
|
||||||
|
).toEqual(["nested/a.txt", "z.txt"]);
|
||||||
|
expect(
|
||||||
|
closure.members.every((member: { sha256: string }) =>
|
||||||
|
/^[a-f0-9]{64}$/u.test(member.sha256),
|
||||||
|
),
|
||||||
|
).toBe(true);
|
||||||
|
await symlink("z.txt", join(root, "link"));
|
||||||
|
await expect(enumerateClosure([{ class: "build", root }])).rejects.toThrow(
|
||||||
|
/symlink/i,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("constructs networkless read-only non-root resource-limited jobs", () => {
|
||||||
|
const command = buildWorkerCommand({
|
||||||
|
runtime: "docker",
|
||||||
|
image: "scanner@sha256:" + h("a"),
|
||||||
|
inputPath: "/safe/input",
|
||||||
|
outputPath: "/safe/output",
|
||||||
|
worker: "worker-a",
|
||||||
|
});
|
||||||
|
expect(command).toContain("--network=none");
|
||||||
|
expect(command).toContain("--read-only");
|
||||||
|
expect(command).toContain("--cap-drop=ALL");
|
||||||
|
expect(command).toContain("--security-opt=no-new-privileges");
|
||||||
|
expect(command).toContain("--user=65532:65532");
|
||||||
|
expect(command).toContain("--memory=256m");
|
||||||
|
expect(command).toContain("--cpus=1");
|
||||||
|
expect(command).toContain("--pids-limit=64");
|
||||||
|
expect(command).toContain("--pull=never");
|
||||||
|
expect(command).toContain(
|
||||||
|
"--mount=type=bind,src=/safe/input,dst=/input,readonly",
|
||||||
|
);
|
||||||
|
expect(command).toContain("--mount=type=bind,src=/safe/output,dst=/output");
|
||||||
|
});
|
||||||
|
|
||||||
|
test.each([
|
||||||
|
["encrypted archive", { encrypted: true }],
|
||||||
|
["malformed container", { malformed: true }],
|
||||||
|
["nested archive", { archiveDepth: 4 }],
|
||||||
|
["archive expansion", { expandedBytes: 67_108_865 }],
|
||||||
|
["traversal", { members: [{ path: "../secret", entryType: "file" }] }],
|
||||||
|
["absolute member", { members: [{ path: "/secret", entryType: "file" }] }],
|
||||||
|
["symlink member", { members: [{ path: "safe", entryType: "symlink" }] }],
|
||||||
|
["device member", { members: [{ path: "safe", entryType: "device" }] }],
|
||||||
|
])("fails closed before decoding an unsafe %s", (_name, override) => {
|
||||||
|
expect(() =>
|
||||||
|
validateScanObjectDescriptor({
|
||||||
|
inputDigest: h("1"),
|
||||||
|
checkId: "container",
|
||||||
|
mediaType: "application/zip",
|
||||||
|
encrypted: false,
|
||||||
|
malformed: false,
|
||||||
|
archiveDepth: 1,
|
||||||
|
expandedBytes: 10,
|
||||||
|
members: [],
|
||||||
|
...override,
|
||||||
|
}),
|
||||||
|
).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("does not expose OCR or QR canary values when a matcher blocks them", () => {
|
||||||
|
const secret = "PRIVATE-CANARY-DO-NOT-PRINT";
|
||||||
|
expect(() =>
|
||||||
|
reconcileScan({
|
||||||
|
expectedChecks: [{ inputDigest: h("1"), checkId: "ocr" }],
|
||||||
|
reports: [
|
||||||
|
report("worker-a", { checkId: "ocr", verdict: "fail" }),
|
||||||
|
report("worker-b", { checkId: "ocr", verdict: "fail" }),
|
||||||
|
],
|
||||||
|
matcherResults: [matcher({ checkId: "ocr", verdict: "fail" })],
|
||||||
|
candidate: {
|
||||||
|
schema: "portfolio-candidate-release-manifest/v1",
|
||||||
|
buildId: "r1",
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
).toThrowError(expect.not.stringContaining(secret));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("accepts only ordered heterogeneous agreement for every expected check", () => {
|
||||||
|
const result = reconcileScan({
|
||||||
|
expectedChecks: [{ inputDigest: h("1"), checkId: "bytes" }],
|
||||||
|
reports: [report("worker-a"), report("worker-b")],
|
||||||
|
matcherResults: [matcher()],
|
||||||
|
candidate: {
|
||||||
|
schema: "portfolio-candidate-release-manifest/v1",
|
||||||
|
buildId: "r1",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(result.evidenceDigest).toMatch(/^[a-f0-9]{64}$/);
|
||||||
|
expect(result.candidateDigest).toMatch(/^[a-f0-9]{64}$/);
|
||||||
|
expect(new TextDecoder().decode(result.evidenceBytes)).not.toContain(
|
||||||
|
"decoded-secret",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test.each([
|
||||||
|
["single worker", [report("worker-a")], [matcher()]],
|
||||||
|
[
|
||||||
|
"reordered workers",
|
||||||
|
[report("worker-b"), report("worker-a")],
|
||||||
|
[matcher()],
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"schema-valid false pass",
|
||||||
|
[report("worker-a"), report("worker-b", { verdict: "fail" })],
|
||||||
|
[matcher()],
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"normalized disagreement",
|
||||||
|
[
|
||||||
|
report("worker-a"),
|
||||||
|
report("worker-b", { normalizedOutputDigest: h("9") }),
|
||||||
|
],
|
||||||
|
[matcher()],
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"substituted worker image",
|
||||||
|
[report("worker-a"), report("worker-b", { imageDigest: h("a") })],
|
||||||
|
[matcher()],
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"mismatched check",
|
||||||
|
[report("worker-a"), report("worker-b", { checkId: "metadata" })],
|
||||||
|
[matcher()],
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"matcher false pass",
|
||||||
|
[report("worker-a"), report("worker-b")],
|
||||||
|
[matcher({ normalizedOutputDigest: h("9") })],
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"unsupported container",
|
||||||
|
[
|
||||||
|
report("worker-a", { verdict: "unsupported" }),
|
||||||
|
report("worker-b", { verdict: "unsupported" }),
|
||||||
|
],
|
||||||
|
[matcher({ verdict: "fail" })],
|
||||||
|
],
|
||||||
|
])("fails closed for %s", (_name, reports, matcherResults) => {
|
||||||
|
expect(() =>
|
||||||
|
reconcileScan({
|
||||||
|
expectedChecks: [{ inputDigest: h("1"), checkId: "bytes" }],
|
||||||
|
reports,
|
||||||
|
matcherResults,
|
||||||
|
candidate: {
|
||||||
|
schema: "portfolio-candidate-release-manifest/v1",
|
||||||
|
buildId: "r1",
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("writes canonical evidence and candidate objects content-addressed without overwrite", async () => {
|
||||||
|
const result = reconcileScan({
|
||||||
|
expectedChecks: [{ inputDigest: h("1"), checkId: "bytes" }],
|
||||||
|
reports: [report("worker-a"), report("worker-b")],
|
||||||
|
matcherResults: [matcher()],
|
||||||
|
candidate: {
|
||||||
|
schema: "portfolio-candidate-release-manifest/v1",
|
||||||
|
buildId: "r1",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const root = await mkdtemp(join(tmpdir(), "portfolio-evidence-"));
|
||||||
|
await result.writeContentAddressed(root);
|
||||||
|
expect(await readFile(join(root, result.evidenceDigest), "utf8")).toBe(
|
||||||
|
new TextDecoder().decode(result.evidenceBytes),
|
||||||
|
);
|
||||||
|
await chmod(join(root, result.candidateDigest), 0o600);
|
||||||
|
await writeFile(join(root, result.candidateDigest), "substituted");
|
||||||
|
await expect(result.writeContentAddressed(root)).rejects.toThrow(
|
||||||
|
/immutable|content-addressed/i,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
441
tests/security/release-signing.test.ts
Normal file
441
tests/security/release-signing.test.ts
Normal file
@@ -0,0 +1,441 @@
|
|||||||
|
import { generateKeyPairSync, sign, verify } from "node:crypto";
|
||||||
|
import { mkdtemp, 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 { computeProductionInputRoot } from "../../src/security/production-input-root.ts";
|
||||||
|
// @ts-expect-error executable release modules intentionally have no browser declarations
|
||||||
|
import * as releaseSigner from "../../scripts/release/sign-release.mjs";
|
||||||
|
|
||||||
|
const {
|
||||||
|
signReleaseCandidate,
|
||||||
|
validateTrustPolicy,
|
||||||
|
verifySignedReleaseEnvelope,
|
||||||
|
} = releaseSigner;
|
||||||
|
|
||||||
|
const h = (c: string) => c.repeat(64);
|
||||||
|
const roles = [
|
||||||
|
["approval", "portfolio-approval-decision/v1"],
|
||||||
|
["projection", "portfolio-approval-projection/v1"],
|
||||||
|
["privacyWorkload", "portfolio-privacy-workload/v1"],
|
||||||
|
["policy", "portfolio-policy-attestation/v1"],
|
||||||
|
["toolImage", "portfolio-tool-image-provenance/v1"],
|
||||||
|
["release", "portfolio-release-signing/v1"],
|
||||||
|
["publisher", "portfolio-publisher-fencing/v1"],
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
function keys(): Record<string, { publicKey: any; privateKey: any }> {
|
||||||
|
return Object.fromEntries(
|
||||||
|
roles.map(([role]) => [role, generateKeyPairSync("ed25519")]),
|
||||||
|
) as Record<string, { publicKey: any; privateKey: any }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function policy(keyPairs: ReturnType<typeof keys>): any {
|
||||||
|
return {
|
||||||
|
schema: "portfolio-trust-policy/v1",
|
||||||
|
policyVersion: "2026-09-02",
|
||||||
|
publicationFrozen: false,
|
||||||
|
roots: Object.fromEntries(
|
||||||
|
roles.map(([role, scope]) => [
|
||||||
|
role,
|
||||||
|
{
|
||||||
|
keyId: `${role}-key`,
|
||||||
|
issuerEpoch: 2,
|
||||||
|
scope,
|
||||||
|
publicKeyPem: keyPairs[role]!.publicKey.export({
|
||||||
|
type: "spki",
|
||||||
|
format: "pem",
|
||||||
|
}),
|
||||||
|
revokedEpochs: [1],
|
||||||
|
compromiseResponse:
|
||||||
|
role === "approval"
|
||||||
|
? ["freeze-publication", "audit-checkpoint", "fresh-approvals"]
|
||||||
|
: role === "publisher"
|
||||||
|
? [
|
||||||
|
"freeze-publication",
|
||||||
|
"rotate-credential",
|
||||||
|
"advance-fence",
|
||||||
|
"reconcile-staging",
|
||||||
|
]
|
||||||
|
: ["freeze-publication", "revoke-epoch", "fresh-evidence"],
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function envelope(
|
||||||
|
role: string,
|
||||||
|
scope: string,
|
||||||
|
keyPairs: ReturnType<typeof keys>,
|
||||||
|
payload: Record<string, unknown> = {},
|
||||||
|
) {
|
||||||
|
const schema = {
|
||||||
|
projection: "portfolio-approval-projection-attestation/v1",
|
||||||
|
privacyWorkload: "portfolio-privacy-workload-attestation/v1",
|
||||||
|
policy: "portfolio-policy-attestation/v1",
|
||||||
|
toolImage: "portfolio-tool-image-provenance/v1",
|
||||||
|
}[role];
|
||||||
|
const body = {
|
||||||
|
schema,
|
||||||
|
keyId: `${role}-key`,
|
||||||
|
issuerEpoch: 2,
|
||||||
|
scope,
|
||||||
|
issuedAt: "2026-09-02T00:00:00Z",
|
||||||
|
expiresAt: "2026-09-03T00:00:00Z",
|
||||||
|
...payload,
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
payload: body,
|
||||||
|
signature: {
|
||||||
|
algorithm: "Ed25519",
|
||||||
|
encoding: "base64url-nopad",
|
||||||
|
value: sign(
|
||||||
|
null,
|
||||||
|
canonicalValueBytes(body),
|
||||||
|
keyPairs[role]!.privateKey,
|
||||||
|
).toString("base64url"),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function fixture() {
|
||||||
|
const keyPairs = keys();
|
||||||
|
const inputDigests = {
|
||||||
|
PortfolioContent: h("a"),
|
||||||
|
PublicAssetManifest: h("b"),
|
||||||
|
PreparedAssetSet: h("c"),
|
||||||
|
SiteConfig: h("d"),
|
||||||
|
};
|
||||||
|
const releaseManifest = {
|
||||||
|
schema: "portfolio-release-manifest/v1",
|
||||||
|
launchMode: "private-source-initial-launch",
|
||||||
|
buildId: "r1",
|
||||||
|
inputDigests,
|
||||||
|
productionInputRoot: computeProductionInputRoot({
|
||||||
|
portfolioContentDigest: h("a"),
|
||||||
|
publicAssetManifestDigest: h("b"),
|
||||||
|
preparedAssetSetDigest: h("c"),
|
||||||
|
siteConfigDigest: h("d"),
|
||||||
|
}),
|
||||||
|
artifacts: [
|
||||||
|
{ path: "index.html", sha256: h("6"), bytes: 42, encoding: "identity" },
|
||||||
|
],
|
||||||
|
deployFiles: ["index.html"],
|
||||||
|
sourceCommit: h("7"),
|
||||||
|
sourceTree: h("8"),
|
||||||
|
approvedRecordRoot: h("9"),
|
||||||
|
approvedAssetRoot: h("e"),
|
||||||
|
approvalProofSetDigest: h("f"),
|
||||||
|
approvalProjectionAttestationDigest: h("2"),
|
||||||
|
scanEvidenceDigest: h("3"),
|
||||||
|
policyVersions: { approval: "1", privacy: "1", release: "1" },
|
||||||
|
toolVersions: { node: "22" },
|
||||||
|
predecessorManifestDigest: null,
|
||||||
|
currentManifestDigest: null,
|
||||||
|
issuedAt: "2026-09-02T00:00:00Z",
|
||||||
|
expiresAt: "2026-09-02T12:00:00Z",
|
||||||
|
minimumBoundExpiry: "2026-09-02T12:00:00Z",
|
||||||
|
};
|
||||||
|
const candidate = {
|
||||||
|
schema: "portfolio-candidate-release-manifest/v1",
|
||||||
|
releaseManifest,
|
||||||
|
latestLedgerHeadHash: h("1"),
|
||||||
|
latestLedgerRevision: 7,
|
||||||
|
sourceClosureDigest: h("4"),
|
||||||
|
publicRefMap: {},
|
||||||
|
lfsOids: [],
|
||||||
|
releaseArchives: [],
|
||||||
|
buildProvenanceDigest: h("5"),
|
||||||
|
privacyPolicyDigest: h("a"),
|
||||||
|
toolImageDigests: [h("b"), h("c")],
|
||||||
|
};
|
||||||
|
const evidence = {
|
||||||
|
schema: "portfolio-scan-evidence/v1",
|
||||||
|
coverageCount: 1,
|
||||||
|
passedCount: 1,
|
||||||
|
checks: [
|
||||||
|
{
|
||||||
|
inputDigest: h("5"),
|
||||||
|
checkId: "bytes",
|
||||||
|
normalizedOutputDigest: h("6"),
|
||||||
|
normalizedMembers: [`bytes:${h("7")}`],
|
||||||
|
workerReports: [h("8"), h("9")],
|
||||||
|
matcherResult: h("a"),
|
||||||
|
ruleSetDigest: h("b"),
|
||||||
|
appliedRuleIds: ["denylist-v1"],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
const evidenceDigest = canonicalJsonDigest(
|
||||||
|
"portfolio-scan-evidence/v1",
|
||||||
|
evidence,
|
||||||
|
);
|
||||||
|
candidate.releaseManifest.scanEvidenceDigest = evidenceDigest;
|
||||||
|
const candidateDigest = canonicalJsonDigest(
|
||||||
|
"portfolio-candidate-release-manifest/v1",
|
||||||
|
candidate,
|
||||||
|
);
|
||||||
|
const attestations = {
|
||||||
|
projection: envelope("projection", roles[1][1], keyPairs, {
|
||||||
|
digest: h("2"),
|
||||||
|
productionInputRoot: releaseManifest.productionInputRoot,
|
||||||
|
approvedRecordRoot: h("9"),
|
||||||
|
approvedAssetRoot: h("e"),
|
||||||
|
approvalProofSetDigest: h("f"),
|
||||||
|
latestLedgerHeadHash: h("1"),
|
||||||
|
latestLedgerRevision: 7,
|
||||||
|
}),
|
||||||
|
privacyWorkload: envelope("privacyWorkload", roles[2][1], keyPairs, {
|
||||||
|
digest: h("2"),
|
||||||
|
candidateDigest,
|
||||||
|
evidenceDigest,
|
||||||
|
}),
|
||||||
|
policy: envelope("policy", roles[3][1], keyPairs, {
|
||||||
|
digest: h("a"),
|
||||||
|
policyVersion: "2026-09-02",
|
||||||
|
}),
|
||||||
|
toolImage: envelope("toolImage", roles[4][1], keyPairs, {
|
||||||
|
digest: h("b"),
|
||||||
|
imageDigests: [h("b"), h("c")],
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
keyPairs,
|
||||||
|
candidate,
|
||||||
|
evidence,
|
||||||
|
candidateDigest,
|
||||||
|
evidenceDigest,
|
||||||
|
attestations,
|
||||||
|
trustPolicy: policy(keyPairs),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("external scoped release signing", () => {
|
||||||
|
test("signs exact canonical candidate bytes with an external Ed25519 key", 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 },
|
||||||
|
);
|
||||||
|
const result = await signReleaseCandidate({
|
||||||
|
...f,
|
||||||
|
releaseKeyPath: keyPath,
|
||||||
|
now: "2026-09-02T01:00:00Z",
|
||||||
|
protectedState: {
|
||||||
|
latestLedgerHeadHash: h("1"),
|
||||||
|
latestLedgerRevision: 7,
|
||||||
|
currentManifestDigest: null,
|
||||||
|
publisherFence: 4,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(result.payloadDigest).toBe(f.candidateDigest);
|
||||||
|
expect(
|
||||||
|
verify(
|
||||||
|
null,
|
||||||
|
canonicalValueBytes(f.candidate),
|
||||||
|
f.keyPairs.release!.publicKey,
|
||||||
|
Buffer.from(result.signature.value, "base64url"),
|
||||||
|
),
|
||||||
|
).toBe(true);
|
||||||
|
expect(
|
||||||
|
verifySignedReleaseEnvelope({
|
||||||
|
signedRelease: result,
|
||||||
|
trustPolicy: f.trustPolicy,
|
||||||
|
now: "2026-09-02T01:00:00Z",
|
||||||
|
protectedState: {
|
||||||
|
latestLedgerHeadHash: h("1"),
|
||||||
|
latestLedgerRevision: 7,
|
||||||
|
currentManifestDigest: null,
|
||||||
|
publisherFence: 4,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
).toMatchObject({
|
||||||
|
schema: "portfolio-verified-release-admission/v1",
|
||||||
|
manifestDigest: f.candidateDigest,
|
||||||
|
expiresAt: "2026-09-02T12:00:00Z",
|
||||||
|
predecessorManifestDigest: null,
|
||||||
|
latestLedgerHeadHash: h("1"),
|
||||||
|
latestLedgerRevision: 7,
|
||||||
|
expectedFence: 5,
|
||||||
|
signatureVerified: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("verified admission rejects stale publisher state and substituted signed payloads", 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 },
|
||||||
|
);
|
||||||
|
const state = {
|
||||||
|
latestLedgerHeadHash: h("1"),
|
||||||
|
latestLedgerRevision: 7,
|
||||||
|
currentManifestDigest: null,
|
||||||
|
publisherFence: 4,
|
||||||
|
};
|
||||||
|
const signedRelease = await signReleaseCandidate({
|
||||||
|
...f,
|
||||||
|
releaseKeyPath: keyPath,
|
||||||
|
now: "2026-09-02T01:00:00Z",
|
||||||
|
protectedState: state,
|
||||||
|
});
|
||||||
|
expect(() =>
|
||||||
|
verifySignedReleaseEnvelope({
|
||||||
|
signedRelease,
|
||||||
|
trustPolicy: f.trustPolicy,
|
||||||
|
now: "2026-09-02T01:00:00Z",
|
||||||
|
protectedState: { ...state, latestLedgerRevision: 8 },
|
||||||
|
}),
|
||||||
|
).toThrow(/stale/i);
|
||||||
|
expect(() =>
|
||||||
|
verifySignedReleaseEnvelope({
|
||||||
|
signedRelease: {
|
||||||
|
...signedRelease,
|
||||||
|
payload: { ...signedRelease.payload, latestLedgerRevision: 8 },
|
||||||
|
},
|
||||||
|
trustPolicy: f.trustPolicy,
|
||||||
|
now: "2026-09-02T01:00:00Z",
|
||||||
|
protectedState: state,
|
||||||
|
}),
|
||||||
|
).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("refuses repository-resident keys and non-external key material", async () => {
|
||||||
|
const f = fixture();
|
||||||
|
await expect(
|
||||||
|
signReleaseCandidate({
|
||||||
|
...f,
|
||||||
|
releaseKeyPath: join(process.cwd(), "release.pem"),
|
||||||
|
now: "2026-09-02T01:00:00Z",
|
||||||
|
protectedState: {
|
||||||
|
latestLedgerHeadHash: h("1"),
|
||||||
|
latestLedgerRevision: 7,
|
||||||
|
currentManifestDigest: null,
|
||||||
|
publisherFence: 4,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
).rejects.toThrow(/outside|external/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
test.each(roles)(
|
||||||
|
"requires current non-revoked %s issuer epoch and exact scope",
|
||||||
|
(role) => {
|
||||||
|
const f = fixture();
|
||||||
|
const mutated = structuredClone(f.trustPolicy);
|
||||||
|
mutated.roots[role].issuerEpoch = 1;
|
||||||
|
expect(() => validateTrustPolicy(mutated)).toThrow(/revoked|epoch/i);
|
||||||
|
const wrongScope = structuredClone(f.trustPolicy);
|
||||||
|
wrongScope.roots[role].scope = "wrong/v1";
|
||||||
|
expect(() => validateTrustPolicy(wrongScope)).toThrow(/scope/i);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
test("requires explicit compromise recovery semantics for approval and publisher roots", () => {
|
||||||
|
const f = fixture();
|
||||||
|
const approval = structuredClone(f.trustPolicy);
|
||||||
|
approval.roots.approval.compromiseResponse = ["freeze-publication"];
|
||||||
|
expect(() => validateTrustPolicy(approval)).toThrow(
|
||||||
|
/checkpoint|fresh approvals/i,
|
||||||
|
);
|
||||||
|
const publisher = structuredClone(f.trustPolicy);
|
||||||
|
publisher.roots.publisher.compromiseResponse = [
|
||||||
|
"freeze-publication",
|
||||||
|
"rotate-credential",
|
||||||
|
];
|
||||||
|
expect(() => validateTrustPolicy(publisher)).toThrow(/fence|staging/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
test.each([
|
||||||
|
[
|
||||||
|
"stale ledger",
|
||||||
|
(f: ReturnType<typeof fixture>) => ({
|
||||||
|
protectedState: {
|
||||||
|
latestLedgerHeadHash: h("9"),
|
||||||
|
latestLedgerRevision: 7,
|
||||||
|
currentManifestDigest: null,
|
||||||
|
publisherFence: 4,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"expired candidate",
|
||||||
|
(f: ReturnType<typeof fixture>) => ({ now: "2026-09-02T12:00:00Z" }),
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"substituted artifact",
|
||||||
|
(f: ReturnType<typeof fixture>) => ({
|
||||||
|
candidate: {
|
||||||
|
...f.candidate,
|
||||||
|
releaseManifest: {
|
||||||
|
...f.candidate.releaseManifest,
|
||||||
|
artifacts: [
|
||||||
|
{ ...f.candidate.releaseManifest.artifacts[0], bytes: 43 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"forged workload epoch",
|
||||||
|
(f: ReturnType<typeof fixture>) => ({
|
||||||
|
attestations: {
|
||||||
|
...f.attestations,
|
||||||
|
privacyWorkload: {
|
||||||
|
...f.attestations.privacyWorkload,
|
||||||
|
payload: {
|
||||||
|
...f.attestations.privacyWorkload.payload,
|
||||||
|
issuerEpoch: 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"projection mismatch",
|
||||||
|
(f: ReturnType<typeof fixture>) => ({
|
||||||
|
candidate: {
|
||||||
|
...f.candidate,
|
||||||
|
releaseManifest: {
|
||||||
|
...f.candidate.releaseManifest,
|
||||||
|
productionInputRoot: h("9"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
])("rejects %s before signing", async (_name, mutate) => {
|
||||||
|
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: "2026-09-02T01:00:00Z",
|
||||||
|
protectedState: {
|
||||||
|
latestLedgerHeadHash: h("1"),
|
||||||
|
latestLedgerRevision: 7,
|
||||||
|
currentManifestDigest: null,
|
||||||
|
publisherFence: 4,
|
||||||
|
},
|
||||||
|
...mutate(f),
|
||||||
|
}),
|
||||||
|
).rejects.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -10,7 +10,12 @@ export default defineConfig({
|
|||||||
test: {
|
test: {
|
||||||
environment: 'happy-dom',
|
environment: 'happy-dom',
|
||||||
globals: false,
|
globals: false,
|
||||||
include: ['tests/unit/**/*.test.ts', 'tests/integration/**/*.test.{ts,mjs}', 'tests/performance/**/*.test.ts'],
|
include: [
|
||||||
|
'tests/unit/**/*.test.ts',
|
||||||
|
'tests/integration/**/*.test.{ts,mjs}',
|
||||||
|
'tests/performance/**/*.test.ts',
|
||||||
|
'tests/security/**/*.test.ts',
|
||||||
|
],
|
||||||
coverage: { include: ['src/**/*.{ts,vue}'] },
|
coverage: { include: ['src/**/*.{ts,vue}'] },
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user