From 41b920c19c89d3cc639a54d4faad15fd40c569a7 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Fri, 21 Aug 2026 03:57:40 -0700 Subject: [PATCH] fix(release): authenticate reusable validation evidence --- ...dation-github-artifact-authenticator.d.mts | 41 +++ ...lidation-github-artifact-authenticator.mjs | 185 +++++++++++ .../release-validation-receipt-contract.d.mts | 23 ++ .../release-validation-receipt-contract.mjs | 90 +++++- ...elease-validation-receipt-contract.test.ts | 298 ++++++++++++++++-- 5 files changed, 611 insertions(+), 26 deletions(-) create mode 100644 scripts/release-validation-github-artifact-authenticator.d.mts create mode 100644 scripts/release-validation-github-artifact-authenticator.mjs diff --git a/scripts/release-validation-github-artifact-authenticator.d.mts b/scripts/release-validation-github-artifact-authenticator.d.mts new file mode 100644 index 000000000000..3c0115984be5 --- /dev/null +++ b/scripts/release-validation-github-artifact-authenticator.d.mts @@ -0,0 +1,41 @@ +import type { + ReleaseValidationSourceArtifact, + ReleaseValidationVerifiedArtifactEvidence, +} from "./release-validation-receipt-contract.mjs"; + +export type GitHubReleaseValidationArtifactEvidence = ReleaseValidationSourceArtifact & { + entry_bytes: string; +}; + +export type GitHubReleaseValidationArtifactExpected = { + repository: string; + workflowPath: string; + workflowSha: string; +}; + +export function authenticateGitHubReleaseValidationArtifact(params: { + evidence: GitHubReleaseValidationArtifactEvidence; + expected: GitHubReleaseValidationArtifactExpected; + artifactMetadata: unknown; + workflowRun: unknown; + archiveBytes: Uint8Array; + nowMs: number; +}): ReleaseValidationVerifiedArtifactEvidence; + +export function downloadAndAuthenticateGitHubReleaseValidationArtifact(params: { + evidence: GitHubReleaseValidationArtifactEvidence; + expected: GitHubReleaseValidationArtifactExpected & { + artifactSizeBytes: number; + runStatePolicy: "completed-success" | "same-run-producer-success"; + workflowEvent: string; + workflowHeadBranch: string; + consumerRunAttempt?: number; + producerJobName?: string; + }; + token: string; + nowMs: number; + fetchImpl?: typeof fetch; + timeoutMs?: number; + retryAttempts?: number; + retryDelayMs?: number; +}): Promise; diff --git a/scripts/release-validation-github-artifact-authenticator.mjs b/scripts/release-validation-github-artifact-authenticator.mjs new file mode 100644 index 000000000000..59e1578c0bf7 --- /dev/null +++ b/scripts/release-validation-github-artifact-authenticator.mjs @@ -0,0 +1,185 @@ +import { + downloadActionsArtifactArchive, + inspectActionsArtifactZipWithPolicy, + sha256Digest, +} from "./lib/actions-artifact-archive.mjs"; +import { isRecord } from "./lib/record-shared.mjs"; +import { + RELEASE_VALIDATION_RECEIPT_MAX_BYTES, + verifyReleaseValidationArtifactEvidence, +} from "./release-validation-receipt-contract.mjs"; + +const MAX_ARCHIVE_BYTES = 16 * 1024 * 1024; +const REPOSITORY = "openclaw/openclaw"; +const WORKFLOW_PATH = ".github/workflows/full-release-validation.yml"; +const SHA_PATTERN = /^[a-f0-9]{40}$/u; + +function fail(message) { + throw new Error(message); +} + +function positiveInteger(value, label) { + if (!Number.isSafeInteger(value) || value < 1) { + fail(`${label} must be a positive safe integer`); + } + return value; +} + +function nonNegativeInteger(value, label) { + if (!Number.isSafeInteger(value) || value < 0) { + fail(`${label} must be a non-negative safe integer`); + } + return value; +} + +function string(value, label) { + if (typeof value !== "string" || value.length === 0) { + fail(`${label} must be a non-empty string`); + } + return value; +} + +function repositoryName(value, label) { + if (!isRecord(value) || typeof value.full_name !== "string") { + fail(`${label} must include full_name`); + } + return value.full_name; +} + +function timestampMs(value, label) { + const text = string(value, label); + const milliseconds = Date.parse(text); + if (!Number.isFinite(milliseconds)) { + fail(`${label} must be a valid timestamp`); + } + return milliseconds; +} + +export function authenticateGitHubReleaseValidationArtifact(params) { + if (!isRecord(params)) { + fail("GitHub release validation artifact authentication parameters are required"); + } + const evidence = params.evidence; + const expected = params.expected; + const artifact = params.artifactMetadata; + const run = params.workflowRun; + if (!isRecord(evidence) || !isRecord(expected) || !isRecord(artifact) || !isRecord(run)) { + fail("GitHub release validation artifact metadata is incomplete"); + } + if (!(params.archiveBytes instanceof Uint8Array)) { + fail("GitHub release validation artifact archiveBytes must be a Uint8Array"); + } + const archiveBytes = Buffer.from( + params.archiveBytes.buffer, + params.archiveBytes.byteOffset, + params.archiveBytes.byteLength, + ); + const nowMs = nonNegativeInteger(params.nowMs, "GitHub artifact authentication nowMs"); + const repository = string(expected.repository, "GitHub artifact expected repository"); + const workflowPath = string(expected.workflowPath, "GitHub artifact expected workflow path"); + const workflowSha = string(expected.workflowSha, "GitHub artifact expected workflow SHA"); + if ( + repository !== REPOSITORY || + workflowPath !== WORKFLOW_PATH || + !SHA_PATTERN.test(workflowSha) + ) { + fail("GitHub artifact expected repository or workflow authority is unsupported"); + } + const artifactId = positiveInteger( + Number(evidence.artifact_id), + "GitHub artifact evidence artifact_id", + ); + const runId = positiveInteger(Number(evidence.run_id), "GitHub artifact evidence run_id"); + const runAttempt = positiveInteger(evidence.run_attempt, "GitHub artifact evidence run_attempt"); + const createdAtMs = timestampMs(evidence.created_at, "GitHub artifact evidence created_at"); + const expiresAtMs = timestampMs(evidence.expires_at, "GitHub artifact evidence expires_at"); + if ( + artifact.id !== artifactId || + artifact.name !== evidence.artifact_name || + artifact.digest !== evidence.archive_digest || + artifact.created_at !== evidence.created_at || + artifact.expires_at !== evidence.expires_at || + artifact.expired !== false || + artifact.size_in_bytes !== archiveBytes.byteLength || + !isRecord(artifact.workflow_run) || + artifact.workflow_run.id !== runId || + artifact.workflow_run.head_sha !== workflowSha + ) { + fail("GitHub artifact metadata differs from the authenticated evidence tuple"); + } + if ( + run.id !== runId || + run.run_attempt !== runAttempt || + run.path !== workflowPath || + run.head_sha !== workflowSha || + repositoryName(run.repository, "GitHub workflow repository") !== repository || + repositoryName(run.head_repository, "GitHub workflow head repository") !== repository + ) { + fail("GitHub workflow metadata differs from the authenticated evidence tuple"); + } + if (createdAtMs > nowMs || createdAtMs >= expiresAtMs || expiresAtMs <= nowMs) { + fail("GitHub artifact is expired or has invalid creation/expiry timestamps"); + } + if (sha256Digest(archiveBytes) !== evidence.archive_digest) { + fail("downloaded GitHub artifact archive digest differs from metadata"); + } + const files = inspectActionsArtifactZipWithPolicy(archiveBytes, { + expectedEntries: [evidence.entry_name], + maxArchiveBytes: MAX_ARCHIVE_BYTES, + maxExpandedBytes: RELEASE_VALIDATION_RECEIPT_MAX_BYTES, + maxEntryBytes: () => RELEASE_VALIDATION_RECEIPT_MAX_BYTES, + }); + const entryBytes = files.get(evidence.entry_name); + if ( + !entryBytes || + typeof evidence.entry_bytes !== "string" || + !entryBytes.equals(Buffer.from(evidence.entry_bytes, "ascii")) + ) { + fail("GitHub artifact entry bytes differ from the authenticated evidence"); + } + return verifyReleaseValidationArtifactEvidence(evidence, () => true); +} + +export async function downloadAndAuthenticateGitHubReleaseValidationArtifact(params) { + if (!isRecord(params) || !isRecord(params.evidence) || !isRecord(params.expected)) { + fail("GitHub release validation artifact download parameters are required"); + } + const evidence = params.evidence; + const expected = params.expected; + const downloaded = await downloadActionsArtifactArchive({ + expected: { + artifactDigest: evidence.archive_digest, + artifactId: Number(evidence.artifact_id), + artifactName: evidence.artifact_name, + artifactSizeBytes: expected.artifactSizeBytes, + repository: expected.repository, + runStatePolicy: expected.runStatePolicy, + runAttempt: evidence.run_attempt, + runId: Number(evidence.run_id), + workflowEvent: expected.workflowEvent, + workflowHeadBranch: expected.workflowHeadBranch, + workflowPath: expected.workflowPath, + workflowSha: expected.workflowSha, + ...(expected.consumerRunAttempt === undefined + ? {} + : { consumerRunAttempt: expected.consumerRunAttempt }), + ...(expected.producerJobName === undefined + ? {} + : { producerJobName: expected.producerJobName }), + }, + token: params.token, + ...(params.fetchImpl === undefined ? {} : { fetchImpl: params.fetchImpl }), + ...(params.timeoutMs === undefined ? {} : { timeoutMs: params.timeoutMs }), + ...(params.retryAttempts === undefined ? {} : { retryAttempts: params.retryAttempts }), + ...(params.retryDelayMs === undefined ? {} : { retryDelayMs: params.retryDelayMs }), + maxArchiveBytes: MAX_ARCHIVE_BYTES, + }); + return authenticateGitHubReleaseValidationArtifact({ + evidence, + expected, + artifactMetadata: downloaded.artifactMetadata, + workflowRun: downloaded.workflowRun, + archiveBytes: downloaded.archiveBytes, + nowMs: params.nowMs, + }); +} diff --git a/scripts/release-validation-receipt-contract.d.mts b/scripts/release-validation-receipt-contract.d.mts index f3a8ff4f4d30..673e8cdf104b 100644 --- a/scripts/release-validation-receipt-contract.d.mts +++ b/scripts/release-validation-receipt-contract.d.mts @@ -90,6 +90,7 @@ export type ReleaseValidationSourceArtifact = { archive_digest: ReleaseValidationReceiptDigest; content_digest: ReleaseValidationReceiptDigest; created_at: string; + expires_at: string; url: string; }; @@ -213,6 +214,15 @@ export const RELEASE_VALIDATION_RECEIPT_LOCATOR_SCHEMA: "openclaw.release-valida export const RELEASE_VALIDATION_POLICY_ID: "openclaw.release-validation-policy.v1"; export const RELEASE_VALIDATION_RECEIPT_MAX_BYTES: number; export const RELEASE_VALIDATION_RECEIPT_LOCATOR_MAX_BYTES: number; +export const RELEASE_VALIDATION_REUSE_POLICIES: Readonly< + Record< + ReleaseValidationIntent, + Readonly<{ + max_age_ms: number; + cadence_ms: number; + }> + > +>; export function validateReleaseValidationExecutionPlanSource( value: unknown, ): ReleaseValidationExecutionPlanSource; @@ -241,6 +251,19 @@ export function verifyReleaseValidationReceipt( receiptValue: unknown, input: ReleaseValidationReceiptSealInput, ): ReleaseValidationVerifiedReceipt; +export function validateReleaseValidationReceiptReuseFreshness( + receiptValue: ReleaseValidationVerifiedReceipt, + options: { + now_ms: number; + max_future_skew_ms: number; + }, +): { + intent: ReleaseValidationIntent; + age_ms: number; + max_age_ms: number; + cadence_ms: number; + expires_at_ms: number; +}; export function canonicalReleaseValidationReceiptJson(value: unknown): string; export function releaseValidationReceiptDigest(value: unknown): ReleaseValidationReceiptDigest; export function parseReleaseValidationReceiptJson(text: string): ReleaseValidationReceipt; diff --git a/scripts/release-validation-receipt-contract.mjs b/scripts/release-validation-receipt-contract.mjs index fd889bb488ee..3688d3a788c5 100644 --- a/scripts/release-validation-receipt-contract.mjs +++ b/scripts/release-validation-receipt-contract.mjs @@ -19,6 +19,28 @@ export const RELEASE_VALIDATION_RECEIPT_LOCATOR_SCHEMA = export const RELEASE_VALIDATION_POLICY_ID = "openclaw.release-validation-policy.v1"; export const RELEASE_VALIDATION_RECEIPT_MAX_BYTES = 256 * 1024; export const RELEASE_VALIDATION_RECEIPT_LOCATOR_MAX_BYTES = 16 * 1024; +export const RELEASE_VALIDATION_REUSE_POLICIES = Object.freeze({ + "release-beta": Object.freeze({ + max_age_ms: 6 * 60 * 60 * 1000, + cadence_ms: 6 * 60 * 60 * 1000, + }), + "release-stable": Object.freeze({ + max_age_ms: 6 * 60 * 60 * 1000, + cadence_ms: 6 * 60 * 60 * 1000, + }), + "main-daily": Object.freeze({ + max_age_ms: 24 * 60 * 60 * 1000, + cadence_ms: 24 * 60 * 60 * 1000, + }), + "main-weekly": Object.freeze({ + max_age_ms: 7 * 24 * 60 * 60 * 1000, + cadence_ms: 7 * 24 * 60 * 60 * 1000, + }), + "diagnostic-full": Object.freeze({ + max_age_ms: 7 * 24 * 60 * 60 * 1000, + cadence_ms: 7 * 24 * 60 * 60 * 1000, + }), +}); const REPOSITORY = "openclaw/openclaw"; const WORKFLOW_PATH = ".github/workflows/full-release-validation.yml"; @@ -593,6 +615,7 @@ export function verifyReleaseValidationArtifactEvidence(value, authenticate) { "archive_digest", "content_digest", "created_at", + "expires_at", "url", "entry_bytes", ], @@ -617,6 +640,7 @@ export function verifyReleaseValidationArtifactEvidence(value, authenticate) { archive_digest: digest(artifact.archive_digest, `${label}.archive_digest`), content_digest: digest(artifact.content_digest, `${label}.content_digest`), created_at: timestamp(artifact.created_at, `${label}.created_at`), + expires_at: timestamp(artifact.expires_at, `${label}.expires_at`), url: exactArtifactUrl(artifact.url, parentRunId, artifactId, `${label}.url`), entry_bytes: artifact.entry_bytes, }); @@ -659,6 +683,7 @@ function validateSourceArtifacts(value, sources) { "archive_digest", "content_digest", "created_at", + "expires_at", "url", "entry_bytes", ], @@ -680,6 +705,7 @@ function validateSourceArtifacts(value, sources) { archive_digest: digest(artifact.archive_digest, `${label}.archive_digest`), content_digest: digest(artifact.content_digest, `${label}.content_digest`), created_at: timestamp(artifact.created_at, `${label}.created_at`), + expires_at: timestamp(artifact.expires_at, `${label}.expires_at`), url: exactArtifactUrl(artifact.url, parentRunId, artifactId, `${label}.url`), }; if ( @@ -696,6 +722,7 @@ function validateSourceArtifacts(value, sources) { ) || result.entry_name !== REQUIRED_ARTIFACTS[kind].entry || result.content_digest !== exactBytesDigest(expectedBytes[kind]) || + Date.parse(result.expires_at) <= Date.parse(result.created_at) || artifact.entry_bytes !== expectedBytes[kind] ) { fail(`${label} coordinates differ from its source object`); @@ -873,7 +900,11 @@ export function sealReleaseValidationReceipt(input) { }); const sealedAt = timestamp(input.sealedAt, "release validation receipt sealed_at"); if ( - sourceArtifacts.some((artifact) => Date.parse(artifact.created_at) > Date.parse(sealedAt)) || + sourceArtifacts.some( + (artifact) => + Date.parse(artifact.created_at) > Date.parse(sealedAt) || + Date.parse(artifact.expires_at) <= Date.parse(sealedAt), + ) || Date.parse(diagnosticDrain.observed_at) > Date.parse(sealedAt) ) { fail("release validation receipt was sealed before its sources completed"); @@ -1092,6 +1123,7 @@ function validateReceiptArtifacts(value, context) { "archive_digest", "content_digest", "created_at", + "expires_at", "url", ], label, @@ -1112,6 +1144,7 @@ function validateReceiptArtifacts(value, context) { archive_digest: digest(artifact.archive_digest, `${label}.archive_digest`), content_digest: digest(artifact.content_digest, `${label}.content_digest`), created_at: timestamp(artifact.created_at, `${label}.created_at`), + expires_at: timestamp(artifact.expires_at, `${label}.expires_at`), url: exactArtifactUrl(artifact.url, artifactRunId, artifactId, `${label}.url`), }; const required = expected[kind]; @@ -1120,7 +1153,8 @@ function validateReceiptArtifacts(value, context) { result.run_attempt !== required.attempt || result.artifact_name !== required.name || result.entry_name !== required.entry || - result.content_digest !== required.digest + result.content_digest !== required.digest || + Date.parse(result.expires_at) <= Date.parse(result.created_at) ) { fail(`${label} coordinates differ from its receipt sources`); } @@ -1148,7 +1182,10 @@ function validateReceiptArtifacts(value, context) { Date.parse(byKind["execution-plan"].created_at) < startedAt || Date.parse(byKind["execution-plan"].created_at) > decisionAt || Date.parse(byKind["release-plan-lock"].created_at) > decisionAt || - artifacts.some((artifact) => Date.parse(artifact.created_at) > sealedAt) + artifacts.some( + (artifact) => + Date.parse(artifact.created_at) > sealedAt || Date.parse(artifact.expires_at) <= sealedAt, + ) ) { fail("release validation receipt source artifact timestamps are invalid"); } @@ -1407,6 +1444,53 @@ export function verifyReleaseValidationReceipt(receiptValue, input) { return receipt; } +export function validateReleaseValidationReceiptReuseFreshness(receiptValue, optionsValue) { + const receipt = authenticatedReceipt(receiptValue, "release validation receipt reuse candidate"); + const options = object(optionsValue, "release validation receipt reuse options"); + exactKeys(options, ["now_ms", "max_future_skew_ms"], "release validation receipt reuse options"); + const nowMs = nonNegativeInteger(options.now_ms, "release validation receipt reuse now_ms"); + const futureSkewMs = nonNegativeInteger( + options.max_future_skew_ms, + "release validation receipt reuse max_future_skew_ms", + ); + const policy = RELEASE_VALIDATION_REUSE_POLICIES[receipt.validation.intent]; + const maxAgeMs = policy.max_age_ms; + const cadenceMs = policy.cadence_ms; + const sealedAtMs = Date.parse(receipt.timestamps.sealed_at); + const sourceTimes = [ + receipt.timestamps.started_at, + receipt.timestamps.decision_at, + receipt.timestamps.drain_completed_at, + receipt.timestamps.sealed_at, + ...receipt.source_artifacts.map((artifact) => artifact.created_at), + ].map(Date.parse); + const futureBoundaryMs = nowMs + futureSkewMs; + if ( + !Number.isSafeInteger(futureBoundaryMs) || + sourceTimes.some((value) => value > futureBoundaryMs) + ) { + fail("release validation receipt reuse evidence is newer than the allowed future skew"); + } + const policyExpiryMs = sealedAtMs + Math.min(maxAgeMs, cadenceMs); + if (!Number.isSafeInteger(policyExpiryMs)) { + fail("release validation receipt reuse policy expiry exceeds safe integer range"); + } + const artifactExpiryMs = Math.min( + ...receipt.source_artifacts.map((artifact) => Date.parse(artifact.expires_at)), + ); + const expiresAtMs = Math.min(policyExpiryMs, artifactExpiryMs); + if (nowMs >= expiresAtMs) { + fail("release validation receipt reuse evidence is expired"); + } + return { + intent: receipt.validation.intent, + age_ms: Math.max(0, nowMs - sealedAtMs), + max_age_ms: maxAgeMs, + cadence_ms: cadenceMs, + expires_at_ms: expiresAtMs, + }; +} + export function canonicalReleaseValidationReceiptJson(value) { return canonicalReleaseJson(validateReleaseValidationReceipt(value)); } diff --git a/test/scripts/release-validation-receipt-contract.test.ts b/test/scripts/release-validation-receipt-contract.test.ts index b5374b53b452..b3ee684e2c78 100644 --- a/test/scripts/release-validation-receipt-contract.test.ts +++ b/test/scripts/release-validation-receipt-contract.test.ts @@ -7,6 +7,7 @@ import { createReleasePlanLock, releaseCanonicalDigest, } from "../../scripts/release-plan-contract.mjs"; +import { authenticateGitHubReleaseValidationArtifact } from "../../scripts/release-validation-github-artifact-authenticator.mjs"; import { canonicalReleaseValidationReceiptJson, canonicalReleaseValidationReceiptLocatorJson, @@ -18,6 +19,7 @@ import { validateReleaseValidationExecutionPlanSource, validateReleaseValidationReceipt, validateReleaseValidationReceiptLocatorForReceipt, + validateReleaseValidationReceiptReuseFreshness, validateReleaseValidationStateSource, verifyReleaseValidationArtifactEvidence, verifyReleaseValidationReceipt, @@ -45,8 +47,48 @@ function addSeconds(value: string, seconds: number): string { return new Date(Date.parse(value) + seconds * 1000).toISOString().replace(".000Z", "Z"); } -function exactBytesDigest(value: string): `sha256:${string}` { - return `sha256:${createHash("sha256").update(value, "ascii").digest("hex")}`; +function exactBytesDigest(value: string | Uint8Array): `sha256:${string}` { + return `sha256:${createHash("sha256").update(value).digest("hex")}`; +} + +function crc32(bytes: Buffer): number { + let crc = 0xffffffff; + for (const byte of bytes) { + crc ^= byte; + for (let bit = 0; bit < 8; bit += 1) { + crc = (crc >>> 1) ^ (crc & 1 ? 0xedb88320 : 0); + } + } + return (crc ^ 0xffffffff) >>> 0; +} + +function createZip(name: string, bytes: Buffer): Buffer { + const nameBytes = Buffer.from(name, "utf8"); + const checksum = crc32(bytes); + const local = Buffer.alloc(30); + local.writeUInt32LE(0x04034b50, 0); + local.writeUInt16LE(20, 4); + local.writeUInt32LE(checksum, 14); + local.writeUInt32LE(bytes.length, 18); + local.writeUInt32LE(bytes.length, 22); + local.writeUInt16LE(nameBytes.length, 26); + const central = Buffer.alloc(46); + central.writeUInt32LE(0x02014b50, 0); + central.writeUInt16LE(0x0314, 4); + central.writeUInt16LE(20, 6); + central.writeUInt32LE(checksum, 16); + central.writeUInt32LE(bytes.length, 20); + central.writeUInt32LE(bytes.length, 24); + central.writeUInt16LE(nameBytes.length, 28); + central.writeUInt32LE((0o100600 * 0x10000) >>> 0, 38); + const centralOffset = local.length + nameBytes.length + bytes.length; + const end = Buffer.alloc(22); + end.writeUInt32LE(0x06054b50, 0); + end.writeUInt16LE(1, 8); + end.writeUInt16LE(1, 10); + end.writeUInt32LE(central.length + nameBytes.length, 12); + end.writeUInt32LE(centralOffset, 16); + return Buffer.concat([local, nameBytes, bytes, central, nameBytes, end]); } function job( @@ -190,6 +232,51 @@ type Fixture = FixtureBase & { sourceArtifacts: ReleaseValidationVerifiedArtifactEvidence[]; }; +type RawArtifactEvidence = ReleaseValidationSourceArtifact & { + entry_bytes: string; +}; + +function githubArtifactAuthenticationParams(evidence: RawArtifactEvidence) { + const archiveBytes = createZip(evidence.entry_name, Buffer.from(evidence.entry_bytes, "ascii")); + return { + evidence: { ...evidence, archive_digest: exactBytesDigest(archiveBytes) }, + expected: { + repository: "openclaw/openclaw", + workflowPath: ".github/workflows/full-release-validation.yml", + workflowSha: TOOLING_SHA, + }, + artifactMetadata: { + id: Number(evidence.artifact_id), + name: evidence.artifact_name, + digest: exactBytesDigest(archiveBytes), + created_at: evidence.created_at, + expires_at: evidence.expires_at, + expired: false, + size_in_bytes: archiveBytes.length, + workflow_run: { id: Number(evidence.run_id), head_sha: TOOLING_SHA }, + }, + workflowRun: { + id: Number(evidence.run_id), + run_attempt: evidence.run_attempt, + path: ".github/workflows/full-release-validation.yml", + head_sha: TOOLING_SHA, + repository: { full_name: "openclaw/openclaw" }, + head_repository: { full_name: "openclaw/openclaw" }, + }, + archiveBytes, + nowMs: Math.min( + Date.parse(evidence.expires_at) - 1, + Date.parse(evidence.created_at) + 60 * 60 * 1000, + ), + }; +} + +function authenticateEvidence( + evidence: RawArtifactEvidence, +): ReleaseValidationVerifiedArtifactEvidence { + return authenticateGitHubReleaseValidationArtifact(githubArtifactAuthenticationParams(evidence)); +} + function sourceArtifacts(fixture: FixtureBase): ReleaseValidationVerifiedArtifactEvidence[] { const coordinates = [ { @@ -200,7 +287,6 @@ function sourceArtifacts(fixture: FixtureBase): ReleaseValidationVerifiedArtifac run_attempt: fixture.decision.parent_run_attempt, content: fixture.decision, created_at: addSeconds(fixture.decision.observed_at, 60), - archive: "1", }, { kind: "diagnostic-drain", @@ -210,7 +296,6 @@ function sourceArtifacts(fixture: FixtureBase): ReleaseValidationVerifiedArtifac run_attempt: fixture.diagnosticDrain.parent_run_attempt, content: fixture.diagnosticDrain, created_at: addSeconds(fixture.diagnosticDrain.observed_at, 60), - archive: "2", }, { kind: "execution-plan", @@ -220,7 +305,6 @@ function sourceArtifacts(fixture: FixtureBase): ReleaseValidationVerifiedArtifac run_attempt: fixture.executionPlan.parent_run_attempt, content: fixture.executionPlan, created_at: addSeconds(fixture.executionPlan.started_at, 60), - archive: "3", }, { kind: "release-plan-lock", @@ -230,28 +314,25 @@ function sourceArtifacts(fixture: FixtureBase): ReleaseValidationVerifiedArtifac run_attempt: fixture.executionPlan.parent_run_attempt, content: fixture.releasePlanLock, created_at: addSeconds(fixture.executionPlan.started_at, -60), - archive: "4", }, ] as const; return coordinates.map((artifact) => { const entryBytes = canonicalReleaseJson(artifact.content); - return verifyReleaseValidationArtifactEvidence( - { - kind: artifact.kind, - artifact_id: artifact.artifact_id, - artifact_name: artifact.artifact_name, - entry_name: artifact.entry_name, - run_id: PARENT_RUN_ID, - run_attempt: artifact.run_attempt, - archive_digest: - `sha256:${artifact.archive.repeat(64)}` as ReleaseValidationSourceArtifact["archive_digest"], - content_digest: exactBytesDigest(entryBytes), - created_at: artifact.created_at, - url: `${PARENT_RUN_URL}/artifacts/${artifact.artifact_id}`, - entry_bytes: entryBytes, - }, - () => true, - ); + const expiresAt = addSeconds(artifact.created_at, 7 * 24 * 60 * 60); + return authenticateEvidence({ + kind: artifact.kind, + artifact_id: artifact.artifact_id, + artifact_name: artifact.artifact_name, + entry_name: artifact.entry_name, + run_id: PARENT_RUN_ID, + run_attempt: artifact.run_attempt, + archive_digest: `sha256:${"0".repeat(64)}`, + content_digest: exactBytesDigest(entryBytes), + created_at: artifact.created_at, + expires_at: expiresAt, + url: `${PARENT_RUN_URL}/artifacts/${artifact.artifact_id}`, + entry_bytes: entryBytes, + }); }); } @@ -568,6 +649,65 @@ describe("release validation receipt source sealer", () => { "coordinates differ from its source object", ); }); + + it("rejects mismatched GitHub metadata, expiry, workflow identity, and archive bytes", () => { + const raw = structuredClone( + inputFixture().sourceArtifacts[0]!, + ) as unknown as RawArtifactEvidence; + + const expired = githubArtifactAuthenticationParams(raw); + (expired.artifactMetadata as Record).expired = true; + expect(() => authenticateGitHubReleaseValidationArtifact(expired)).toThrow("metadata differs"); + + const wrongCreated = githubArtifactAuthenticationParams(raw); + (wrongCreated.artifactMetadata as Record).created_at = "2026-08-21T10:02:00Z"; + expect(() => authenticateGitHubReleaseValidationArtifact(wrongCreated)).toThrow( + "metadata differs", + ); + + const wrongWorkflow = githubArtifactAuthenticationParams(raw); + (wrongWorkflow.workflowRun as Record).path = ".github/workflows/ci.yml"; + expect(() => authenticateGitHubReleaseValidationArtifact(wrongWorkflow)).toThrow( + "workflow metadata differs", + ); + + for (const mutate of [ + (params: ReturnType) => { + (params.artifactMetadata as Record).id = 9999; + }, + (params: ReturnType) => { + (params.artifactMetadata as Record).name = "wrong-name"; + }, + (params: ReturnType) => { + (params.artifactMetadata as Record).digest = `sha256:${"f".repeat(64)}`; + }, + (params: ReturnType) => { + (params.workflowRun as Record).run_attempt = 99; + }, + (params: ReturnType) => { + (params.workflowRun as Record).repository.full_name = "other/repo"; + }, + ]) { + const mismatched = githubArtifactAuthenticationParams(raw); + mutate(mismatched); + expect(() => authenticateGitHubReleaseValidationArtifact(mismatched)).toThrow( + /metadata differs/, + ); + } + + const unsupportedAuthority = githubArtifactAuthenticationParams(raw); + unsupportedAuthority.expected.repository = "other/repo"; + expect(() => authenticateGitHubReleaseValidationArtifact(unsupportedAuthority)).toThrow( + "authority is unsupported", + ); + + const tamperedArchive = githubArtifactAuthenticationParams(raw); + tamperedArchive.archiveBytes = Buffer.from(tamperedArchive.archiveBytes); + tamperedArchive.archiveBytes.writeUInt8(tamperedArchive.archiveBytes.readUInt8(40) ^ 1, 40); + expect(() => authenticateGitHubReleaseValidationArtifact(tamperedArchive)).toThrow( + "archive digest differs", + ); + }); }); describe("release validation receipt lineage", () => { @@ -665,6 +805,118 @@ describe("release validation receipt lineage", () => { }); }); +describe("release validation receipt reuse freshness", () => { + const sealedAtMs = Date.parse("2026-08-21T10:32:00Z"); + + it("selects the intent policy and returns the bounded effective expiry", () => { + const receipt = sealReleaseValidationReceipt(inputFixture()); + expect( + validateReleaseValidationReceiptReuseFreshness(receipt, { + now_ms: sealedAtMs + 60 * 60 * 1000, + max_future_skew_ms: 60_000, + }), + ).toEqual({ + intent: "release-beta", + age_ms: 60 * 60 * 1000, + max_age_ms: 6 * 60 * 60 * 1000, + cadence_ms: 6 * 60 * 60 * 1000, + expires_at_ms: sealedAtMs + 6 * 60 * 60 * 1000, + }); + }); + + it("rejects invalid clocks and evidence beyond the allowed future skew", () => { + const receipt = sealReleaseValidationReceipt(inputFixture()); + expect(() => + validateReleaseValidationReceiptReuseFreshness(structuredClone(receipt), { + now_ms: sealedAtMs, + max_future_skew_ms: 0, + }), + ).toThrow("authenticated release validation receipt"); + for (const nowMs of [Number.NaN, Number.POSITIVE_INFINITY, -1, 1.5]) { + expect(() => + validateReleaseValidationReceiptReuseFreshness(receipt, { + now_ms: nowMs, + max_future_skew_ms: 60_000, + }), + ).toThrow("now_ms"); + } + for (const futureSkewMs of [Number.NaN, Number.POSITIVE_INFINITY, -1, 1.5]) { + expect(() => + validateReleaseValidationReceiptReuseFreshness(receipt, { + now_ms: sealedAtMs, + max_future_skew_ms: futureSkewMs, + }), + ).toThrow("max_future_skew_ms"); + } + expect(() => + validateReleaseValidationReceiptReuseFreshness(receipt, { + now_ms: sealedAtMs - 61_000, + max_future_skew_ms: 60_000, + }), + ).toThrow("future skew"); + expect(() => + validateReleaseValidationReceiptReuseFreshness(receipt, { + now_ms: Number.MAX_SAFE_INTEGER, + max_future_skew_ms: 1, + }), + ).toThrow("future skew"); + }); + + it("expires at the policy window or earlier authenticated artifact expiry", () => { + const receipt = sealReleaseValidationReceipt(inputFixture()); + expect(() => + validateReleaseValidationReceiptReuseFreshness(receipt, { + now_ms: sealedAtMs + 6 * 60 * 60 * 1000, + max_future_skew_ms: 0, + }), + ).toThrow("expired"); + + const artifactBoundInput = inputFixture(); + const shortLived = structuredClone( + artifactBoundInput.sourceArtifacts[0]!, + ) as unknown as RawArtifactEvidence; + shortLived.expires_at = addSeconds("2026-08-21T10:32:00Z", 30 * 60); + artifactBoundInput.sourceArtifacts[0] = authenticateEvidence(shortLived); + const artifactBoundReceipt = sealReleaseValidationReceipt(artifactBoundInput); + expect(() => + validateReleaseValidationReceiptReuseFreshness(artifactBoundReceipt, { + now_ms: sealedAtMs + 30 * 60 * 1000, + max_future_skew_ms: 0, + }), + ).toThrow("expired"); + }); + + it("uses fixed per-intent policy and rejects caller-supplied policy overrides", () => { + const betaReceipt = sealReleaseValidationReceipt(inputFixture()); + expect(() => + validateReleaseValidationReceiptReuseFreshness(betaReceipt, { + now_ms: sealedAtMs + 7 * 60 * 60 * 1000, + max_future_skew_ms: 0, + }), + ).toThrow("expired"); + + const dailyReceipt = sealReleaseValidationReceipt(mainDailyInputFixture()); + expect( + validateReleaseValidationReceiptReuseFreshness(dailyReceipt, { + now_ms: sealedAtMs + 7 * 60 * 60 * 1000, + max_future_skew_ms: 0, + }), + ).toMatchObject({ + intent: "main-daily", + max_age_ms: 24 * 60 * 60 * 1000, + cadence_ms: 24 * 60 * 60 * 1000, + }); + + expect(() => + validateReleaseValidationReceiptReuseFreshness(betaReceipt, { + now_ms: sealedAtMs, + max_future_skew_ms: 0, + policies: {}, + } as any), + ).toThrow("keys must be exactly"); + }); +}); + describe("release validation receipt canonical bytes and locator", () => { it("rejects unknown fields, duplicate keys, noncanonical bytes, and digest tampering", () => { const input = inputFixture();