mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-27 21:07:01 -06:00
fix(release): authenticate reusable validation evidence
This commit is contained in:
@@ -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<ReleaseValidationVerifiedArtifactEvidence>;
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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<string, unknown>).expired = true;
|
||||
expect(() => authenticateGitHubReleaseValidationArtifact(expired)).toThrow("metadata differs");
|
||||
|
||||
const wrongCreated = githubArtifactAuthenticationParams(raw);
|
||||
(wrongCreated.artifactMetadata as Record<string, unknown>).created_at = "2026-08-21T10:02:00Z";
|
||||
expect(() => authenticateGitHubReleaseValidationArtifact(wrongCreated)).toThrow(
|
||||
"metadata differs",
|
||||
);
|
||||
|
||||
const wrongWorkflow = githubArtifactAuthenticationParams(raw);
|
||||
(wrongWorkflow.workflowRun as Record<string, unknown>).path = ".github/workflows/ci.yml";
|
||||
expect(() => authenticateGitHubReleaseValidationArtifact(wrongWorkflow)).toThrow(
|
||||
"workflow metadata differs",
|
||||
);
|
||||
|
||||
for (const mutate of [
|
||||
(params: ReturnType<typeof githubArtifactAuthenticationParams>) => {
|
||||
(params.artifactMetadata as Record<string, unknown>).id = 9999;
|
||||
},
|
||||
(params: ReturnType<typeof githubArtifactAuthenticationParams>) => {
|
||||
(params.artifactMetadata as Record<string, unknown>).name = "wrong-name";
|
||||
},
|
||||
(params: ReturnType<typeof githubArtifactAuthenticationParams>) => {
|
||||
(params.artifactMetadata as Record<string, unknown>).digest = `sha256:${"f".repeat(64)}`;
|
||||
},
|
||||
(params: ReturnType<typeof githubArtifactAuthenticationParams>) => {
|
||||
(params.workflowRun as Record<string, unknown>).run_attempt = 99;
|
||||
},
|
||||
(params: ReturnType<typeof githubArtifactAuthenticationParams>) => {
|
||||
(params.workflowRun as Record<string, any>).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();
|
||||
|
||||
Reference in New Issue
Block a user