feat: package atomic reversible nginx releases

This commit is contained in:
2026-09-02 10:27:28 +03:00
parent 8592581b18
commit d3e746b905
17 changed files with 1443 additions and 0 deletions

View File

@@ -0,0 +1,133 @@
# Atomic Nginx release runbook
This runbook describes the provider-neutral release transaction. It is not authorization to connect to a server or publish the portfolio. Replace every angle-bracket placeholder only at the explicit deployment checkpoint.
## Release-root contract
Provision one dedicated absolute directory such as `<release-root>`. Never use `/`, `/var`, `/srv`, a home directory, or an existing web root directly. The directory must be owned by the narrowly scoped deployment account and contain:
```text
<release-root>/
.portfolio-release-root exact bytes: portfolio-release-root/v1\n
control/state.json canonical JSON, owner read/write only
```
The initial state is the canonical form of:
```json
{
"activeTransaction": null,
"currentManifestDigest": null,
"currentReleaseId": null,
"latestLedgerHeadHash": "<protected-live-ledger-head>",
"latestLedgerRevision": 0,
"publisherFence": 0,
"schema": "portfolio-release-control-state/v1"
}
```
Provisioning must write that state through the protected control plane, not by copying an example with placeholder values. The deployment account needs write access only to this dedicated root. Nginx needs read/traverse access to `current`; it does not need permission to write releases or control state. `current` points to `releases/<release-id>/public`, while signed metadata and the SBOM remain in the immutable sibling `.release` directory and are therefore outside the served document root. Signing keys, the approval ledger, trust policy, server credentials, and private source remain outside the release package and public root.
The scripts reject non-absolute roots, broad roots, missing sentinels, symlinked verifier modules, unknown canonical fields, and non-canonical JSON.
## Build a deterministic package
After the isolated privacy/signing stage has produced a canonical signed release envelope and SBOM:
```sh
node scripts/build-release.mjs \
--dist <exact-dist-directory> \
--signed-manifest <signed-release-envelope.json> \
--sbom <sbom.cdx.json> \
--verifier-module <absolute-path-to-audited-sign-release.mjs> \
--trust-policy <external-canonical-trust-policy.json> \
--protected-state '<canonical-protected-state-json>' \
--now <trusted-rfc3339-time> \
--output <new-release.tar>
```
The builder verifies the real Ed25519 envelope through `verifySignedReleaseEnvelope`, validates the typed admission, requires the directory to equal the manifest closure exactly, and checks every byte count and SHA-256 hash. Its ustar output has sorted entries, fixed time/ownership/modes, no links, and exactly these metadata paths:
```text
.release/package-index.json
.release/sbom.cdx.json
.release/signed-release-manifest.json
```
The remaining entries are exactly the signed deploy files. Building twice from identical inputs must produce identical archive bytes.
## Internal staged smoke command
Preparation requires an operator-provisioned absolute executable. It is called without a shell as:
```text
<smoke-command> --release-dir <immutable-release-directory> --manifest-digest <digest>
```
The executable must expose that directory only through a non-public, authenticated/internal Nginx location, request the staged index and representative hashed/media assets, verify the expected 200/status, headers, MIME/range behavior, and return non-zero on any mismatch. Its standard output becomes digest-bound smoke evidence. Do not point public Nginx at the staged directory.
## Prepare without changing visibility
Use the deployment account's numeric UID/GID. Preparation verifies signature, exact predecessor, live ledger, fence, hashes, and time; extracts without using the system tar extractor; makes all release files `0444` and directories `0555`; runs the internal smoke; rechecks time and protected state; and writes an immutable readiness receipt. It does not create or change `current`.
```sh
scripts/deploy/prepare-release.sh \
<release-root> <new-release.tar> <transaction-id> \
<absolute-verifier-module> <external-trust-policy.json> <absolute-smoke-command> \
<deployment-uid> <deployment-gid> <trusted-rfc3339-time>
```
Inspect the resulting release ID and readiness receipt before activation. A preparation failure leaves the existing `current` pointer untouched.
## Fenced atomic activation
Supply the exact predecessor digest (`none` only for the first release) and the next fence printed by the verified admission:
```sh
scripts/deploy/activate-release.sh \
<release-root> <transaction-id> <expected-current-digest-or-none> <expected-next-fence> \
<absolute-verifier-module> <external-trust-policy.json> <trusted-rfc3339-time>
```
Activation obtains an exclusive transaction lock, re-verifies the signed envelope against current protected state, rechecks receipt/ledger/predecessor/fence/expiry, writes a durable committing intent, creates the relative `current.next` symlink, and renames it atomically over `current`. Nothing else changes public visibility.
- A failure before the rename leaves the predecessor visible.
- A crash after the rename is recovered by repeating the same command: the script observes the new pointer and finalizes the receipt/state without another visibility change.
- A second concurrent or stale invocation fails after the winner advances the pointer/fence.
- Never edit `current`, `current.next`, control state, or receipts by hand.
After activation, run the independent public smoke suite. If it fails, use the signed rollback flow below.
## Controlled rollback
Rollback does not replay the selected release's old manifest. The signing pipeline must first create a new candidate whose predecessor is the currently committed manifest, whose ledger/approvals are current, and whose artifact closure is byte-identical to the selected immutable release. Package that new signed envelope as usual, then run:
```sh
scripts/deploy/rollback-release.sh \
<release-root> <selected-old-release-id> <newly-signed-rollback-release.tar> <transaction-id> \
<absolute-verifier-module> <external-trust-policy.json> <absolute-smoke-command> \
<deployment-uid> <deployment-gid> <current-manifest-digest> <expected-next-fence> <trusted-rfc3339-time>
```
The script verifies that the rollback package uses a fresh manifest digest and exactly matches the selected old artifact closure, then performs the same readiness and atomic pointer transaction. Record both activation and rollback receipts in the release record.
## Orphan staging recovery
Only hidden directories named `<safe-transaction-id>.partial` beneath the validated dedicated root may be cleaned. Active transaction staging is retained. After protected-state recovery has confirmed that no transaction can resume:
```sh
scripts/deploy/prepare-release.sh <release-root> --cleanup-orphans
```
The cleanup path validates the absolute root and sentinel before traversal, rejects unexpected entries or symlinks, and never touches immutable releases, `current`, or paths outside `<release-root>/staging`.
## Release record
For every attempt record, without secrets:
- package SHA-256 and signed candidate/manifest digest;
- build ID, release ID, predecessor, live-ledger head/revision, and fence;
- readiness and activation receipt digests;
- staged and public smoke outcomes;
- selected rollback release and the new rollback manifest digest, when applicable;
- operator authorization and UTC timestamps.

View File

@@ -0,0 +1,17 @@
export interface BuildReleaseInput {
distDir: string;
signedManifestPath: string;
sbomPath: string;
outputPath: string;
verifierModulePath: string;
trustPolicyPath: string;
protectedState: Record<string, unknown>;
now: string;
verifySignedReleaseEnvelope?: (input: any) => any;
}
export function buildReleasePackage(input: BuildReleaseInput): Promise<{
releaseId: string;
manifestDigest: string;
archivePath: string;
}>;

88
scripts/build-release.mjs Executable file
View File

@@ -0,0 +1,88 @@
#!/usr/bin/env node
import { lstat, mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises';
import { dirname, resolve } from 'node:path';
import { pathToFileURL } from 'node:url';
import { canonicalValueBytes } from '../src/security/canonical-digest.ts';
import {
createDeterministicTar, enumerateRegularFiles, packageIndexFor, parseDeterministicTar, validatePackageFiles,
verifySignedRelease, writeArchiveFiles,
} from './deploy/lib/release-common.mjs';
function parseArgs(args) {
const allowed = new Set(['dist', 'signed-manifest', 'sbom', 'output', 'verifier-module', 'trust-policy', 'protected-state', 'now']);
const values = {};
for (let index = 0; index < args.length; index += 2) {
const token = args[index];
const value = args[index + 1];
if (typeof token !== 'string' || !token.startsWith('--') || typeof value !== 'string' || value.length === 0) throw new Error('Release package arguments must be complete name/value pairs.');
const name = token.slice(2);
if (!allowed.has(name) || Object.hasOwn(values, name)) throw new Error(`Unknown or duplicate release package argument: ${token}`);
values[name] = value;
}
if ([...allowed].some((name) => !Object.hasOwn(values, name))) throw new Error('Release package arguments are incomplete.');
let protectedState;
try { protectedState = JSON.parse(values['protected-state']); } catch { throw new Error('Protected state must be valid JSON.'); }
return {
distDir: resolve(values.dist), signedManifestPath: resolve(values['signed-manifest']), sbomPath: resolve(values.sbom),
outputPath: resolve(values.output), verifierModulePath: resolve(values['verifier-module']), trustPolicyPath: resolve(values['trust-policy']),
protectedState, now: values.now,
};
}
export async function buildReleasePackage(input) {
const outputInfo = await lstat(input.outputPath).catch((error) => error?.code === 'ENOENT' ? null : Promise.reject(error));
if (outputInfo !== null) throw new Error('Release archive output already exists.');
const verified = await verifySignedRelease(input);
const { admission, manifest, signedReleaseBytes } = verified;
const sourceFiles = await enumerateRegularFiles(input.distDir);
const actualPaths = sourceFiles.map((file) => file.publicPath);
const expectedPaths = [...manifest.deployFiles].sort((left, right) => Buffer.compare(Buffer.from(left), Buffer.from(right)));
if (actualPaths.length !== expectedPaths.length || actualPaths.some((path, index) => path !== expectedPaths[index])) {
throw new Error('dist files must exactly match the signed deploy closure.');
}
const sourceByPath = new Map(sourceFiles.map((file) => [file.publicPath, file.path]));
const artifactFiles = [];
for (const artifact of manifest.artifacts) {
if (artifact.path.startsWith('.release/')) throw new Error('Signed deploy closure uses the reserved .release path.');
const source = sourceByPath.get(artifact.path);
if (source === undefined) throw new Error(`Signed artifact is missing: ${artifact.path}`);
const bytes = await readFile(source);
const digest = (await import('../src/security/canonical-digest.ts')).byteDigest(bytes);
if (digest !== artifact.sha256 || bytes.length !== artifact.bytes) throw new Error(`Artifact hash or size mismatch: ${artifact.path}`);
artifactFiles.push({ path: artifact.path, bytes });
}
const sbomBytes = await readFile(input.sbomPath);
const index = packageIndexFor({ admission, manifest, signedBytes: signedReleaseBytes, sbomBytes });
const archiveBytes = createDeterministicTar([
...artifactFiles,
{ path: '.release/package-index.json', bytes: canonicalValueBytes(index) },
{ path: '.release/sbom.cdx.json', bytes: sbomBytes },
{ path: '.release/signed-release-manifest.json', bytes: signedReleaseBytes },
]);
const temporary = `${input.outputPath}.partial-${process.pid}`;
await mkdir(dirname(input.outputPath), { recursive: true });
await writeFile(temporary, archiveBytes, { flag: 'wx', mode: 0o400 });
const verifyRoot = `${input.outputPath}.verify-${process.pid}`;
try {
const parsed = parseDeterministicTar(await readFile(temporary));
validatePackageFiles(parsed);
await writeArchiveFiles(parsed, verifyRoot);
const extracted = await enumerateRegularFiles(verifyRoot);
if (extracted.length !== parsed.length) throw new Error('Extracted release archive closure mismatch.');
await rename(temporary, input.outputPath);
} finally {
await rm(verifyRoot, { recursive: true, force: true });
await rm(temporary, { force: true });
}
return { releaseId: index.releaseId, manifestDigest: admission.manifestDigest, archivePath: input.outputPath };
}
async function main() {
const result = await buildReleasePackage(parseArgs(process.argv.slice(2)));
process.stdout.write(`${JSON.stringify(result)}\n`);
}
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
main().catch((error) => { process.stderr.write(`Release packaging failed: ${error instanceof Error ? error.message : 'unknown error'}\n`); process.exitCode = 1; });
}

