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: {
|
||||
environment: 'happy-dom',
|
||||
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}'] },
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user