From 808581a1efda514318cc65a117aac2cc9ef3ed8e Mon Sep 17 00:00:00 2001 From: Harlan Wilton Date: Wed, 7 Oct 2026 01:48:02 +1100 Subject: [PATCH] fix(artifact-signer): sign the previous policy through a deploy The deploy updates the signer before the site, and a failed smoke rolls the site back alone. In both windows the running site stages statements under the policy before the signer's, and the signer refused them, so every run in the window failed after the queue retries ran out. The signer now signs the previous policy too, judged by that policy's checks. --- .../artifact-delivery/server/utils/checks.ts | 40 +++++++++++-- .../artifact-delivery/server/utils/state.ts | 3 + test/unit/artifact-signer.test.ts | 58 +++++++++++++++---- workers/artifact-signer/src/handler.ts | 12 +++- 4 files changed, 93 insertions(+), 20 deletions(-) diff --git a/layers/artifact-delivery/server/utils/checks.ts b/layers/artifact-delivery/server/utils/checks.ts index 37181be1..d9e74702 100644 --- a/layers/artifact-delivery/server/utils/checks.ts +++ b/layers/artifact-delivery/server/utils/checks.ts @@ -1,6 +1,7 @@ import type { ArtifactFile, CheckResult, ResolvedSource } from '../schemas/contracts' import type { ArtifactSourceFile } from './github-source' import { digestHex } from './encoding' +import { ARTIFACT_POLICY_VERSION } from './state' /** * 2026-10-07: the check became advisory. It still reports every finding, but @@ -14,13 +15,39 @@ const PATH_POLICY_VERSION = '1' const CREDENTIAL_MATERIAL_VERSION = '1' const EXECUTABLE_FILES_VERSION = '1' -const CURRENT_ARTIFACT_CHECKS = new Map([ +/** The checks a statement under one policy carries, by check name. */ +export type ArtifactCheckSet = ReadonlyMap + +const CURRENT_ARTIFACT_CHECKS: ArtifactCheckSet = new Map([ ['path-policy', { version: PATH_POLICY_VERSION, required: true }], ['agent-skills-spec', { version: AGENT_SKILLS_CHECK_VERSION, required: false }], ['credential-material', { version: CREDENTIAL_MATERIAL_VERSION, required: true }], ['executable-files', { version: EXECUTABLE_FILES_VERSION, required: false }], ]) +/** + * Every policy the artifact signer signs, with the checks its statements carry. + * + * The deploy updates the signer before the site, and a failed smoke rolls the + * site back alone. In both windows the running site stages statements under + * the policy before the signer's, so the signer signs that one too. When you + * bump `ARTIFACT_POLICY_VERSION`, replace the previous entry with the policy + * you bumped from. Leave it out only when the bump closes a safety gap, so the + * signer refuses the old policy at once. + * + * The site never reuses a build under another policy, so a statement signed + * under the previous one only finishes a run already in flight. + */ +export const SIGNABLE_ARTIFACT_POLICIES: ReadonlyMap = new Map([ + [ARTIFACT_POLICY_VERSION, CURRENT_ARTIFACT_CHECKS], + ['2026-08-20.1', new Map([ + ['path-policy', { version: '1', required: true }], + ['agent-skills-spec', { version: '2026-08-20', required: true }], + ['credential-material', { version: '1', required: true }], + ['executable-files', { version: '1', required: false }], + ])], +]) + export interface CheckedArtifactSource { files: ArtifactFile[] checkResults: CheckResult[] @@ -151,11 +178,11 @@ export async function checkArtifactSource( } } -export function checksBlockArtifact(checks: CheckResult[]): boolean { +export function checksBlockArtifact(checks: CheckResult[], checkSet: ArtifactCheckSet = CURRENT_ARTIFACT_CHECKS): boolean { const byName = new Map(checks.map(check => [check.name, check])) if (byName.size !== checks.length) return true - for (const [name, expected] of CURRENT_ARTIFACT_CHECKS) { + for (const [name, expected] of checkSet) { const check = byName.get(name) if (!check || check.version !== expected.version || check.required !== expected.required) return true @@ -163,8 +190,11 @@ export function checksBlockArtifact(checks: CheckResult[]): boolean { return checks.some(check => check.required && (check.outcome === 'fail' || check.outcome === 'error')) } -export function checksPermitSigning(checks: CheckResult[]): boolean { - return !checksBlockArtifact(checks) +/** Whether checks permit signing under one policy. The current policy is the default. */ +export function checksPermitSigning(checks: CheckResult[], policyVersion: string = ARTIFACT_POLICY_VERSION): boolean { + const checkSet = SIGNABLE_ARTIFACT_POLICIES.get(policyVersion) + return checkSet !== undefined + && !checksBlockArtifact(checks, checkSet) && checks.every(check => !check.required || check.outcome === 'pass') } diff --git a/layers/artifact-delivery/server/utils/state.ts b/layers/artifact-delivery/server/utils/state.ts index 84bfba56..008b03ff 100644 --- a/layers/artifact-delivery/server/utils/state.ts +++ b/layers/artifact-delivery/server/utils/state.ts @@ -22,6 +22,9 @@ import { canonicalJson, digestHex } from './encoding' * Bump it when a change to loading, packaging, or checks changes what one * commit produces. A ready build signed under another version is never reused, * so the bump also makes every commit load from GitHub once more. + * + * The signer also signs the policy before this one, so a deploy window fails + * no run. Update `SIGNABLE_ARTIFACT_POLICIES` in `checks.ts` with each bump. */ export const ARTIFACT_POLICY_VERSION = '2026-10-07.1' diff --git a/test/unit/artifact-signer.test.ts b/test/unit/artifact-signer.test.ts index b9ba0905..7749459d 100644 --- a/test/unit/artifact-signer.test.ts +++ b/test/unit/artifact-signer.test.ts @@ -1,4 +1,4 @@ -import type { ResolvedSource } from '../../layers/artifact-delivery/server/schemas/contracts' +import type { CheckResult, ResolvedSource } from '../../layers/artifact-delivery/server/schemas/contracts' import type { ArtifactSignerBindings } from '../../workers/artifact-signer/src/handler' import { describe, expect, it, vi } from 'vitest' import { artifactR2Key } from '../../layers/artifact-delivery/server/utils/artifact-storage' @@ -7,7 +7,9 @@ import { createAttestationStatement, encodeAttestationStatement, } from '../../layers/artifact-delivery/server/utils/attestation' +import { SIGNABLE_ARTIFACT_POLICIES } from '../../layers/artifact-delivery/server/utils/checks' import { bytesToBase64Url, digestHex } from '../../layers/artifact-delivery/server/utils/encoding' +import { ARTIFACT_POLICY_VERSION } from '../../layers/artifact-delivery/server/utils/state' import { ARTIFACT_SIGNER_MAX_REQUEST_BYTES, handleArtifactSignerRequest, @@ -154,6 +156,33 @@ describe('artifact signing Worker', () => { fixture.close() }) + // The deploy updates the signer before the site, and a failed smoke rolls + // the site back alone. In both windows the running site stages statements + // under the policy before the signer's. The signer refused every one, so a + // deploy that bumped the policy failed each run in its window. + it('signs a statement the site staged under the previous policy', async () => { + const [policyVersion, checkSet] = [...SIGNABLE_ARTIFACT_POLICIES].find(([version]) => version !== ARTIFACT_POLICY_VERSION)! + const fixture = await createSignerFixture({ + policyVersion, + checks: [...checkSet].map(([name, check]) => ({ name, version: check.version, outcome: 'pass' as const, required: check.required })), + }) + + const response = await handleArtifactSignerRequest(attestRequest(fixture.artifactId), fixture.bindings, () => NOW) + + expect(response.status).toBe(200) + fixture.close() + }) + + it('rejects current check results under the previous policy', async () => { + const [policyVersion] = [...SIGNABLE_ARTIFACT_POLICIES].find(([version]) => version !== ARTIFACT_POLICY_VERSION)! + const fixture = await createSignerFixture({ policyVersion }) + + const response = await handleArtifactSignerRequest(attestRequest(fixture.artifactId), fixture.bindings, () => NOW) + + expect(await readCode(response)).toBe('CHECKS_BLOCKED') + fixture.close() + }) + it.each([ ['size', { sizeOffset: 1 }], ['stored digest', { storedDigest: 'f'.repeat(64) }], @@ -235,6 +264,8 @@ interface SignerFixtureOptions { body?: Uint8Array } privateArtifact?: boolean + policyVersion?: string + checks?: CheckResult[] } async function createSignerFixture(options: SignerFixtureOptions = {}) { @@ -255,21 +286,24 @@ async function createSignerFixture(options: SignerFixtureOptions = {}) { const r2Key = options.privateArtifact ? `v1/private/account/${RESOLUTION_ID}.bin` : artifactR2Key(contentSha256) - const checks = [ + const checks: CheckResult[] = options.checks ?? [ { name: 'path-policy', version: '1', outcome: options.checkOutcome ?? 'pass', required: true }, { name: 'agent-skills-spec', version: '2026-10-07', outcome: 'pass', required: false }, { name: 'credential-material', version: '1', outcome: 'pass', required: true }, { name: 'executable-files', version: '1', outcome: 'pass', required: false }, - ] as const - const statement = encodeAttestationStatement(createAttestationStatement({ - artifactId, - createdAt: new Date((NOW - 60) * 1000).toISOString(), - source: options.privateArtifact ? { ...source, visibility: 'private' } : source, - contentSha256, - contentBytes: artifactBytes.byteLength, - files: [{ path: 'SKILL.md', mode: 420, size: 1, sha256: 'a'.repeat(64) }], - checkResults: checks.map(check => ({ ...check })), - })) + ] + const statement = encodeAttestationStatement({ + ...createAttestationStatement({ + artifactId, + createdAt: new Date((NOW - 60) * 1000).toISOString(), + source: options.privateArtifact ? { ...source, visibility: 'private' } : source, + contentSha256, + contentBytes: artifactBytes.byteLength, + files: [{ path: 'SKILL.md', mode: 420, size: 1, sha256: 'a'.repeat(64) }], + checkResults: checks.map(check => ({ ...check })), + }), + ...(options.policyVersion ? { policyVersion: options.policyVersion } : {}), + }) sqlite.raw.prepare( `INSERT INTO artifact_resolutions ( id, request_fingerprint, state, state_version, diff --git a/workers/artifact-signer/src/handler.ts b/workers/artifact-signer/src/handler.ts index 5e928fcc..c384a0ca 100644 --- a/workers/artifact-signer/src/handler.ts +++ b/workers/artifact-signer/src/handler.ts @@ -9,7 +9,7 @@ import { } from '../../../layers/artifact-delivery/server/schemas/contracts' import { artifactR2Key } from '../../../layers/artifact-delivery/server/utils/artifact-storage' import { createAttestationSignaturePayload, encodeAttestationStatement } from '../../../layers/artifact-delivery/server/utils/attestation' -import { checksPermitSigning } from '../../../layers/artifact-delivery/server/utils/checks' +import { checksPermitSigning, SIGNABLE_ARTIFACT_POLICIES } from '../../../layers/artifact-delivery/server/utils/checks' import { base64ToBytes, bytesToBase64Url, canonicalJson, digestHex } from '../../../layers/artifact-delivery/server/utils/encoding' import { ARTIFACT_POLICY_VERSION } from '../../../layers/artifact-delivery/server/utils/state' @@ -211,9 +211,15 @@ function validateSigningRow(row: SigningRow, maximumAgeSeconds: number, now: num ) { return failures.artifactIdMismatch() } + // Checks count under the policy the statement names, when the signer signs + // it. An unknown policy counts under the current one and fails below. + const stagedPolicy = artifactAttestationStatementSchema.safeParse(parseJson(row.attestation_statement_json)) + const policyVersion = stagedPolicy.success && SIGNABLE_ARTIFACT_POLICIES.has(stagedPolicy.data.policyVersion) + ? stagedPolicy.data.policyVersion + : ARTIFACT_POLICY_VERSION const checkResultsValue = parseJson(row.check_results_json) const checkResults = z.array(checkResultSchema).min(1).max(100).safeParse(checkResultsValue) - if (!checkResults.success || !checksPermitSigning(checkResults.data)) + if (!checkResults.success || !checksPermitSigning(checkResults.data, policyVersion)) return failures.checksBlocked() const checkAge = now - row.updated_at if (checkAge < 0 || checkAge > maximumAgeSeconds) @@ -226,7 +232,7 @@ function validateSigningRow(row: SigningRow, maximumAgeSeconds: number, now: num const statement = artifactAttestationStatementSchema.safeParse(statementValue) if (!statement.success) return failures.statementChanged() - if (statement.data.policyVersion !== ARTIFACT_POLICY_VERSION) + if (!SIGNABLE_ARTIFACT_POLICIES.has(statement.data.policyVersion)) return failures.policyOutdated() if (row.attestation_statement_json !== encodeAttestationStatement(statement.data)) return failures.statementChanged()