View File

@@ -0,0 +1,15 @@
#!/usr/bin/env node
import { pathToFileURL } from 'node:url';
import { resolve } from 'node:path';
import { activateRelease } from './lib/release-transaction.mjs';
async function main() {
const args = process.argv.slice(2);
if (args.length < 7 || args.length > 8) throw new Error('Usage: activate-release.mjs <root> <transaction-id> <expected-current|none> <expected-fence> <verifier-module> <trust-policy> <trusted-now> [test-crash-point]');
const [root, transactionId, expectedCurrentManifestDigest, expectedFence, verifierModulePath, trustPolicyPath, now, crashPoint] = args;
if (crashPoint !== undefined && process.env.PORTFOLIO_DEPLOY_TEST_HOOKS !== '1') throw new Error('Crash hooks are disabled outside the isolated test harness.');
const receipt = await activateRelease({ root: resolve(root), transactionId, expectedCurrentManifestDigest, expectedFence: Number(expectedFence),
verifierModulePath: resolve(verifierModulePath), trustPolicyPath: resolve(trustPolicyPath), now, crashPoint });
process.stdout.write(`${JSON.stringify(receipt)}\n`);
}
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) main().catch((error) => { process.stderr.write(`Release activation failed: ${error instanceof Error ? error.message : 'unknown error'}\n`); process.exitCode = 1; });

View File

@@ -0,0 +1,15 @@
#!/usr/bin/env bash
set -euo pipefail
IFS=$'\n\t'
if [[ $# -lt 1 ]]; then
echo 'Usage: activate-release.sh <dedicated-root> ...' >&2
exit 64
fi
release_root=$1
if [[ "$release_root" != /* || "$release_root" == / || ! -f "$release_root/.portfolio-release-root" ]]; then
echo 'Release root must be an explicit dedicated directory with a valid sentinel.' >&2
exit 65
fi
script_dir=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)
exec node "$script_dir/activate-release.mjs" "$@"

View File

@@ -0,0 +1,7 @@
export interface ArchiveFile {
path: string;
bytes: Uint8Array;
}
export function createDeterministicTar(files: readonly ArchiveFile[]): Buffer;
export function parseDeterministicTar(source: Uint8Array): ArchiveFile[];

View File

@@ -0,0 +1,343 @@
import { createHash } from 'node:crypto';
import { chmod, lstat, mkdir, open, readFile, readdir, realpath, rename, rm, stat, writeFile } from 'node:fs/promises';
import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
import { pathToFileURL } from 'node:url';
import { byteDigest, canonicalJsonDigest, canonicalValueBytes, isSha256Hex, parseCanonicalJson } from '../../../src/security/canonical-digest.ts';
import { validatePublicPath } from '../../../src/security/production-input-root.ts';
import { validateReleaseManifest } from '../../../src/security/release-manifest.ts';
const encoder = new TextEncoder();
const reservedPrefix = '.release/';
const safeToken = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/u;
const timestamp = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,3})?Z$/u;
export function exactObject(value, fields, label) {
if (value === null || typeof value !== 'object' || Array.isArray(value)) throw new Error(`${label} must be an object.`);
const actual = Object.keys(value).sort();
const expected = [...fields].sort();
if (actual.length !== expected.length || actual.some((field, index) => field !== expected[index])) {
throw new Error(`${label} contains missing or unknown fields.`);
}
return value;
}
export function assertToken(value, label) {
if (typeof value !== 'string' || !safeToken.test(value)) throw new Error(`${label} is invalid.`);
return value;
}
export function assertTimestamp(value, label) {
if (typeof value !== 'string' || !timestamp.test(value) || Number.isNaN(Date.parse(value))) throw new Error(`${label} is invalid.`);
return value;
}
export function assertDigest(value, label) {
if (!isSha256Hex(value)) throw new Error(`${label} is invalid.`);
return value;
}
export async function readCanonicalFile(path, label) {
const info = await lstat(path);
if (!info.isFile() || info.isSymbolicLink()) throw new Error(`${label} must be a regular file.`);
return parseCanonicalJson(await readFile(path));
}
export async function loadVerifier(modulePath) {
if (!isAbsolute(modulePath)) throw new Error('Verifier module path must be absolute.');
const info = await lstat(modulePath);
if (!info.isFile() || info.isSymbolicLink()) throw new Error('Verifier module must be a regular file.');
const module = await import(/* @vite-ignore */ pathToFileURL(await realpath(modulePath)).href);
if (typeof module.verifySignedReleaseEnvelope !== 'function') throw new Error('Verifier module does not expose verifySignedReleaseEnvelope.');
return module.verifySignedReleaseEnvelope;
}
export function validateProtectedState(value) {
const state = exactObject(value, ['latestLedgerHeadHash', 'latestLedgerRevision', 'currentManifestDigest', 'publisherFence'], 'protected state');
assertDigest(state.latestLedgerHeadHash, 'protected ledger head');
if (!Number.isSafeInteger(state.latestLedgerRevision) || state.latestLedgerRevision < 0 ||
!Number.isSafeInteger(state.publisherFence) || state.publisherFence < 0 ||
(state.currentManifestDigest !== null && !isSha256Hex(state.currentManifestDigest))) throw new Error('Protected state is invalid.');
return state;
}
export function validateAdmission(value, protectedState, now) {
const admission = exactObject(value, [
'schema', 'manifestDigest', 'manifestPayload', 'expiresAt', 'predecessorManifestDigest', 'latestLedgerHeadHash',
'latestLedgerRevision', 'expectedFence', 'signatureVerified',
], 'verified release admission');
if (admission.schema !== 'portfolio-verified-release-admission/v1' || admission.signatureVerified !== true) {
throw new Error('Release signature was not verified.');
}
assertDigest(admission.manifestDigest, 'manifest digest');
assertTimestamp(now, 'trusted time');
assertTimestamp(admission.expiresAt, 'admission expiry');
if (Date.parse(now) >= Date.parse(admission.expiresAt)) throw new Error('Release admission is expired.');
if (admission.predecessorManifestDigest !== null) assertDigest(admission.predecessorManifestDigest, 'predecessor manifest digest');
const state = validateProtectedState(protectedState);
if (admission.latestLedgerHeadHash !== state.latestLedgerHeadHash || admission.latestLedgerRevision !== state.latestLedgerRevision) {
throw new Error('Release admission is stale relative to the protected ledger.');
}
if (admission.predecessorManifestDigest !== state.currentManifestDigest || admission.expectedFence !== state.publisherFence + 1) {
throw new Error('Release admission predecessor or fence is stale.');
}
const manifest = validateReleaseManifest(admission.manifestPayload, { now });
if (manifest.expiresAt !== admission.expiresAt || manifest.currentManifestDigest !== admission.predecessorManifestDigest ||
manifest.predecessorManifestDigest !== admission.predecessorManifestDigest) throw new Error('Admission does not bind the embedded release manifest.');
return { admission, manifest };
}
export async function verifySignedRelease({ signedManifestPath, trustPolicyPath, verifierModulePath, verifySignedReleaseEnvelope, protectedState, now }) {
const [signedRelease, trustPolicy] = await Promise.all([
readCanonicalFile(signedManifestPath, 'signed release'), readCanonicalFile(trustPolicyPath, 'trust policy'),
]);
const verifier = verifySignedReleaseEnvelope ?? await loadVerifier(verifierModulePath);
if (typeof verifier !== 'function') throw new Error('A release signature verifier is required.');
const result = await verifier({ signedRelease, trustPolicy, now, protectedState });
const verified = validateAdmission(result, protectedState, now);
return { ...verified, signedRelease, signedReleaseBytes: canonicalValueBytes(signedRelease) };
}
export async function enumerateRegularFiles(root) {
const rootPath = await realpath(root);
const output = [];
async function visit(directory, prefix = '') {
const entries = await readdir(directory, { withFileTypes: true });
entries.sort((left, right) => Buffer.compare(Buffer.from(left.name), Buffer.from(right.name)));
for (const entry of entries) {
const path = join(directory, entry.name);
const publicPath = prefix === '' ? entry.name : `${prefix}/${entry.name}`;
if (entry.isSymbolicLink()) throw new Error(`Symlink is forbidden in release closure: ${publicPath}`);
if (entry.isDirectory()) await visit(path, publicPath);
else if (entry.isFile()) output.push({ path, publicPath });
else throw new Error(`Non-regular release input is forbidden: ${publicPath}`);
}
}
await visit(rootPath);
return output;
}
function splitTarPath(path) {
const bytes = encoder.encode(path);
if (bytes.length <= 100) return { name: path, prefix: '' };
const segments = path.split('/');
for (let index = segments.length - 1; index > 0; index -= 1) {
const prefix = segments.slice(0, index).join('/');
const name = segments.slice(index).join('/');
if (encoder.encode(prefix).length <= 155 && encoder.encode(name).length <= 100) return { name, prefix };
}
throw new Error(`Release path is too long for deterministic ustar: ${path}`);
}
function writeString(target, offset, length, value) {
const bytes = encoder.encode(value);
if (bytes.length > length) throw new Error('Tar header value exceeds its field.');
target.set(bytes, offset);
}
function writeOctal(target, offset, length, value) {
const text = value.toString(8).padStart(length - 1, '0');
if (text.length !== length - 1) throw new Error('Tar numeric field overflow.');
writeString(target, offset, length, `${text}\0`);
}
function tarHeader(path, size) {
const header = new Uint8Array(512);
const { name, prefix } = splitTarPath(path);
writeString(header, 0, 100, name);
writeOctal(header, 100, 8, 0o444);
writeOctal(header, 108, 8, 0);
writeOctal(header, 116, 8, 0);
writeOctal(header, 124, 12, size);
writeOctal(header, 136, 12, 0);
header.fill(0x20, 148, 156);
writeString(header, 156, 1, '0');
writeString(header, 257, 6, 'ustar\0');
writeString(header, 263, 2, '00');
writeString(header, 345, 155, prefix);
const checksum = header.reduce((sum, byte) => sum + byte, 0);
const checksumText = checksum.toString(8).padStart(6, '0');
writeString(header, 148, 8, `${checksumText}\0 `);
return header;
}
export function createDeterministicTar(files) {
const ordered = [...files].sort((left, right) => Buffer.compare(Buffer.from(left.path), Buffer.from(right.path)));
const seen = new Set();
const chunks = [];
for (const file of ordered) {
validatePublicPath(file.path);
if (seen.has(file.path)) throw new Error(`Duplicate release archive path: ${file.path}`);
seen.add(file.path);
const bytes = new Uint8Array(file.bytes);
chunks.push(tarHeader(file.path, bytes.length), bytes);
const remainder = bytes.length % 512;
if (remainder !== 0) chunks.push(new Uint8Array(512 - remainder));
}
chunks.push(new Uint8Array(1024));
return Buffer.concat(chunks.map((chunk) => Buffer.from(chunk)));
}
function readString(bytes, start, length) {
const end = bytes.indexOf(0, start);
return new TextDecoder('utf-8', { fatal: true }).decode(bytes.subarray(start, end === -1 || end > start + length ? start + length : end));
}
function readOctal(bytes, start, length, label) {
const text = readString(bytes, start, length).trim();
if (!/^[0-7]+$/u.test(text)) throw new Error(`Invalid tar ${label}.`);
const value = Number.parseInt(text, 8);
if (!Number.isSafeInteger(value) || value < 0) throw new Error(`Invalid tar ${label}.`);
return value;
}
export function parseDeterministicTar(source) {
const bytes = new Uint8Array(source);
const files = [];
const seen = new Set();
let offset = 0;
let zeroBlocks = 0;
while (offset + 512 <= bytes.length) {
const header = bytes.subarray(offset, offset + 512);
offset += 512;
if (header.every((byte) => byte === 0)) {
zeroBlocks += 1;
if (zeroBlocks === 2) break;
continue;
}
if (zeroBlocks !== 0) throw new Error('Malformed tar end marker.');
const expectedChecksum = readOctal(header, 148, 8, 'checksum');
const checksumHeader = new Uint8Array(header);
checksumHeader.fill(0x20, 148, 156);
if (checksumHeader.reduce((sum, byte) => sum + byte, 0) !== expectedChecksum) throw new Error('Tar checksum mismatch.');
if (readString(header, 257, 6) !== 'ustar' || String.fromCharCode(header[156] ?? 0) !== '0') throw new Error('Only deterministic regular ustar entries are accepted.');
const name = readString(header, 0, 100);
const prefix = readString(header, 345, 155);
const path = prefix === '' ? name : `${prefix}/${name}`;
validatePublicPath(path);
if (seen.has(path)) throw new Error(`Duplicate archive entry: ${path}`);
seen.add(path);
const size = readOctal(header, 124, 12, 'size');
if (offset + size > bytes.length) throw new Error('Truncated tar entry.');
files.push({ path, bytes: bytes.slice(offset, offset + size) });
offset += Math.ceil(size / 512) * 512;
}
if (zeroBlocks !== 2 || offset !== bytes.length) throw new Error('Tar archive has trailing or missing data.');
return files;
}
export function packageIndexFor({ admission, manifest, signedBytes, sbomBytes }) {
const artifacts = [...manifest.artifacts].sort((left, right) => Buffer.compare(Buffer.from(left.path), Buffer.from(right.path)));
return {
schema: 'portfolio-release-package/v1',
buildId: manifest.buildId,
releaseId: `${manifest.buildId}-${admission.manifestDigest}`,
manifestDigest: admission.manifestDigest,
predecessorManifestDigest: admission.predecessorManifestDigest,
expiresAt: admission.expiresAt,
expectedFence: admission.expectedFence,
latestLedgerHeadHash: admission.latestLedgerHeadHash,
latestLedgerRevision: admission.latestLedgerRevision,
signedManifest: { path: '.release/signed-release-manifest.json', sha256: byteDigest(signedBytes), bytes: signedBytes.length },
sbom: { path: '.release/sbom.cdx.json', sha256: byteDigest(sbomBytes), bytes: sbomBytes.length },
artifacts,
};
}
export function validatePackageFiles(files) {
const byPath = new Map(files.map((file) => [file.path, file]));
const indexFile = byPath.get('.release/package-index.json');
if (indexFile === undefined) throw new Error('Release package index is missing.');
const index = exactObject(parseCanonicalJson(indexFile.bytes), [
'schema', 'buildId', 'releaseId', 'manifestDigest', 'predecessorManifestDigest', 'expiresAt', 'expectedFence',
'latestLedgerHeadHash', 'latestLedgerRevision', 'signedManifest', 'sbom', 'artifacts',
], 'release package index');
if (index.schema !== 'portfolio-release-package/v1') throw new Error('Release package schema is invalid.');
assertToken(index.buildId, 'package buildId');
assertToken(index.releaseId, 'package releaseId');
assertDigest(index.manifestDigest, 'package manifest digest');
assertTimestamp(index.expiresAt, 'package expiry');
if (!Number.isSafeInteger(index.expectedFence) || index.expectedFence < 1) throw new Error('Package fence is invalid.');
const metadata = [index.signedManifest, index.sbom];
for (const record of metadata) {
exactObject(record, ['path', 'sha256', 'bytes'], 'package metadata record');
const file = byPath.get(record.path);
if (file === undefined || byteDigest(file.bytes) !== record.sha256 || file.bytes.length !== record.bytes) throw new Error('Package metadata hash or size mismatch.');
}
if (!Array.isArray(index.artifacts) || index.artifacts.length === 0) throw new Error('Package artifact closure is empty.');
const expectedPaths = new Set(['.release/package-index.json', '.release/signed-release-manifest.json', '.release/sbom.cdx.json']);
for (const artifact of index.artifacts) {
exactObject(artifact, ['path', 'sha256', 'bytes', 'encoding'], 'package artifact');
if (typeof artifact.path !== 'string' || artifact.path.startsWith(reservedPrefix)) throw new Error('Package artifact uses a reserved path.');
const file = byPath.get(artifact.path);
if (file === undefined || byteDigest(file.bytes) !== artifact.sha256 || file.bytes.length !== artifact.bytes) throw new Error(`Artifact hash or size mismatch: ${artifact.path}`);
expectedPaths.add(artifact.path);
}
if (expectedPaths.size !== byPath.size || [...byPath.keys()].some((path) => !expectedPaths.has(path))) throw new Error('Release package files do not exactly match the signed closure.');
return { index, byPath };
}
export async function writeArchiveFiles(files, targetRoot) {
await mkdir(targetRoot, { recursive: false, mode: 0o700 });
const root = resolve(targetRoot);
for (const file of files) {
const destination = resolve(root, file.path);
if (relative(root, destination).startsWith(`..${sep}`) || destination === root) throw new Error('Archive entry escapes staging root.');
await mkdir(dirname(destination), { recursive: true, mode: 0o700 });
await writeFile(destination, file.bytes, { flag: 'wx', mode: 0o600 });
}
}
export async function makeImmutable(root, uid, gid) {
if (!Number.isSafeInteger(uid) || uid < 0 || !Number.isSafeInteger(gid) || gid < 0) throw new Error('Release owner UID/GID is invalid.');
async function visit(path) {
const info = await lstat(path);
if (info.isSymbolicLink()) throw new Error('Symlink found in immutable release.');
if (info.uid !== uid || info.gid !== gid) throw new Error(`Release ownership mismatch at ${path}.`);
if (info.isDirectory()) {
for (const entry of await readdir(path)) await visit(join(path, entry));
await chmod(path, 0o555);
} else if (info.isFile()) await chmod(path, 0o444);
else throw new Error('Immutable release contains a non-file entry.');
}
await visit(root);
}
export async function assertReleaseRoot(rootPath) {
if (typeof rootPath !== 'string' || !isAbsolute(rootPath)) throw new Error('Release root must be an explicit absolute path.');
const normalized = resolve(rootPath);
if (normalized === '/' || normalized.split('/').filter(Boolean).length < 2) throw new Error('Broad release root is forbidden.');
const info = await lstat(normalized);
if (!info.isDirectory() || info.isSymbolicLink()) throw new Error('Release root must be a real dedicated directory.');
const marker = await readFile(join(normalized, '.portfolio-release-root'), 'utf8').catch((error) => {
if (error?.code === 'ENOENT') throw new Error('Release root sentinel is missing or invalid.');
throw error;
});
if (marker !== 'portfolio-release-root/v1\n') throw new Error('Release root sentinel is missing or invalid.');
return normalized;
}
export async function atomicCanonicalWrite(path, value, mode = 0o600) {
const temporary = `${path}.next-${process.pid}`;
const handle = await open(temporary, 'wx', mode);
try {
await handle.writeFile(canonicalValueBytes(value));
await handle.sync();
} finally {
await handle.close();
}
await rename(temporary, path);
}
export async function safeRemoveStage(root, stageName) {
assertToken(stageName, 'stage name');
const staging = join(root, 'staging');
const target = join(staging, stageName);
const rel = relative(staging, target);
if (rel.startsWith('..') || isAbsolute(rel)) throw new Error('Stage cleanup escaped its root.');
await rm(target, { recursive: true, force: true });
}
export function digestBytes(bytes) {
return createHash('sha256').update(bytes).digest('hex');
}

View File

@@ -0,0 +1,34 @@
export interface TransactionInput {
root: string;
archivePath: string;
transactionId: string;
verifierModulePath: string;
trustPolicyPath: string;
uid: number;
gid: number;
now?: string;
clock?: () => string;
verifySignedReleaseEnvelope?: (input: any) => any;
smokeCheck?: (input: { releaseDir: string; manifestDigest: string }) => Promise<Uint8Array>;
smokeCommand?: string;
expectedCurrentManifestDigest?: string | null;
expectedFence?: number;
crashPoint?: 'before-swap' | 'after-swap';
targetReleaseId?: string;
}
export interface ControlState {
schema: 'portfolio-release-control-state/v1';
currentManifestDigest: string | null;
currentReleaseId: string | null;
publisherFence: number;
latestLedgerHeadHash: string;
latestLedgerRevision: number;
activeTransaction: null | Record<string, unknown>;
}
export function readControlState(root: string): Promise<ControlState>;
export function prepareRelease(input: TransactionInput): Promise<Record<string, unknown>>;
export function activateRelease(input: TransactionInput): Promise<Record<string, any>>;
export function rollbackRelease(input: TransactionInput): Promise<Record<string, any>>;
export function cleanupOrphanStages(root: string): Promise<string[]>;

View File

@@ -0,0 +1,299 @@
import { spawn } from 'node:child_process';
import { chmod, lstat, mkdir, open, readFile, readlink, readdir, rename, symlink, unlink } from 'node:fs/promises';
import { isAbsolute, join, resolve } from 'node:path';
import { byteDigest, canonicalJsonDigest, canonicalValueBytes, parseCanonicalJson } from '../../../src/security/canonical-digest.ts';
import {
assertDigest, assertReleaseRoot, assertTimestamp, assertToken, atomicCanonicalWrite, exactObject, makeImmutable,
safeRemoveStage, verifySignedRelease, writeArchiveFiles,
} from './release-common.mjs';
import { verifyStagedRelease } from '../verify-staged-release.mjs';
const stateFields = ['schema', 'currentManifestDigest', 'currentReleaseId', 'publisherFence', 'latestLedgerHeadHash', 'latestLedgerRevision', 'activeTransaction'];
const transactionFields = ['transactionId', 'manifestDigest', 'releaseId', 'expectedFence', 'phase', 'readinessReceiptDigest'];
const readinessFields = ['schema', 'transactionId', 'manifestDigest', 'releaseId', 'predecessorManifestDigest', 'expectedFence', 'latestLedgerHeadHash', 'latestLedgerRevision', 'expiresAt', 'packageDigest', 'smokeEvidenceDigest', 'createdAt'];
function nowFrom(input) {
const value = typeof input === 'function' ? input() : input ?? new Date().toISOString();
return assertTimestamp(value, 'trusted current time');
}
function normalizeExpectedDigest(value) {
if (value === null || value === 'none') return null;
return assertDigest(value, 'expected current manifest digest');
}
export function validateControlState(value) {
const state = exactObject(value, stateFields, 'release control state');
if (state.schema !== 'portfolio-release-control-state/v1') throw new Error('Release control state schema is invalid.');
if (state.currentManifestDigest !== null) assertDigest(state.currentManifestDigest, 'current manifest digest');
if (state.currentReleaseId !== null) assertToken(state.currentReleaseId, 'current release ID');
if (!Number.isSafeInteger(state.publisherFence) || state.publisherFence < 0 || !Number.isSafeInteger(state.latestLedgerRevision) || state.latestLedgerRevision < 0) throw new Error('Release control counters are invalid.');
assertDigest(state.latestLedgerHeadHash, 'live ledger head');
if ((state.currentManifestDigest === null) !== (state.currentReleaseId === null)) throw new Error('Current release pointer state is incoherent.');
if (state.activeTransaction !== null) {
const tx = exactObject(state.activeTransaction, transactionFields, 'active release transaction');
assertToken(tx.transactionId, 'transaction ID'); assertDigest(tx.manifestDigest, 'transaction manifest digest'); assertToken(tx.releaseId, 'transaction release ID');
if (!Number.isSafeInteger(tx.expectedFence) || tx.expectedFence !== state.publisherFence + 1 || !['preparing', 'ready', 'committing'].includes(tx.phase)) throw new Error('Active transaction fence or phase is invalid.');
if (tx.readinessReceiptDigest !== null) assertDigest(tx.readinessReceiptDigest, 'readiness receipt digest');
}
return state;
}
export async function readControlState(root) {
return validateControlState(parseCanonicalJson(await readFile(join(root, 'control/state.json'))));
}
async function withExclusiveLock(root, callback) {
const lockPath = join(root, 'control', 'transaction.lock');
let handle;
for (let attempt = 0; attempt < 200; attempt += 1) {
try {
handle = await open(lockPath, 'wx', 0o600);
await handle.writeFile(`${process.pid}\n`);
await handle.sync();
break;
} catch (error) {
if (error?.code !== 'EEXIST') throw error;
const owner = await readFile(lockPath, 'utf8').catch(() => '');
if (!/^\d+\n$/u.test(owner)) throw new Error('Release transaction lock is malformed; protected recovery is required.');
try { process.kill(Number(owner.trim()), 0); } catch (probeError) {
if (probeError?.code !== 'ESRCH') throw probeError;
await unlink(lockPath).catch((unlinkError) => { if (unlinkError?.code !== 'ENOENT') throw unlinkError; });
continue;
}
await new Promise((resolvePromise) => setTimeout(resolvePromise, 5));
}
}
if (handle === undefined) throw new Error('Could not obtain the exclusive release transaction lock.');
try { return await callback(); } finally { await handle.close(); await unlink(lockPath).catch(() => undefined); }
}
async function runSmokeCommand(command, releaseDir, manifestDigest) {
if (typeof command !== 'string' || !isAbsolute(command)) throw new Error('Smoke command must be an absolute executable path.');
const info = await lstat(command);
if (!info.isFile() || info.isSymbolicLink() || (info.mode & 0o111) === 0) throw new Error('Smoke command is not an executable regular file.');
return await new Promise((resolvePromise, reject) => {
const child = spawn(command, ['--release-dir', releaseDir, '--manifest-digest', manifestDigest], { shell: false, stdio: ['ignore', 'pipe', 'pipe'] });
const stdout = []; const stderr = [];
const timer = setTimeout(() => { child.kill('SIGKILL'); reject(new Error('Staged smoke check timed out.')); }, 15_000);
child.stdout.on('data', (chunk) => stdout.push(chunk)); child.stderr.on('data', (chunk) => stderr.push(chunk));
child.once('error', (error) => { clearTimeout(timer); reject(error); });
child.once('close', (code) => {
clearTimeout(timer);
if (code !== 0) reject(new Error(`Staged smoke check failed: ${Buffer.concat(stderr).toString('utf8').slice(0, 200)}`));
else resolvePromise(Buffer.concat(stdout));
});
});
}
async function prepareUnlocked(input) {
const root = await assertReleaseRoot(input.root);
const transactionId = assertToken(input.transactionId, 'transaction ID');
const statePath = join(root, 'control/state.json');
const state = await readControlState(root);
if (state.activeTransaction !== null) throw new Error('Another release transaction is already active.');
const firstNow = nowFrom(input.clock ?? input.now);
const verified = await verifyStagedRelease({
archivePath: resolve(input.archivePath), verifierModulePath: input.verifierModulePath,
verifySignedReleaseEnvelope: input.verifySignedReleaseEnvelope, trustPolicyPath: input.trustPolicyPath,
protectedState: {
latestLedgerHeadHash: state.latestLedgerHeadHash, latestLedgerRevision: state.latestLedgerRevision,
currentManifestDigest: state.currentManifestDigest, publisherFence: state.publisherFence,
}, now: firstNow,
});
const { index, admission, files } = verified;
if (input.rollbackTargetReleaseId !== undefined) {
const targetId = assertToken(input.rollbackTargetReleaseId, 'rollback target release ID');
const targetIndex = parseCanonicalJson(await readFile(join(root, 'releases', targetId, '.release/package-index.json')));
if (targetIndex.manifestDigest === index.manifestDigest) throw new Error('Rollback must use a newly signed manifest, not replay an old one.');
const left = canonicalValueBytes(targetIndex.artifacts);
const right = canonicalValueBytes(index.artifacts);
if (!Buffer.from(left).equals(Buffer.from(right))) throw new Error('Rollback package does not bind the selected immutable release artifact closure.');
}
const activeTransaction = {
transactionId, manifestDigest: admission.manifestDigest, releaseId: index.releaseId, expectedFence: admission.expectedFence,
phase: 'preparing', readinessReceiptDigest: null,
};
await atomicCanonicalWrite(statePath, { ...state, activeTransaction });
const partialName = `${transactionId}.partial`;
const partialDir = join(root, 'staging', partialName);
const releaseDir = join(root, 'releases', index.releaseId);
try {
await mkdir(join(root, 'staging'), { recursive: true, mode: 0o750 });
await mkdir(join(root, 'releases'), { recursive: true, mode: 0o750 });
await mkdir(join(root, 'receipts', 'readiness'), { recursive: true, mode: 0o750 });
await safeRemoveStage(root, partialName);
await writeArchiveFiles(files.map((file) => file.path.startsWith('.release/')
? file
: { ...file, path: `public/${file.path}` }), partialDir);
await makeImmutable(partialDir, input.uid, input.gid);
const publicDir = join(partialDir, 'public');
const smokeBytes = input.smokeCheck !== undefined
? await input.smokeCheck({ releaseDir: publicDir, manifestDigest: admission.manifestDigest })
: await runSmokeCommand(input.smokeCommand, publicDir, admission.manifestDigest);
const secondNow = nowFrom(input.clock ?? input.now);
if (Date.parse(secondNow) >= Date.parse(admission.expiresAt)) throw new Error('Release expired while staging or smoke-checking.');
const stateAfterSmoke = await readControlState(root);
if (stateAfterSmoke.latestLedgerHeadHash !== admission.latestLedgerHeadHash || stateAfterSmoke.latestLedgerRevision !== admission.latestLedgerRevision ||
stateAfterSmoke.currentManifestDigest !== admission.predecessorManifestDigest || stateAfterSmoke.publisherFence + 1 !== admission.expectedFence) {
throw new Error('Protected state changed while staging the release.');
}
await chmod(partialDir, 0o755);
await rename(partialDir, releaseDir).catch((error) => {
if (error?.code === 'EEXIST') throw new Error('Immutable release ID already exists.');
throw error;
});
await chmod(releaseDir, 0o555);
const receipt = {
schema: 'portfolio-release-readiness-receipt/v1', transactionId, manifestDigest: admission.manifestDigest,
releaseId: index.releaseId, predecessorManifestDigest: admission.predecessorManifestDigest, expectedFence: admission.expectedFence,
latestLedgerHeadHash: admission.latestLedgerHeadHash, latestLedgerRevision: admission.latestLedgerRevision,
expiresAt: admission.expiresAt, packageDigest: byteDigest(await readFile(input.archivePath)),
smokeEvidenceDigest: byteDigest(smokeBytes instanceof Uint8Array ? smokeBytes : Buffer.from(String(smokeBytes))), createdAt: secondNow,
};
const receiptDigest = canonicalJsonDigest('portfolio-release-readiness-receipt/v1', receipt);
const receiptPath = join(root, 'receipts', 'readiness', `${transactionId}.json`);
await atomicCanonicalWrite(receiptPath, receipt, 0o440);
await chmod(receiptPath, 0o440);
await atomicCanonicalWrite(statePath, { ...stateAfterSmoke, activeTransaction: { ...activeTransaction, phase: 'ready', readinessReceiptDigest: receiptDigest } });
return { receipt, receiptDigest, releaseDir };
} catch (error) {
await makeWritable(partialDir).catch(() => undefined);
await safeRemoveStage(root, partialName).catch(() => undefined);
const latest = await readControlState(root).catch(() => null);
if (latest?.activeTransaction?.transactionId === transactionId && latest.activeTransaction.phase !== 'committing') {
await atomicCanonicalWrite(statePath, { ...latest, activeTransaction: null }).catch(() => undefined);
}
throw error;
}
}
export async function prepareRelease(input) {
const root = await assertReleaseRoot(input.root);
return input.lock === false ? prepareUnlocked({ ...input, root }) : withExclusiveLock(root, () => prepareUnlocked({ ...input, root }));
}
function validateReadiness(value) {
const receipt = exactObject(value, readinessFields, 'readiness receipt');
if (receipt.schema !== 'portfolio-release-readiness-receipt/v1') throw new Error('Readiness receipt schema is invalid.');
assertToken(receipt.transactionId, 'readiness transaction ID'); assertDigest(receipt.manifestDigest, 'readiness manifest digest');
assertToken(receipt.releaseId, 'readiness release ID'); assertTimestamp(receipt.expiresAt, 'readiness expiry');
assertDigest(receipt.latestLedgerHeadHash, 'readiness ledger head'); assertDigest(receipt.packageDigest, 'readiness package digest'); assertDigest(receipt.smokeEvidenceDigest, 'smoke evidence digest');
return receipt;
}
async function signedManifestFromRelease(root, releaseId) {
return join(root, 'releases', releaseId, '.release/signed-release-manifest.json');
}
async function currentTarget(root) {
try { return await readlink(join(root, 'current')); } catch (error) { if (error?.code === 'ENOENT') return null; throw error; }
}
async function finalizeActivation(root, state, tx, receipt, committedAt, recovered) {
const newState = { ...state, currentManifestDigest: tx.manifestDigest, currentReleaseId: tx.releaseId, publisherFence: tx.expectedFence, activeTransaction: null };
await mkdir(join(root, 'receipts', 'activation'), { recursive: true, mode: 0o750 });
const activationReceipt = {
schema: 'portfolio-release-activation-receipt/v1', transactionId: tx.transactionId, predecessorManifestDigest: receipt.predecessorManifestDigest,
manifestDigest: tx.manifestDigest, releaseId: tx.releaseId, fence: tx.expectedFence,
readinessReceiptDigest: canonicalJsonDigest('portfolio-release-readiness-receipt/v1', receipt), committedAt, recoveredAfterPointerSwap: recovered,
};
const path = join(root, 'receipts', 'activation', `${tx.transactionId}.json`);
await atomicCanonicalWrite(path, activationReceipt, 0o440);
await chmod(path, 0o440);
await atomicCanonicalWrite(join(root, 'control/state.json'), newState);
return activationReceipt;
}
async function activateUnlocked(input) {
const root = await assertReleaseRoot(input.root);
const transactionId = assertToken(input.transactionId, 'transaction ID');
const expectedCurrent = normalizeExpectedDigest(input.expectedCurrentManifestDigest);
if (!Number.isSafeInteger(input.expectedFence) || input.expectedFence < 1) throw new Error('Expected fence is invalid.');
let state = await readControlState(root);
const tx = state.activeTransaction;
if (tx === null || tx.transactionId !== transactionId || !['ready', 'committing'].includes(tx.phase)) throw new Error('Named transaction is not ready for activation.');
const receipt = validateReadiness(parseCanonicalJson(await readFile(join(root, 'receipts', 'readiness', `${transactionId}.json`))));
if (tx.readinessReceiptDigest !== canonicalJsonDigest('portfolio-release-readiness-receipt/v1', receipt)) throw new Error('Readiness receipt digest does not match the protected transaction.');
const target = `releases/${tx.releaseId}/public`;
const actualCurrent = await currentTarget(root);
if (tx.phase === 'committing' && actualCurrent === target) {
return finalizeActivation(root, state, tx, receipt, nowFrom(input.clock ?? input.now), true);
}
if (state.currentManifestDigest !== expectedCurrent || tx.expectedFence !== input.expectedFence || state.publisherFence + 1 !== input.expectedFence ||
receipt.predecessorManifestDigest !== expectedCurrent || receipt.expectedFence !== input.expectedFence) throw new Error('Activation predecessor or fence is stale.');
const activationNow = nowFrom(input.clock ?? input.now);
if (Date.parse(activationNow) >= Date.parse(receipt.expiresAt)) throw new Error('Release expired before activation.');
const protectedState = {
latestLedgerHeadHash: state.latestLedgerHeadHash, latestLedgerRevision: state.latestLedgerRevision,
currentManifestDigest: state.currentManifestDigest, publisherFence: state.publisherFence,
};
const verified = await verifySignedRelease({
signedManifestPath: await signedManifestFromRelease(root, tx.releaseId), verifierModulePath: input.verifierModulePath,
verifySignedReleaseEnvelope: input.verifySignedReleaseEnvelope, trustPolicyPath: input.trustPolicyPath,
protectedState, now: activationNow,
});
if (verified.admission.manifestDigest !== tx.manifestDigest || verified.admission.expectedFence !== tx.expectedFence ||
verified.admission.latestLedgerHeadHash !== receipt.latestLedgerHeadHash || verified.admission.latestLedgerRevision !== receipt.latestLedgerRevision) {
throw new Error('Activation signature admission does not match readiness.');
}
state = await readControlState(root);
if (state.currentManifestDigest !== expectedCurrent || state.publisherFence + 1 !== input.expectedFence || state.activeTransaction?.transactionId !== transactionId) {
throw new Error('Protected state changed before activation commit.');
}
await atomicCanonicalWrite(join(root, 'control/state.json'), { ...state, activeTransaction: { ...tx, phase: 'committing' } });
const nextPath = join(root, 'current.next');
const nextInfo = await lstat(nextPath).catch((error) => error?.code === 'ENOENT' ? null : Promise.reject(error));
if (nextInfo !== null) {
if (!nextInfo.isSymbolicLink()) throw new Error('current.next is not a symlink and will not be overwritten.');
await unlink(nextPath);
}
await symlink(target, nextPath);
if (input.crashPoint === 'before-swap') throw new Error('Injected crash before pointer swap.');
await rename(nextPath, join(root, 'current'));
if (input.crashPoint === 'after-swap') throw new Error('Injected crash after pointer swap.');
return finalizeActivation(root, state, tx, receipt, activationNow, false);
}
export async function activateRelease(input) {
const root = await assertReleaseRoot(input.root);
return input.lock === false ? activateUnlocked({ ...input, root }) : withExclusiveLock(root, () => activateUnlocked({ ...input, root }));
}
export async function rollbackRelease(input) {
const root = await assertReleaseRoot(input.root);
return withExclusiveLock(root, async () => {
await prepareUnlocked({ ...input, root, rollbackTargetReleaseId: input.targetReleaseId, lock: false });
return activateUnlocked({ ...input, root, lock: false });
});
}
async function makeWritable(path) {
const info = await lstat(path);
if (info.isDirectory()) {
await chmod(path, 0o700);
for (const entry of await readdir(path)) await makeWritable(join(path, entry));
} else if (info.isFile()) await chmod(path, 0o600);
else throw new Error('Orphan staging contains a non-file entry.');
}
export async function cleanupOrphanStages(rootPath) {
const root = await assertReleaseRoot(rootPath);
const staging = join(root, 'staging');
await mkdir(staging, { recursive: true, mode: 0o750 });
const state = await readControlState(root);
const activePrefix = state.activeTransaction === null ? null : `${state.activeTransaction.transactionId}.`;
const removed = [];
for (const entry of await readdir(staging, { withFileTypes: true })) {
if (!entry.isDirectory() || entry.isSymbolicLink() || !/^[A-Za-z0-9][A-Za-z0-9._-]{0,127}\.partial$/u.test(entry.name)) {
throw new Error('Unexpected staging entry blocks orphan cleanup.');
}
if (activePrefix !== null && entry.name.startsWith(activePrefix)) continue;
const path = join(staging, entry.name);
await makeWritable(path);
await safeRemoveStage(root, entry.name);
removed.push(entry.name);
}
return removed;
}

View File

@@ -0,0 +1,19 @@
#!/usr/bin/env node
import { pathToFileURL } from 'node:url';
import { resolve } from 'node:path';
import { cleanupOrphanStages, prepareRelease } from './lib/release-transaction.mjs';
async function main() {
const args = process.argv.slice(2);
if (args.length === 2 && args[1] === '--cleanup-orphans') {
const removed = await cleanupOrphanStages(resolve(args[0]));
process.stdout.write(`${JSON.stringify({ removed })}\n`); return;
}
if (args.length !== 9) throw new Error('Usage: prepare-release.mjs <root> <archive> <transaction-id> <verifier-module> <trust-policy> <smoke-command> <uid> <gid> <trusted-now>');
const [root, archivePath, transactionId, verifierModulePath, trustPolicyPath, smokeCommand, uid, gid, now] = args;
const result = await prepareRelease({ root: resolve(root), archivePath: resolve(archivePath), transactionId,
verifierModulePath: resolve(verifierModulePath), trustPolicyPath: resolve(trustPolicyPath), smokeCommand: resolve(smokeCommand),
uid: Number(uid), gid: Number(gid), now });
process.stdout.write(`${JSON.stringify({ manifestDigest: result.receipt.manifestDigest, releaseId: result.receipt.releaseId })}\n`);
}
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) main().catch((error) => { process.stderr.write(`Release preparation failed: ${error instanceof Error ? error.message : 'unknown error'}\n`); process.exitCode = 1; });

View File

@@ -0,0 +1,15 @@
#!/usr/bin/env bash
set -euo pipefail
IFS=$'\n\t'
if [[ $# -lt 1 ]]; then
echo 'Usage: prepare-release.sh <dedicated-root> ...' >&2
exit 64
fi
release_root=$1
if [[ "$release_root" != /* || "$release_root" == / || ! -f "$release_root/.portfolio-release-root" ]]; then
echo 'Release root must be an explicit dedicated directory with a valid sentinel.' >&2
exit 65
fi
script_dir=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)
exec node "$script_dir/prepare-release.mjs" "$@"

View File

@@ -0,0 +1,15 @@
#!/usr/bin/env node
import { pathToFileURL } from 'node:url';
import { resolve } from 'node:path';
import { rollbackRelease } from './lib/release-transaction.mjs';
async function main() {
const args = process.argv.slice(2);
if (args.length !== 12) throw new Error('Usage: rollback-release.mjs <root> <target-release-id> <new-release-archive> <transaction-id> <verifier-module> <trust-policy> <smoke-command> <uid> <gid> <expected-current> <expected-fence> <trusted-now>');
const [root, targetReleaseId, archivePath, transactionId, verifierModulePath, trustPolicyPath, smokeCommand, uid, gid, expectedCurrentManifestDigest, expectedFence, now] = args;
const receipt = await rollbackRelease({ root: resolve(root), targetReleaseId, archivePath: resolve(archivePath), transactionId,
verifierModulePath: resolve(verifierModulePath), trustPolicyPath: resolve(trustPolicyPath), smokeCommand: resolve(smokeCommand),
uid: Number(uid), gid: Number(gid), expectedCurrentManifestDigest, expectedFence: Number(expectedFence), now });
process.stdout.write(`${JSON.stringify(receipt)}\n`);
}
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) main().catch((error) => { process.stderr.write(`Release rollback failed: ${error instanceof Error ? error.message : 'unknown error'}\n`); process.exitCode = 1; });

View File

@@ -0,0 +1,15 @@
#!/usr/bin/env bash
set -euo pipefail
IFS=$'\n\t'
if [[ $# -lt 1 ]]; then
echo 'Usage: rollback-release.sh <dedicated-root> ...' >&2
exit 64
fi
release_root=$1
if [[ "$release_root" != /* || "$release_root" == / || ! -f "$release_root/.portfolio-release-root" ]]; then
echo 'Release root must be an explicit dedicated directory with a valid sentinel.' >&2
exit 65
fi
script_dir=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)
exec node "$script_dir/rollback-release.mjs" "$@"

View File

@@ -0,0 +1,39 @@
#!/usr/bin/env node
import { mkdtemp, readFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join, resolve } from 'node:path';
import { pathToFileURL } from 'node:url';
import { canonicalValueBytes } from '../../src/security/canonical-digest.ts';
import { parseDeterministicTar, validatePackageFiles, verifySignedRelease } from './lib/release-common.mjs';
export async function verifyStagedRelease({ archivePath, verifierModulePath, verifySignedReleaseEnvelope, trustPolicyPath, protectedState, now }) {
const files = parseDeterministicTar(await readFile(archivePath));
const { index, byPath } = validatePackageFiles(files);
const temporaryRoot = await mkdtemp(join(tmpdir(), 'portfolio-release-verify-'));
const signedPath = join(temporaryRoot, 'signed-release.json');
const { writeFile, rm } = await import('node:fs/promises');
await writeFile(signedPath, byPath.get('.release/signed-release-manifest.json').bytes, { flag: 'wx', mode: 0o400 });
try {
const verified = await verifySignedRelease({ signedManifestPath: signedPath, verifierModulePath, verifySignedReleaseEnvelope, trustPolicyPath, protectedState, now });
if (verified.admission.manifestDigest !== index.manifestDigest || verified.manifest.buildId !== index.buildId ||
`${verified.manifest.buildId}-${verified.admission.manifestDigest}` !== index.releaseId ||
verified.admission.expectedFence !== index.expectedFence || verified.admission.expiresAt !== index.expiresAt) {
throw new Error('Package index does not bind the verified signed release admission.');
}
return { files, index, ...verified };
} finally {
await rm(temporaryRoot, { recursive: true, force: true });
}
}
async function main() {
const [archivePath, verifierModulePath, trustPolicyPath, protectedStateJson, now] = process.argv.slice(2);
if (!archivePath || !verifierModulePath || !trustPolicyPath || !protectedStateJson || !now || process.argv.length !== 7) throw new Error('Usage: verify-staged-release.mjs <archive> <verifier-module> <trust-policy> <protected-state-json> <trusted-now>');
const result = await verifyStagedRelease({ archivePath: resolve(archivePath), verifierModulePath: resolve(verifierModulePath), trustPolicyPath: resolve(trustPolicyPath), protectedState: JSON.parse(protectedStateJson), now });
process.stdout.write(Buffer.from(canonicalValueBytes(result.admission))); process.stdout.write('\n');
}
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
main().catch((error) => { process.stderr.write(`Staged release verification failed: ${error instanceof Error ? error.message : 'unknown error'}\n`); process.exitCode = 1; });
}

View File

@@ -0,0 +1,141 @@
import { lstat, mkdtemp, mkdir, readFile, readlink, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { describe, expect, test } from 'vitest';
import { buildReleasePackage } from '../../scripts/build-release.mjs';
import { activateRelease, cleanupOrphanStages, prepareRelease, readControlState, rollbackRelease } from '../../scripts/deploy/lib/release-transaction.mjs';
import { createReleaseFixture, initializeReleaseRoot } from './release-test-fixture.ts';
type Fixture = Awaited<ReturnType<typeof createReleaseFixture>>;
function buildInput(fixture: Fixture, outputPath: string) {
return {
distDir: fixture.distDir, signedManifestPath: fixture.signedManifestPath, sbomPath: fixture.sbomPath,
verifierModulePath: fixture.verifierModulePath, verifySignedReleaseEnvelope: fixture.verifySignedReleaseEnvelope,
trustPolicyPath: fixture.trustPolicyPath, protectedState: fixture.protectedState,
now: '2026-09-02T01:00:00Z', outputPath,
};
}
function transactionInput(root: string, archivePath: string, transactionId: string, fixture: Fixture) {
return {
root, archivePath, transactionId, verifierModulePath: fixture.verifierModulePath,
verifySignedReleaseEnvelope: fixture.verifySignedReleaseEnvelope, trustPolicyPath: fixture.trustPolicyPath,
uid: process.getuid?.() ?? 0, gid: process.getgid?.() ?? 0, now: '2026-09-02T01:00:00Z',
smokeCheck: async ({ releaseDir }: { releaseDir: string }) => {
await readFile(join(releaseDir, 'index.html'));
return Buffer.from('internal-nginx-smoke-ok\n');
},
};
}
describe('fenced atomic Nginx release transaction', () => {
test('prepares immutably without changing current, then activates with one atomic pointer swap', async () => {
const temp = await mkdtemp(join(tmpdir(), 'portfolio-atomic-'));
const fixture = await createReleaseFixture(join(temp, 'fixture'));
const root = join(temp, 'release-root'); await initializeReleaseRoot(root);
const archive = join(temp, 'release.tar'); await buildReleasePackage(buildInput(fixture, archive));
await prepareRelease(transactionInput(root, archive, 'tx-a', fixture));
await expect(lstat(join(root, 'current'))).rejects.toThrow();
await activateRelease({ ...transactionInput(root, archive, 'tx-a', fixture), expectedCurrentManifestDigest: null, expectedFence: 1, now: '2026-09-02T01:01:00Z' });
const target = await readlink(join(root, 'current'));
expect(target).toBe(`releases/release-a-${fixture.manifestDigest}/public`);
expect((await lstat(join(root, target, 'index.html'))).mode & 0o777).toBe(0o444);
expect((await lstat(join(root, target))).mode & 0o777).toBe(0o555);
});
test('rejects stale predecessor/fence, expiry crossing, and concurrent activation without moving the old pointer', async () => {
const temp = await mkdtemp(join(tmpdir(), 'portfolio-fence-'));
const fixture = await createReleaseFixture(join(temp, 'fixture'));
const root = join(temp, 'release-root'); await initializeReleaseRoot(root);
const archive = join(temp, 'release.tar'); await buildReleasePackage(buildInput(fixture, archive));
const input = transactionInput(root, archive, 'tx-a', fixture);
await prepareRelease(input);
await expect(activateRelease({ ...input, expectedCurrentManifestDigest: null, expectedFence: 2, now: '2026-09-02T01:01:00Z' })).rejects.toThrow('fence');
await expect(lstat(join(root, 'current'))).rejects.toThrow();
await expect(activateRelease({ ...input, expectedCurrentManifestDigest: fixture.manifestDigest, expectedFence: 1, now: '2026-09-02T01:01:00Z' })).rejects.toThrow('predecessor');
await expect(activateRelease({ ...input, expectedCurrentManifestDigest: null, expectedFence: 1, now: fixture.expiresAt })).rejects.toThrow('expired');
const attempts = await Promise.allSettled([
activateRelease({ ...input, expectedCurrentManifestDigest: null, expectedFence: 1, now: '2026-09-02T01:01:00Z' }),
activateRelease({ ...input, expectedCurrentManifestDigest: null, expectedFence: 1, now: '2026-09-02T01:01:00Z' }),
]);
expect(attempts.filter((result) => result.status === 'fulfilled')).toHaveLength(1);
expect(attempts.filter((result) => result.status === 'rejected')).toHaveLength(1);
expect(await readlink(join(root, 'current'))).toBe(`releases/release-a-${fixture.manifestDigest}/public`);
});
test('rechecks trusted time after smoke so an expiry crossing never becomes ready', async () => {
const temp = await mkdtemp(join(tmpdir(), 'portfolio-expiry-crossing-'));
const fixture = await createReleaseFixture(join(temp, 'fixture'));
const root = join(temp, 'release-root'); await initializeReleaseRoot(root);
const archive = join(temp, 'release.tar'); await buildReleasePackage(buildInput(fixture, archive));
const times = ['2026-09-02T01:00:00Z', fixture.expiresAt];
await expect(prepareRelease({ ...transactionInput(root, archive, 'tx-a', fixture), clock: () => times.shift() ?? fixture.expiresAt })).rejects.toThrow('expired while staging');
expect((await readControlState(root)).activeTransaction).toBeNull();
await expect(lstat(join(root, 'current'))).rejects.toThrow();
});
test('a crash before swap leaves the predecessor visible and retry after a post-swap crash converges on the committed release', async () => {
const temp = await mkdtemp(join(tmpdir(), 'portfolio-crash-'));
const fixture = await createReleaseFixture(join(temp, 'fixture'));
const root = join(temp, 'release-root'); await initializeReleaseRoot(root);
const archive = join(temp, 'release.tar'); await buildReleasePackage(buildInput(fixture, archive));
const input = { ...transactionInput(root, archive, 'tx-a', fixture), expectedCurrentManifestDigest: null, expectedFence: 1, now: '2026-09-02T01:01:00Z' };
await prepareRelease(input);
await expect(activateRelease({ ...input, crashPoint: 'before-swap' })).rejects.toThrow('before pointer');
await expect(lstat(join(root, 'current'))).rejects.toThrow();
await expect(activateRelease({ ...input, crashPoint: 'after-swap' })).rejects.toThrow('after pointer');
expect(await readlink(join(root, 'current'))).toBe(`releases/release-a-${fixture.manifestDigest}/public`);
const recovered = await activateRelease(input);
expect(recovered.recoveredAfterPointerSwap).toBe(true);
expect((await readControlState(root)).currentManifestDigest).toBe(fixture.manifestDigest);
});
test('rollback requires a fresh signed manifest and never replays the selected old manifest', async () => {
const temp = await mkdtemp(join(tmpdir(), 'portfolio-rollback-'));
const fixtureA = await createReleaseFixture(join(temp, 'fixture-a'));
const root = join(temp, 'release-root'); await initializeReleaseRoot(root);
const archiveA = join(temp, 'a.tar'); await buildReleasePackage(buildInput(fixtureA, archiveA));
const inputA = transactionInput(root, archiveA, 'tx-a', fixtureA);
await prepareRelease(inputA);
await activateRelease({ ...inputA, expectedCurrentManifestDigest: null, expectedFence: 1, now: '2026-09-02T01:01:00Z' });
const releaseA = (await readlink(join(root, 'current'))).split('/')[1] as string;
const fixtureB = await createReleaseFixture(join(temp, 'fixture-b'));
fixtureB.protectedState.currentManifestDigest = fixtureA.manifestDigest; fixtureB.protectedState.publisherFence = 1;
await writeFile(join(fixtureB.distDir, 'index.html'), '<!doctype html><title>Portfolio B</title>\n');
const signedB = await fixtureB.signFor({ buildId: 'release-b', distDir: fixtureB.distDir, predecessorManifestDigest: fixtureA.manifestDigest, publisherFence: 1, outputPath: fixtureB.signedManifestPath });
const archiveB = join(temp, 'b.tar'); await buildReleasePackage({ ...buildInput(fixtureB, archiveB), now: '2026-09-02T02:00:00Z' });
const inputB = { ...transactionInput(root, archiveB, 'tx-b', fixtureB), now: '2026-09-02T02:00:00Z' };
await prepareRelease(inputB);
await activateRelease({ ...inputB, expectedCurrentManifestDigest: fixtureA.manifestDigest, expectedFence: 2, now: '2026-09-02T02:01:00Z' });
await expect(rollbackRelease({ ...inputA, targetReleaseId: releaseA, transactionId: 'tx-replay', expectedCurrentManifestDigest: signedB.manifestDigest, expectedFence: 3, now: '2026-09-02T03:00:00Z' })).rejects.toThrow();
fixtureA.protectedState.currentManifestDigest = signedB.manifestDigest; fixtureA.protectedState.publisherFence = 2;
const freshSigned = join(temp, 'rollback-signed.json');
const fresh = await fixtureA.signFor({ buildId: 'rollback-a', distDir: fixtureA.distDir, predecessorManifestDigest: signedB.manifestDigest, publisherFence: 2, outputPath: freshSigned });
fixtureA.signedManifestPath = freshSigned;
const rollbackArchive = join(temp, 'rollback.tar'); await buildReleasePackage({ ...buildInput(fixtureA, rollbackArchive), now: '2026-09-02T03:00:00Z' });
const rollback = await rollbackRelease({ ...transactionInput(root, rollbackArchive, 'tx-rollback', fixtureA), targetReleaseId: releaseA,
expectedCurrentManifestDigest: signedB.manifestDigest, expectedFence: 3, now: '2026-09-02T03:00:00Z' });
expect(rollback.manifestDigest).toBe(fresh.manifestDigest);
expect(await readlink(join(root, 'current'))).toContain('rollback-a-');
});
test('orphan cleanup and all mutations remain confined to a validated explicit release root', async () => {
const temp = await mkdtemp(join(tmpdir(), 'portfolio-cleanup-'));
const outside = join(temp, 'outside.txt'); await writeFile(outside, 'keep');
const invalidRoot = join(temp, 'not-a-release-root'); await mkdir(join(invalidRoot, 'staging', 'orphan.partial'), { recursive: true });
await expect(cleanupOrphanStages(invalidRoot)).rejects.toThrow('sentinel');
expect(await readFile(outside, 'utf8')).toBe('keep');
const root = join(temp, 'release-root'); await initializeReleaseRoot(root);
await mkdir(join(root, 'staging', 'orphan.partial'), { recursive: true });
await writeFile(join(root, 'staging', 'orphan.partial', 'marker'), 'orphan');
expect(await cleanupOrphanStages(root)).toEqual(['orphan.partial']);
await expect(lstat(join(root, 'staging', 'orphan.partial'))).rejects.toThrow();
expect(await readFile(outside, 'utf8')).toBe('keep');
await expect(cleanupOrphanStages('/')).rejects.toThrow('Broad');
});
});

View File

@@ -0,0 +1,51 @@
import { mkdtemp, readFile, unlink, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { describe, expect, test } from 'vitest';
import { byteDigest, canonicalValueBytes } from '../../src/security/canonical-digest.ts';
import { buildReleasePackage } from '../../scripts/build-release.mjs';
import { parseDeterministicTar } from '../../scripts/deploy/lib/release-common.mjs';
import { createReleaseFixture } from './release-test-fixture.ts';
function buildInput(fixture: Awaited<ReturnType<typeof createReleaseFixture>>, outputPath: string) {
return {
distDir: fixture.distDir, signedManifestPath: fixture.signedManifestPath, sbomPath: fixture.sbomPath,
verifierModulePath: fixture.verifierModulePath, trustPolicyPath: fixture.trustPolicyPath,
verifySignedReleaseEnvelope: fixture.verifySignedReleaseEnvelope,
protectedState: fixture.protectedState, now: '2026-09-02T01:00:00Z', outputPath,
};
}
describe('deterministic exact-manifest release package', () => {
test('builds byte-identical archives containing only the signed closure and release metadata', async () => {
const root = await mkdtemp(join(tmpdir(), 'portfolio-package-'));
const fixture = await createReleaseFixture(root);
const first = join(root, 'release-one.tar');
const second = join(root, 'release-two.tar');
await buildReleasePackage(buildInput(fixture, first));
await buildReleasePackage(buildInput(fixture, second));
expect(byteDigest(await readFile(first))).toBe(byteDigest(await readFile(second)));
expect(parseDeterministicTar(await readFile(first)).map((file) => file.path)).toEqual([
'.release/package-index.json', '.release/sbom.cdx.json', '.release/signed-release-manifest.json',
'assets/app.js', 'index.html',
]);
});
test('fails closed on extra, substituted, or invalidly signed inputs', async () => {
const root = await mkdtemp(join(tmpdir(), 'portfolio-package-invalid-'));
const fixture = await createReleaseFixture(root);
await writeFile(join(fixture.distDir, 'extra.txt'), 'not in manifest');
await expect(buildReleasePackage(buildInput(fixture, join(root, 'extra.tar')))).rejects.toThrow('exactly match');
await unlink(join(fixture.distDir, 'extra.txt'));
await writeFile(join(fixture.distDir, 'index.html'), 'substituted');
await expect(buildReleasePackage(buildInput(fixture, join(root, 'substituted.tar')))).rejects.toThrow('hash');
await writeFile(join(fixture.distDir, 'index.html'), '<!doctype html><title>Portfolio</title>\n');
const envelope = JSON.parse(await readFile(fixture.signedManifestPath, 'utf8'));
envelope.signature.value = `${envelope.signature.value[0] === 'A' ? 'B' : 'A'}${envelope.signature.value.slice(1)}`;
await writeFile(fixture.signedManifestPath, canonicalValueBytes(envelope));
await expect(buildReleasePackage(buildInput(fixture, join(root, 'invalid.tar')))).rejects.toThrow('signature');
});
});

View File

@@ -0,0 +1,197 @@
import { generateKeyPairSync, sign, verify } from 'node:crypto';
import { chmod, mkdir, readFile, writeFile } from 'node:fs/promises';
import { join } from 'node:path';
import { canonicalJsonDigest, canonicalValueBytes, byteDigest } from '../../src/security/canonical-digest.ts';
import { computeProductionInputRoot } from '../../src/security/production-input-root.ts';
const digest = (character: string) => character.repeat(64);
export interface ReleaseFixture {
distDir: string;
signedManifestPath: string;
sbomPath: string;
trustPolicyPath: string;
verifierModulePath: string;
verifySignedReleaseEnvelope(input: { signedRelease: any; trustPolicy: any; now: string; protectedState: any }): any;
protectedState: {
latestLedgerHeadHash: string;
latestLedgerRevision: number;
currentManifestDigest: string | null;
publisherFence: number;
};
manifestDigest: string;
buildId: string;
expiresAt: string;
signFor(input: {
buildId: string;
distDir: string;
predecessorManifestDigest: string | null;
publisherFence: number;
issuedAt?: string;
expiresAt?: string;
outputPath: string;
}): Promise<{ manifestDigest: string; signedManifestPath: string }>;
}
export async function createReleaseFixture(root: string): Promise<ReleaseFixture> {
const distDir = join(root, 'dist');
await mkdir(join(distDir, 'assets'), { recursive: true });
await writeFile(join(distDir, 'index.html'), '<!doctype html><title>Portfolio</title>\n');
await writeFile(join(distDir, 'assets/app.js'), 'console.log("portfolio");\n');
const sbomPath = join(root, 'sbom.cdx.json');
await writeFile(sbomPath, '{"bomFormat":"CycloneDX","specVersion":"1.6"}\n');
const keys = generateKeyPairSync('ed25519');
const publicKeyPem = keys.publicKey.export({ type: 'spki', format: 'pem' }).toString();
const trustPolicyPath = join(root, 'release-trust.json');
await writeFile(trustPolicyPath, canonicalValueBytes({
keyId: 'release-test-key', issuerEpoch: 7, scope: 'portfolio-release-signing/v1', publicKeyPem,
}));
const verifierModulePath = join(root, 'real-release-verifier.mjs');
await writeFile(verifierModulePath, `
import { createHash, verify } from 'node:crypto';
function canonical(value) {
if (value === null || typeof value !== 'object') return JSON.stringify(value);
if (Array.isArray(value)) return '[' + value.map(canonical).join(',') + ']';
return '{' + Object.keys(value).sort().map((key) => JSON.stringify(key) + ':' + canonical(value[key])).join(',') + '}';
}
function digest(schema, value) {
return createHash('sha256').update(canonical({ schema, value })).digest('hex');
}
function fail(message) { throw new Error(message); }
export function verifySignedReleaseEnvelope({ signedRelease, trustPolicy, now, protectedState }) {
if (signedRelease?.schema !== 'portfolio-signed-release-manifest/v1') fail('bad envelope schema');
if (signedRelease.signature?.algorithm !== 'Ed25519' || signedRelease.signature?.encoding !== 'base64url-nopad') fail('bad signature metadata');
if (signedRelease.signature?.keyId !== trustPolicy.keyId || signedRelease.signature?.issuerEpoch !== trustPolicy.issuerEpoch || signedRelease.signature?.scope !== trustPolicy.scope) fail('untrusted signer');
const signature = Buffer.from(signedRelease.signature.value, 'base64url');
if (signature.length !== 64 || !verify(null, Buffer.from(canonical(signedRelease.payload)), trustPolicy.publicKeyPem, signature)) fail('invalid release signature');
const manifestDigest = digest('portfolio-candidate-release-manifest/v1', signedRelease.payload);
if (manifestDigest !== signedRelease.payloadDigest) fail('candidate digest mismatch');
const candidate = signedRelease.payload;
if (candidate.latestLedgerHeadHash !== protectedState.latestLedgerHeadHash || candidate.latestLedgerRevision !== protectedState.latestLedgerRevision) fail('stale ledger');
if (candidate.releaseManifest.currentManifestDigest !== protectedState.currentManifestDigest) fail('stale predecessor');
if (Date.parse(now) >= Date.parse(candidate.releaseManifest.expiresAt)) fail('expired release');
return {
schema: 'portfolio-verified-release-admission/v1',
manifestDigest,
manifestPayload: candidate.releaseManifest,
expiresAt: candidate.releaseManifest.expiresAt,
predecessorManifestDigest: candidate.releaseManifest.currentManifestDigest,
latestLedgerHeadHash: candidate.latestLedgerHeadHash,
latestLedgerRevision: candidate.latestLedgerRevision,
expectedFence: protectedState.publisherFence + 1,
signatureVerified: true,
};
}
`);
await chmod(verifierModulePath, 0o500);
const protectedState = {
latestLedgerHeadHash: digest('9'), latestLedgerRevision: 11, currentManifestDigest: null, publisherFence: 0,
};
const verifySignedReleaseEnvelope = ({ signedRelease, trustPolicy, now, protectedState: state }: { signedRelease: any; trustPolicy: any; now: string; protectedState: any }) => {
if (signedRelease?.schema !== 'portfolio-signed-release-manifest/v1') throw new Error('bad envelope schema');
if (signedRelease.signature?.keyId !== trustPolicy.keyId || signedRelease.signature?.issuerEpoch !== trustPolicy.issuerEpoch ||
signedRelease.signature?.scope !== trustPolicy.scope) throw new Error('untrusted signer');
const signature = Buffer.from(signedRelease.signature.value, 'base64url');
if (signature.length !== 64 || !verify(null, canonicalValueBytes(signedRelease.payload), trustPolicy.publicKeyPem, signature)) throw new Error('invalid release signature');
const manifestDigest = canonicalJsonDigest('portfolio-candidate-release-manifest/v1', signedRelease.payload);
if (manifestDigest !== signedRelease.payloadDigest) throw new Error('candidate digest mismatch');
const candidate = signedRelease.payload;
if (candidate.latestLedgerHeadHash !== state.latestLedgerHeadHash || candidate.latestLedgerRevision !== state.latestLedgerRevision) throw new Error('stale ledger');
if (candidate.releaseManifest.currentManifestDigest !== state.currentManifestDigest) throw new Error('stale predecessor');
if (Date.parse(now) >= Date.parse(candidate.releaseManifest.expiresAt)) throw new Error('expired release');
return {
schema: 'portfolio-verified-release-admission/v1', manifestDigest, manifestPayload: candidate.releaseManifest,
expiresAt: candidate.releaseManifest.expiresAt, predecessorManifestDigest: candidate.releaseManifest.currentManifestDigest,
latestLedgerHeadHash: candidate.latestLedgerHeadHash, latestLedgerRevision: candidate.latestLedgerRevision,
expectedFence: state.publisherFence + 1, signatureVerified: true,
};
};
const signFor = async (input: {
buildId: string;
distDir: string;
predecessorManifestDigest: string | null;
publisherFence: number;
issuedAt?: string;
expiresAt?: string;
outputPath: string;
}) => {
const artifactPaths = ['assets/app.js', 'index.html'];
const artifacts = await Promise.all(artifactPaths.map(async (path) => {
const bytes = await readFile(join(input.distDir, path));
return { path, sha256: byteDigest(bytes), bytes: bytes.length, encoding: 'identity' as const };
}));
const inputDigests = {
PortfolioContent: digest('a'), PublicAssetManifest: digest('b'), PreparedAssetSet: digest('c'), SiteConfig: digest('d'),
};
const issuedAt = input.issuedAt ?? '2026-09-02T00:00:00Z';
const expiresAt = input.expiresAt ?? '2026-09-03T00:00:00Z';
const releaseManifest = {
schema: 'portfolio-release-manifest/v1' as const,
launchMode: 'private-source-initial-launch' as const,
buildId: input.buildId,
inputDigests,
productionInputRoot: computeProductionInputRoot({
portfolioContentDigest: inputDigests.PortfolioContent,
publicAssetManifestDigest: inputDigests.PublicAssetManifest,
preparedAssetSetDigest: inputDigests.PreparedAssetSet,
siteConfigDigest: inputDigests.SiteConfig,
}),
artifacts,
deployFiles: artifactPaths,
sourceCommit: digest('e'), sourceTree: digest('f'), approvedRecordRoot: digest('1'), approvedAssetRoot: digest('2'),
approvalProofSetDigest: digest('3'), approvalProjectionAttestationDigest: digest('4'), scanEvidenceDigest: digest('5'),
policyVersions: { approval: '1', privacy: '1', release: '1' }, toolVersions: { node: '22.22.1' },
predecessorManifestDigest: input.predecessorManifestDigest,
currentManifestDigest: input.predecessorManifestDigest,
issuedAt, expiresAt, minimumBoundExpiry: expiresAt,
};
const candidate = {
schema: 'portfolio-candidate-release-manifest/v1',
releaseManifest,
latestLedgerHeadHash: protectedState.latestLedgerHeadHash,
latestLedgerRevision: protectedState.latestLedgerRevision,
sourceClosureDigest: digest('6'), publicRefMap: [], lfsOids: [], releaseArchives: [],
buildProvenanceDigest: digest('7'), privacyPolicyDigest: digest('8'), toolImageDigests: [digest('0')],
};
const payloadDigest = canonicalJsonDigest('portfolio-candidate-release-manifest/v1', candidate);
const signature = sign(null, canonicalValueBytes(candidate), keys.privateKey).toString('base64url');
const signedRelease = {
schema: 'portfolio-signed-release-manifest/v1', payload: candidate, payloadDigest,
signature: {
algorithm: 'Ed25519', encoding: 'base64url-nopad', keyId: 'release-test-key', issuerEpoch: 7,
scope: 'portfolio-release-signing/v1', value: signature,
},
};
await writeFile(input.outputPath, canonicalValueBytes(signedRelease));
return { manifestDigest: payloadDigest, signedManifestPath: input.outputPath };
};
const signedManifestPath = join(root, 'signed-release.json');
const initial = await signFor({ buildId: 'release-a', distDir, predecessorManifestDigest: null, publisherFence: 0, outputPath: signedManifestPath });
return {
distDir, signedManifestPath, sbomPath, trustPolicyPath, verifierModulePath, verifySignedReleaseEnvelope, protectedState,
manifestDigest: initial.manifestDigest, buildId: 'release-a', expiresAt: '2026-09-03T00:00:00Z', signFor,
};
}
export async function initializeReleaseRoot(root: string, state?: Partial<ReleaseFixture['protectedState']>): Promise<void> {
await mkdir(join(root, 'control'), { recursive: true });
await writeFile(join(root, '.portfolio-release-root'), 'portfolio-release-root/v1\n');
await writeFile(join(root, 'control/state.json'), canonicalValueBytes({
schema: 'portfolio-release-control-state/v1',
currentManifestDigest: state?.currentManifestDigest ?? null,
currentReleaseId: null,
publisherFence: state?.publisherFence ?? 0,
latestLedgerHeadHash: state?.latestLedgerHeadHash ?? digest('9'),
latestLedgerRevision: state?.latestLedgerRevision ?? 11,
activeTransaction: null,
}));
}