From 03c6c37ae864fab793d873109e6c486cea34f384 Mon Sep 17 00:00:00 2001 From: Ayobami Haastrup <47716486+AyobamiH@users.noreply.github.com> Date: Sun, 18 Oct 2026 11:23:18 +0100 Subject: [PATCH 1/4] fix: identify GitHub release requests and attest durable reservations --- docs/CURRENT-STATUS.md | 14 ++++++++++ services/release-provider/src/policy.mjs | 16 ++++++++++++ services/release-provider/src/worker.mjs | 11 +++++--- .../release-provider/test/provider.test.mjs | 26 +++++++++++++++++-- src/release-v1-github.ts | 3 ++- 5 files changed, 63 insertions(+), 7 deletions(-) diff --git a/docs/CURRENT-STATUS.md b/docs/CURRENT-STATUS.md index 714ac61..bb1d273 100644 --- a/docs/CURRENT-STATUS.md +++ b/docs/CURRENT-STATUS.md @@ -112,3 +112,17 @@ The same candidate preserves the bounded raw, validated HTTP 200 GitHub publicat PR #19 independent review identified the existing no-error-body-retention contract. The follow-up records only validated HTTP 200 publication responses; failed/malformed response bodies remain unretained, with explicit null recovery evidence and a regression test. Canary workflow PR #1 independent review identified that service build revisions and permitted caller-workflow revisions must be checked separately. The follow-up release-only health metadata exposes both, preserving the legacy sourceCommit field for guarded monitoring. Production activation remains pending; a new AgentProof merge requires another unchanged-key patch re-pin and real 17-check canary before closeout. + + +### 18 October: pre-publication refusal and guarded recovery candidate + +Protected release run [39118530819](https://github.com/AyobamiH/agentproof-release-canary/actions/runs/39118530819) +authorised DoneState run `12741172-631f-47b4-88c2-a63eeb1f9574`, but GitHub observation in preparation +was refused before any execution request. Release `416342082` remains an unpublished draft. +The adapter omitted GitHub's mandatory User-Agent header; a read-only request without it reproduced HTTP 403. +This change supplies the application identity and signs an immutable reservation when an exact seed +first wins the existing permanent fence. A reservation is not a receipt or verified outcome. +Attempted/verified targets never expose a reusable reservation; changing a claimed seed remains forbidden. +The separately reviewed DoneState recovery must preserve the old authorisation and all five trust keys, +accept only a fresh exact-run reservation, and require execution after recovery. No target fence is reset. +Current-main patch acceptance must be re-pinned after this source merge. Real release acceptance remains open. diff --git a/services/release-provider/src/policy.mjs b/services/release-provider/src/policy.mjs index 6270bbd..5fb632c 100644 --- a/services/release-provider/src/policy.mjs +++ b/services/release-provider/src/policy.mjs @@ -215,3 +215,19 @@ export async function verifiedReceipt({ request, completion, env }) { export function targetScope(subject) { return subject.repositoryId + ":" + subject.releaseId; } + +// A reservation proves that this exact seed won the permanent target fence. +// It is never a publication receipt and cannot attest an external outcome. +export function reservationEvidence(seed, approval, claims, env) { + const unsigned = { + schema: "agentproof.release-reservation.v1", state: "reserved", + requestDigest: digest({ ...seed, approval }), subjectDigest: digest(seed.subject), + transactionId: seed.transactionId, githubRunId: claims.run_id, + workflowCommit: env.TRUSTED_SOURCE_SHA, + issuedAt: approval.decidedAt, expiresAt: approval.expiresAt, + authorityPublicKeyPem: env.SIGNING_PUBLIC_KEY_PEM, + }; + return { ...unsigned, signatureBase64: sign(null, + Buffer.from("agentproof.release-reservation.v1\0" + stableJson(unsigned)), + privateKey(env).key).toString("base64") }; +} diff --git a/services/release-provider/src/worker.mjs b/services/release-provider/src/worker.mjs index 98a61fe..26c99b3 100644 --- a/services/release-provider/src/worker.mjs +++ b/services/release-provider/src/worker.mjs @@ -3,7 +3,7 @@ import { validateReleaseObservation } from "../../../dist/release-v1-contract.js import { authenticate, githubToken, demand, ReleaseRefusal } from "./identity.mjs"; import { strictBody, readTarget, privateKey, provider, validateSeed, decision, validateSignedRequest, signCompletion, validateCompletion, - verifiedReceipt, targetScope, policy } from "./policy.mjs"; + verifiedReceipt, targetScope, policy, reservationEvidence } from "./policy.mjs"; function reply(payload, status = 200) { return new Response(JSON.stringify(payload), { @@ -89,7 +89,8 @@ export class ReleaseFence { const previous = this.get(); if (previous) { sameSeed(previous, seed); - return { state: previous.state, request: { ...previous.seed, approval: previous.approval } }; + return { state: previous.state, request: { ...previous.seed, approval: previous.approval }, + reservation: previous.state === "reserved" ? previous.reservation ?? null : null }; } const before = await provider(seed.subject, token).observe(seed.subject); validateReleaseObservation(before, seed.subject, false); @@ -99,12 +100,14 @@ export class ReleaseFence { const current = this.get(); if (current) { sameSeed(current, seed); return current; } const approval = decision(seed, this.env); - const value = { state: "reserved", seed, approval, before, + const reservation = reservationEvidence(seed, approval, claims, this.env); + const value = { state: "reserved", seed, approval, before, reservation, attemptedAt: null, verifiedAt: null, after: null, completion: null, publicationResponse: null }; this.set(value); return value; }); - return { state: record.state, request: { ...record.seed, approval: record.approval } }; + return { state: record.state, request: { ...record.seed, approval: record.approval }, + reservation: record.state === "reserved" ? record.reservation ?? null : null }; } async observeAndSettle(seed, token) { let record = this.get(); diff --git a/services/release-provider/test/provider.test.mjs b/services/release-provider/test/provider.test.mjs index 796c24e..d8e8991 100644 --- a/services/release-provider/test/provider.test.mjs +++ b/services/release-provider/test/provider.test.mjs @@ -1,7 +1,7 @@ import test from "node:test"; import assert from "node:assert/strict"; -import { generateKeyPairSync, randomUUID } from "node:crypto"; -import { digest } from "../../../dist/hash.js"; +import { generateKeyPairSync, randomUUID, createPublicKey, verify } from "node:crypto"; +import { digest, stableJson } from "../../../dist/hash.js"; import { publicKeyFingerprint } from "../../../dist/signer.js"; import { verifyReleaseReceiptV2 } from "../../../dist/release-v1-receipt.js"; import { validateClaims, SUBJECT, CANARY_REPOSITORY, WORKFLOW } from "../src/identity.mjs"; @@ -77,6 +77,7 @@ function githubFixture(t, options={}) { const url = new URL(String(input)); assert.equal(url.origin,"https://api.github.com"); assert.equal(init.redirect,"error"); + assert.match(init.headers["user-agent"], /^agentproof-/); if (init.headers.authorization !== undefined) assert.equal(init.headers.authorization,"Bearer test-ephemeral-gh-token"); else assert.equal(init.method,"GET"); @@ -211,3 +212,24 @@ test("rejected publication error bodies are not retained or returned",async t=>{ const recovered=await call("approval",a,"/v1/reconcile",{request:p.json.request}); assert.equal(recovered.json.publicationResponse,null);assert.equal(api.mutationCount,1); }); + + +test("unattempted reservation is signed, durable and never returned after an attempt",async t=>{ + const api=githubFixture(t,{failBeforeEffect:true}), a=env("approval"), draft=seed(); + const first=await call("approval",a,"/v1/prepare",{seed:draft}); + assert.equal(first.status,200); + const {signatureBase64,...unsigned}=first.json.reservation; + assert.equal(unsigned.requestDigest,digest(first.json.request)); + assert.equal(unsigned.githubRunId,claims().run_id); + assert.equal(unsigned.workflowCommit,HEAD); + assert(verify(null,Buffer.from("agentproof.release-reservation.v1\0"+stableJson(unsigned)), + createPublicKey(approvalKey.publicKeyPem),Buffer.from(signatureBase64,"base64"))); + assert.equal(api.mutationCount,0); + const again=await call("approval",a,"/v1/prepare",{seed:draft}); + assert.deepEqual(again.json,first.json); + assert.equal((await call("approval",a,"/v1/prepare",{seed:seed("9003")},claims("9003"))).status,409); + await call("approval",a,"/v1/execute",{request:first.json.request}); + const attempted=await call("approval",a,"/v1/prepare",{seed:draft}); + assert.equal(attempted.json.state,"attempted");assert.equal(attempted.json.reservation,null); + assert.equal(api.mutationCount,1); +}); diff --git a/src/release-v1-github.ts b/src/release-v1-github.ts index 536e60b..d516562 100644 --- a/src/release-v1-github.ts +++ b/src/release-v1-github.ts @@ -49,7 +49,8 @@ export class GitHubReleaseV1Provider implements ReleaseV1Provider { const response = await fetch(this.base + suffix, { method, redirect: "error", cache: "no-store", signal: controller.signal, headers: { "accept": "application/vnd.github+json", "content-type": "application/json", - "authorization": "Bearer " + token, "x-github-api-version": "2022-11-28" }, + "authorization": "Bearer " + token, "x-github-api-version": "2022-11-28", + "user-agent": "agentproof-release-provider/1.0" }, ...(body ? { body: JSON.stringify(body) } : {}), }); releaseAssert(Number(response.headers.get("content-length") ?? 0) < 262144, "github_response_oversized"); From b845df20d0b5ae69b5d7fa7ba2f15cb9e11c1b32 Mon Sep 17 00:00:00 2001 From: Ayobami Haastrup <47716486+AyobamiH@users.noreply.github.com> Date: Sun, 18 Oct 2026 11:38:07 +0100 Subject: [PATCH 2/4] fix: bind recovery causally to committed authority and exact attempt --- docs/CURRENT-STATUS.md | 4 ++ services/release-provider/src/policy.mjs | 6 ++- services/release-provider/src/recovery.mjs | 37 ++++++++++++++++ services/release-provider/src/worker.mjs | 26 ++++++++++- .../release-provider/test/provider.test.mjs | 43 ++++++++++++++++++- 5 files changed, 113 insertions(+), 3 deletions(-) create mode 100644 services/release-provider/src/recovery.mjs diff --git a/docs/CURRENT-STATUS.md b/docs/CURRENT-STATUS.md index bb1d273..4f76998 100644 --- a/docs/CURRENT-STATUS.md +++ b/docs/CURRENT-STATUS.md @@ -126,3 +126,7 @@ Attempted/verified targets never expose a reusable reservation; changing a claim The separately reviewed DoneState recovery must preserve the old authorisation and all five trust keys, accept only a fresh exact-run reservation, and require execution after recovery. No target fence is reset. Current-main patch acceptance must be re-pinned after this source merge. Real release acceptance remains open. + +Independent PR #21 review required run-attempt binding in the reservation. The signed payload now includes the authenticated numeric attempt, and DoneState checks the exact run/attempt pair. Retrieving an existing reservation never relabels its producing attempt. + +Recovery now uses a causal signature gate: DoneState commits the preserved-authority transition before signing a domain-separated grant with its existing handoff key. AgentProof requires that exact grant to activate the same permanent reserved fence before execution. The signed completion records its digest. Timestamp checks remain additional freshness bounds, never the sole ordering proof. Withheld, forged, stale or wrong-attempt grants cannot execute; disabling recovery configuration cannot clear the stored gate. diff --git a/services/release-provider/src/policy.mjs b/services/release-provider/src/policy.mjs index 5fb632c..16cbc03 100644 --- a/services/release-provider/src/policy.mjs +++ b/services/release-provider/src/policy.mjs @@ -117,6 +117,7 @@ export function completionUnsigned(record, env) { subjectDigest: digest(record.seed.subject), attemptedAt: record.attemptedAt, verifiedAt: record.verifiedAt, before: record.before, after: record.after, + recoveryAuthorisationDigest: record.recoveryAuthorisation ? digest(record.recoveryAuthorisation) : null, }; } export function signCompletion(record, env) { @@ -126,7 +127,7 @@ export function signCompletion(record, env) { } export function validateCompletion(completion, request, env) { exactKeys(completion, ["schema", "sourceCommit", "transactionId", "requestDigest", - "subjectDigest", "attemptedAt", "verifiedAt", "before", "after", "signatureBase64"], + "subjectDigest", "attemptedAt", "verifiedAt", "before", "after", "recoveryAuthorisationDigest", "signatureBase64"], "completion_shape"); const { signatureBase64, ...unsigned } = completion; demand(completion.schema === "agentproof.release-canary.execution-evidence.v1" && @@ -135,6 +136,8 @@ export function validateCompletion(completion, request, env) { completion.requestDigest === digest(seedFromRequest(request)) && completion.subjectDigest === digest(request.subject), "invalid_execution_evidence"); + demand(env.RECOVERY_REQUIRED === "true" ? HEX64.test(completion.recoveryAuthorisationDigest) : + completion.recoveryAuthorisationDigest === null, "recovery_completion_required"); validateReleaseObservation(completion.before, request.subject, false); validateReleaseObservation(completion.after, request.subject, true); demand(typeof completion.attemptedAt === "string" && @@ -223,6 +226,7 @@ export function reservationEvidence(seed, approval, claims, env) { schema: "agentproof.release-reservation.v1", state: "reserved", requestDigest: digest({ ...seed, approval }), subjectDigest: digest(seed.subject), transactionId: seed.transactionId, githubRunId: claims.run_id, + githubRunAttempt: Number(claims.run_attempt), workflowCommit: env.TRUSTED_SOURCE_SHA, issuedAt: approval.decidedAt, expiresAt: approval.expiresAt, authorityPublicKeyPem: env.SIGNING_PUBLIC_KEY_PEM, diff --git a/services/release-provider/src/recovery.mjs b/services/release-provider/src/recovery.mjs new file mode 100644 index 0000000..7392a6a --- /dev/null +++ b/services/release-provider/src/recovery.mjs @@ -0,0 +1,37 @@ +import {createPublicKey,verify} from 'node:crypto'; +import {digest,stableJson} from '../../../dist/hash.js'; +import {publicKeyFingerprint} from '../../../dist/signer.js'; +import {exactKeys,releaseTime} from '../../../dist/release-v1-contract.js'; +import {demand} from './identity.mjs'; +export function recoveryPolicy(env) { + if (env.RECOVERY_REQUIRED !== 'true') return null; + const key=createPublicKey(env.RECOVERY_HANDOFF_PUBLIC_KEY_PEM); + const pin=publicKeyFingerprint(env.RECOVERY_HANDOFF_PUBLIC_KEY_PEM); + demand(key.asymmetricKeyType==='ed25519' && pin===env.RECOVERY_HANDOFF_FINGERPRINT && + pin!==env.SIGNER_FINGERPRINT && pin!==env.RECEIPT_FINGERPRINT && pin!==env.AUTHORITY_FINGERPRINT && + /^[a-f0-9-]{36}$/.test(env.RECOVERY_RUN_ID), 'recovery_policy_invalid',503); + return {fingerprint:pin,runId:env.RECOVERY_RUN_ID}; +} +export function validateRecoveryGrant(grant,record,claims,env,now=Date.now()) { + const configured=recoveryPolicy(env); + demand(configured && stableJson(configured)===stableJson(record.recoveryPolicy), 'recovery_policy_changed'); + exactKeys(grant,['schema','runId','transactionId','subjectDigest','githubRunId','githubRunAttempt', + 'workflowCommit','reservationDigest','authorisationAuditDigest','issuedAt','expiresAt', + 'publicKeyPem','signatureBase64'],'recovery_grant_shape'); + demand(grant.schema==='donestate.release-recovery-authorisation.v1' && grant.runId===configured.runId && + grant.transactionId===record.seed.transactionId && grant.subjectDigest===digest(record.seed.subject) && + grant.githubRunId===claims.run_id && grant.githubRunAttempt===Number(claims.run_attempt) && + grant.workflowCommit===env.TRUSTED_SOURCE_SHA && grant.reservationDigest===digest(record.reservation) && + /^[a-f0-9]{64}$/.test(grant.authorisationAuditDigest), 'recovery_grant_binding'); + const issued=releaseTime(grant.issuedAt),expires=releaseTime(grant.expiresAt); + demand(issued<=now+30000 && expires>now && expires>issued && expires-issued<=300000 && + expires<=releaseTime(record.approval.expiresAt), 'recovery_grant_expired'); + const key=createPublicKey(grant.publicKeyPem); + demand(key.asymmetricKeyType==='ed25519' && publicKeyFingerprint(grant.publicKeyPem)===configured.fingerprint, + 'recovery_grant_signer'); + const {signatureBase64,...unsigned}=grant; + demand(typeof signatureBase64==='string' && /^[A-Za-z0-9+/]{86}==$/.test(signatureBase64) && + Buffer.from(signatureBase64,'base64').toString('base64')===signatureBase64 && + verify(null,Buffer.from('donestate.release-recovery-authorisation.v1\0'+stableJson(unsigned)),key, + Buffer.from(signatureBase64,'base64')), 'recovery_grant_signature'); +} diff --git a/services/release-provider/src/worker.mjs b/services/release-provider/src/worker.mjs index 26c99b3..cea83f0 100644 --- a/services/release-provider/src/worker.mjs +++ b/services/release-provider/src/worker.mjs @@ -1,3 +1,4 @@ +import { recoveryPolicy, validateRecoveryGrant } from "./recovery.mjs"; import { digest, stableJson } from "../../../dist/hash.js"; import { validateReleaseObservation } from "../../../dist/release-v1-contract.js"; import { authenticate, githubToken, demand, ReleaseRefusal } from "./identity.mjs"; @@ -47,7 +48,7 @@ export async function handle(request, env, auth = authenticate) { } demand(env.ROLE === "approval" || env.ROLE === "signing", "role_invalid", 503); const paths = env.ROLE === "approval" ? - ["/v1/prepare", "/v1/execute", "/v1/reconcile"] : ["/v1/sign"]; + ["/v1/prepare", "/v1/activate", "/v1/execute", "/v1/reconcile"] : ["/v1/sign"]; demand(request.method === "POST" && paths.includes(url.pathname), "route_not_found", 404); const claims = await auth(request, env); // A receipt signer never receives the caller's release-write credential. @@ -102,6 +103,7 @@ export class ReleaseFence { const approval = decision(seed, this.env); const reservation = reservationEvidence(seed, approval, claims, this.env); const value = { state: "reserved", seed, approval, before, reservation, + recoveryPolicy: recoveryPolicy(this.env), recoveryAuthorisation: null, attemptedAt: null, verifiedAt: null, after: null, completion: null, publicationResponse: null }; this.set(value); return value; @@ -136,6 +138,21 @@ export class ReleaseFence { }); return { state: settled.state, completion: settled.completion, publicationResponse: settled.publicationResponse ?? null }; } + activate(request, grant, claims) { + const {seed}=validateSignedRequest(request,claims,this.env); + return once(this.storage,()=>{ + const record=this.get();demand(record,"approval_reservation_missing");sameSeed(record,seed); + demand(stableJson(record.approval)===stableJson(request.approval),"approval_not_reserved"); + if(record.recoveryAuthorisation) { + demand(digest(record.recoveryAuthorisation)===digest(grant),"recovery_grant_changed"); + return {state:record.state,recoveryAuthorisationDigest:digest(record.recoveryAuthorisation)}; + } + demand(record.state==="reserved","recovery_after_attempt_forbidden",409); + validateRecoveryGrant(grant,record,claims,this.env); + this.set({...record,recoveryAuthorisation:grant}); + return {state:"reserved",recoveryAuthorisationDigest:digest(grant)}; + }); + } async execute(request, claims, token) { const { seed } = validateSignedRequest(request, claims, this.env); const previous = this.get(); @@ -144,6 +161,12 @@ export class ReleaseFence { demand(stableJson(previous.approval) === stableJson(request.approval), "approval_not_reserved", 403); if (previous.state !== "reserved") return this.observeAndSettle(seed, token); + demand(stableJson(previous.recoveryPolicy ?? null)===stableJson(recoveryPolicy(this.env)), + "recovery_policy_changed"); + if(previous.recoveryPolicy) { + demand(previous.recoveryAuthorisation,"recovery_authorisation_required"); + validateRecoveryGrant(previous.recoveryAuthorisation,previous,claims,this.env); + } // Verify pre-state before permanently marking the ONE allowed attempt. const current = await provider(seed.subject, token).observe(seed.subject); validateReleaseObservation(current, seed.subject, false); @@ -181,6 +204,7 @@ export class ReleaseFence { const { body, claims, token } = await request.json(); const path = new URL(request.url).pathname; if (path === "/v1/prepare") return reply(await this.prepare(body.seed, claims, token)); + if (path === "/v1/activate") return reply(this.activate(body.request,body.recoveryAuthorisation,claims)); if (path === "/v1/execute") return reply(await this.execute(body.request, claims, token)); demand(path === "/v1/reconcile", "route_not_found", 404); const { seed } = validateSignedRequest(body.request, claims, this.env, { historical: true }); diff --git a/services/release-provider/test/provider.test.mjs b/services/release-provider/test/provider.test.mjs index d8e8991..572f0df 100644 --- a/services/release-provider/test/provider.test.mjs +++ b/services/release-provider/test/provider.test.mjs @@ -1,6 +1,6 @@ import test from "node:test"; import assert from "node:assert/strict"; -import { generateKeyPairSync, randomUUID, createPublicKey, verify } from "node:crypto"; +import { generateKeyPairSync, randomUUID, createPublicKey, verify, createPrivateKey, sign } from "node:crypto"; import { digest, stableJson } from "../../../dist/hash.js"; import { publicKeyFingerprint } from "../../../dist/signer.js"; import { verifyReleaseReceiptV2 } from "../../../dist/release-v1-receipt.js"; @@ -221,15 +221,56 @@ test("unattempted reservation is signed, durable and never returned after an att const {signatureBase64,...unsigned}=first.json.reservation; assert.equal(unsigned.requestDigest,digest(first.json.request)); assert.equal(unsigned.githubRunId,claims().run_id); + assert.equal(unsigned.githubRunAttempt,Number(claims().run_attempt)); assert.equal(unsigned.workflowCommit,HEAD); assert(verify(null,Buffer.from("agentproof.release-reservation.v1\0"+stableJson(unsigned)), createPublicKey(approvalKey.publicKeyPem),Buffer.from(signatureBase64,"base64"))); assert.equal(api.mutationCount,0); const again=await call("approval",a,"/v1/prepare",{seed:draft}); assert.deepEqual(again.json,first.json); + const rerun=await call("approval",a,"/v1/prepare",{seed:draft},{...claims(),run_attempt:"2"}); + assert.deepEqual(rerun.json.reservation,first.json.reservation); + assert.equal(rerun.json.reservation.githubRunAttempt,1); assert.equal((await call("approval",a,"/v1/prepare",{seed:seed("9003")},claims("9003"))).status,409); await call("approval",a,"/v1/execute",{request:first.json.request}); const attempted=await call("approval",a,"/v1/prepare",{seed:draft}); assert.equal(attempted.json.state,"attempted");assert.equal(attempted.json.reservation,null); assert.equal(api.mutationCount,1); }); + + +test("recovery reservation cannot execute until a distinct signed committed-authority grant activates it",async t=>{ + const api=githubFixture(t),a=env('approval'),handoff=keyPair(),runId=randomUUID(),draft=seed(); + Object.assign(a.vars,{RECOVERY_REQUIRED:'true',RECOVERY_RUN_ID:runId, + RECOVERY_HANDOFF_PUBLIC_KEY_PEM:handoff.publicKeyPem,RECOVERY_HANDOFF_FINGERPRINT:handoff.fingerprint}); + const prepared=(await call('approval',a,'/v1/prepare',{seed:draft})).json; + const blocked=await call('approval',a,'/v1/execute',{request:prepared.request}); + assert.equal(blocked.status,403);assert.equal(blocked.json.error,'recovery_authorisation_required'); + assert.equal(api.mutationCount,0);assert.equal(a.ledger.get('release').state,'reserved'); + const unsigned={schema:'donestate.release-recovery-authorisation.v1',runId, + transactionId:draft.transactionId,subjectDigest:digest(subject),githubRunId:claims().run_id, + githubRunAttempt:1,workflowCommit:HEAD,reservationDigest:digest(prepared.reservation), + authorisationAuditDigest:'d'.repeat(64),issuedAt:prepared.request.approval.decidedAt, + expiresAt:prepared.request.approval.expiresAt,publicKeyPem:handoff.publicKeyPem}; + const signed=value=>({...value,signatureBase64:sign(null, + Buffer.from('donestate.release-recovery-authorisation.v1\0'+stableJson(value)), + createPrivateKey(handoff.privateKeyPem)).toString('base64')}); + const grant=signed(unsigned); + for(const change of [{githubRunAttempt:2},{githubRunId:'9999'},{transactionId:randomUUID()}, + {reservationDigest:'e'.repeat(64)},{expiresAt:unsigned.issuedAt}]) { + const denied=await call('approval',a,'/v1/activate',{request:prepared.request,recoveryAuthorisation:signed({...unsigned,...change})}); + assert.equal(denied.status,403);assert.equal(a.ledger.get('release').recoveryAuthorisation,null); + } + const forged=await call('approval',a,'/v1/activate',{request:prepared.request, + recoveryAuthorisation:{...grant,signatureBase64:'A'.repeat(86)+'=='}});assert.equal(forged.status,403); + a.vars.RECOVERY_REQUIRED='false'; + assert.equal((await call('approval',a,'/v1/execute',{request:prepared.request})).status,403); + assert.equal(api.mutationCount,0);a.vars.RECOVERY_REQUIRED='true'; + const active=await call('approval',a,'/v1/activate',{request:prepared.request,recoveryAuthorisation:grant}); + assert.equal(active.status,200);assert.equal(active.json.recoveryAuthorisationDigest,digest(grant)); + assert.deepEqual((await call('approval',a,'/v1/activate',{request:prepared.request,recoveryAuthorisation:grant})).json,active.json); + const completed=await call('approval',a,'/v1/execute',{request:prepared.request}); + assert.equal(completed.json.state,'verified');assert.equal(api.mutationCount,1); + assert.equal(completed.json.completion.recoveryAuthorisationDigest,digest(grant)); + await call('approval',a,'/v1/execute',{request:prepared.request});assert.equal(api.mutationCount,1); +}); From cfce52d1dced2f176fe2a5960353df203f9ed8cf Mon Sep 17 00:00:00 2001 From: Ayobami Haastrup <47716486+AyobamiH@users.noreply.github.com> Date: Sun, 18 Oct 2026 11:47:06 +0100 Subject: [PATCH 3/4] fix: require gated reservations and complete exact-attempt receipt recovery --- services/release-provider/README.md | 13 +++++++++++++ services/release-provider/src/policy.mjs | 3 ++- services/release-provider/src/worker.mjs | 2 +- services/release-provider/test/provider.test.mjs | 9 +++++++++ services/release-provider/wrangler.approval.jsonc | 6 +++++- services/release-provider/wrangler.signing.jsonc | 6 +++++- 6 files changed, 35 insertions(+), 4 deletions(-) diff --git a/services/release-provider/README.md b/services/release-provider/README.md index 94ee18f..938030e 100644 --- a/services/release-provider/README.md +++ b/services/release-provider/README.md @@ -25,3 +25,16 @@ Activation additionally requires a real test-only draft with exactly two indepen ## Separate deployed-source and caller-source identities The health response retains legacy sourceCommit as the trusted GitHub workflow revision for existing disabled-state monitoring. trustedWorkflowCommit makes that meaning explicit; serviceSourceCommit separately identifies the reviewed AgentProof build revision configured by the deployment process. An unset service revision is null and cannot satisfy production acceptance. The canary checks both identities, and deployment evidence additionally records the Cloudflare version and exact source checkout. No authentication, signing or publication authority changes. + + +## Recovery activation configuration + +Both committed release Worker configurations require recovery by default while remaining disabled. +An activation must set `RECOVERY_REQUIRED=true` on both approval and receipt-signing Workers, +and pin `RECOVERY_RUN_ID`, `RECOVERY_HANDOFF_FINGERPRINT`, and `RECOVERY_HANDOFF_PUBLIC_KEY_PEM` +to the preserved DoneState run and its unchanged handoff role. `/health` exposes `recoveryRequired`; +the protected workflow checks both roles before publication. A recovered completion's non-null +grant digest is refused by an unconfigured receipt signer. Provider tests carry recovery all the +way through separate receipt issuance and independently verify that receipt. +The signed reservation itself binds this recovery-required fence policy, so DoneState cannot +accept an ungated reservation. Never reset a target fence or replace an attempted seed. diff --git a/services/release-provider/src/policy.mjs b/services/release-provider/src/policy.mjs index 16cbc03..f179622 100644 --- a/services/release-provider/src/policy.mjs +++ b/services/release-provider/src/policy.mjs @@ -7,6 +7,7 @@ import { validateReleaseSubject, validateReleaseObservation, validateReleaseRequ validateReleaseApproval, exactKeys, releaseAssert } from "../../../dist/release-v1-contract.js"; import { releasePayload, signReleaseReceiptV2 } from "../../../dist/release-v1-receipt.js"; import { demand, ReleaseRefusal } from "./identity.mjs"; +import { recoveryPolicy } from "./recovery.mjs"; export const AUTHORITY_ISSUER = "agentproof.release-canary.authority.v1"; export const RECEIPT_KEY_ID = "agentproof.release-canary.receipts.v1"; @@ -223,7 +224,7 @@ export function targetScope(subject) { // It is never a publication receipt and cannot attest an external outcome. export function reservationEvidence(seed, approval, claims, env) { const unsigned = { - schema: "agentproof.release-reservation.v1", state: "reserved", + schema: "agentproof.release-reservation.v1", state: "reserved", recoveryPolicy: recoveryPolicy(env), requestDigest: digest({ ...seed, approval }), subjectDigest: digest(seed.subject), transactionId: seed.transactionId, githubRunId: claims.run_id, githubRunAttempt: Number(claims.run_attempt), diff --git a/services/release-provider/src/worker.mjs b/services/release-provider/src/worker.mjs index cea83f0..61fef6e 100644 --- a/services/release-provider/src/worker.mjs +++ b/services/release-provider/src/worker.mjs @@ -44,7 +44,7 @@ export async function handle(request, env, auth = authenticate) { return reply({ service: "agentproof-release-canary", role: env.ROLE, enabled: env.ENABLED === "true", custody: "cloudflare-encrypted-worker-secret", sourceCommit: env.TRUSTED_SOURCE_SHA, trustedWorkflowCommit: env.TRUSTED_SOURCE_SHA, - serviceSourceCommit: env.SERVICE_SOURCE_SHA ?? null, targetConfigured: !!env.TARGET_SUBJECT_JSON }); + serviceSourceCommit: env.SERVICE_SOURCE_SHA ?? null, recoveryRequired: env.RECOVERY_REQUIRED === "true", targetConfigured: !!env.TARGET_SUBJECT_JSON }); } demand(env.ROLE === "approval" || env.ROLE === "signing", "role_invalid", 503); const paths = env.ROLE === "approval" ? diff --git a/services/release-provider/test/provider.test.mjs b/services/release-provider/test/provider.test.mjs index 572f0df..e8848fe 100644 --- a/services/release-provider/test/provider.test.mjs +++ b/services/release-provider/test/provider.test.mjs @@ -272,5 +272,14 @@ test("recovery reservation cannot execute until a distinct signed committed-auth const completed=await call('approval',a,'/v1/execute',{request:prepared.request}); assert.equal(completed.json.state,'verified');assert.equal(api.mutationCount,1); assert.equal(completed.json.completion.recoveryAuthorisationDigest,digest(grant)); + const signer=env('signing'); + const missing=await call('signing',signer,'/v1/sign',{request:prepared.request,completion:completed.json.completion}); + assert.equal(missing.status,403);assert.equal(signer.ledger.get('receipt'),undefined); + signer.vars.RECOVERY_REQUIRED='true'; + const signedReceipt=await call('signing',signer,'/v1/sign',{request:prepared.request,completion:completed.json.completion}); + assert.equal(signedReceipt.status,200);assert.equal(signedReceipt.json.state,'signed'); + assert.equal(verifyReleaseReceiptV2(signedReceipt.json.receipt,{subject, + issuer:'agentproof.release-canary.authority.v1',authorityFingerprints:[approvalKey.fingerprint], + signerFingerprint:receiptKey.fingerprint,signerKeyId:'agentproof.release-canary.receipts.v1'}).trusted,true); await call('approval',a,'/v1/execute',{request:prepared.request});assert.equal(api.mutationCount,1); }); diff --git a/services/release-provider/wrangler.approval.jsonc b/services/release-provider/wrangler.approval.jsonc index 504efcc..03c9b33 100644 --- a/services/release-provider/wrangler.approval.jsonc +++ b/services/release-provider/wrangler.approval.jsonc @@ -34,7 +34,11 @@ "SIGNING_PUBLIC_KEY_PEM": "", "SIGNER_FINGERPRINT": "", "RECEIPT_PUBLIC_KEY_PEM": "", - "RECEIPT_FINGERPRINT": "" + "RECEIPT_FINGERPRINT": "", + "RECOVERY_REQUIRED": "true", + "RECOVERY_RUN_ID": "", + "RECOVERY_HANDOFF_FINGERPRINT": "", + "RECOVERY_HANDOFF_PUBLIC_KEY_PEM": "" }, "observability": { "enabled": false diff --git a/services/release-provider/wrangler.signing.jsonc b/services/release-provider/wrangler.signing.jsonc index 588f9fe..544b25b 100644 --- a/services/release-provider/wrangler.signing.jsonc +++ b/services/release-provider/wrangler.signing.jsonc @@ -34,7 +34,11 @@ "SIGNING_PUBLIC_KEY_PEM": "", "SIGNER_FINGERPRINT": "", "AUTHORITY_PUBLIC_KEY_PEM": "", - "AUTHORITY_FINGERPRINT": "" + "AUTHORITY_FINGERPRINT": "", + "RECOVERY_REQUIRED": "true", + "RECOVERY_RUN_ID": "", + "RECOVERY_HANDOFF_FINGERPRINT": "", + "RECOVERY_HANDOFF_PUBLIC_KEY_PEM": "" }, "observability": { "enabled": false From fdd02760d392a474abf7a3b999f26808c481a5e3 Mon Sep 17 00:00:00 2001 From: Ayobami Haastrup <47716486+AyobamiH@users.noreply.github.com> Date: Sun, 18 Oct 2026 11:55:56 +0100 Subject: [PATCH 4/4] fix: validate recorded recovery grants on read-only settlement Validate the original producing-attempt grant before settling or returning an existing effect. Preserve same-run read-only recovery without authorising another publication. Provider regression tests cover lost settlement, later readers, tampered retained grants and expired-grant readback; all eight provider tests pass. --- docs/CURRENT-STATUS.md | 9 ++++++++ services/release-provider/src/recovery.mjs | 17 ++++++++++++++ services/release-provider/src/worker.mjs | 14 ++++++----- .../release-provider/test/provider.test.mjs | 23 +++++++++++++++++++ 4 files changed, 57 insertions(+), 6 deletions(-) diff --git a/docs/CURRENT-STATUS.md b/docs/CURRENT-STATUS.md index 4f76998..612b8cb 100644 --- a/docs/CURRENT-STATUS.md +++ b/docs/CURRENT-STATUS.md @@ -130,3 +130,12 @@ Current-main patch acceptance must be re-pinned after this source merge. Real re Independent PR #21 review required run-attempt binding in the reservation. The signed payload now includes the authenticated numeric attempt, and DoneState checks the exact run/attempt pair. Retrieving an existing reservation never relabels its producing attempt. Recovery now uses a causal signature gate: DoneState commits the preserved-authority transition before signing a domain-separated grant with its existing handoff key. AgentProof requires that exact grant to activate the same permanent reserved fence before execution. The signed completion records its digest. Timestamp checks remain additional freshness bounds, never the sole ordering proof. Withheld, forged, stale or wrong-attempt grants cannot execute; disabling recovery configuration cannot clear the stored gate. + +The release recovery read lane now verifies the persisted causal grant against its +original producing attempt before settling or returning attempted/verified evidence. +A later authenticated attempt of the same pinned workflow run may reconcile using +GET only; it never receives new publication authority. The original signed grant +and its digest remain unchanged. Regression coverage exercises a lost settlement, +a later-attempt readback, tampered stored grants and read-only recovery after grant +expiry, with the publication count fixed at one. This remains source validation, +not a real publication or DoneState terminal outcome. diff --git a/services/release-provider/src/recovery.mjs b/services/release-provider/src/recovery.mjs index 7392a6a..367091d 100644 --- a/services/release-provider/src/recovery.mjs +++ b/services/release-provider/src/recovery.mjs @@ -35,3 +35,20 @@ export function validateRecoveryGrant(grant,record,claims,env,now=Date.now()) { verify(null,Buffer.from('donestate.release-recovery-authorisation.v1\0'+stableJson(unsigned)),key, Buffer.from(signatureBase64,'base64')), 'recovery_grant_signature'); } + +// A later authenticated attempt may inspect the original effect, never spend +// the original grant again. Verify that grant at its recorded issuance time. +export function validateRecordedRecoveryGrant(record, claims, env) { + const configured = recoveryPolicy(env); + demand(stableJson(configured) === stableJson(record.recoveryPolicy ?? null), + 'recovery_policy_changed'); + if (!configured) return; + const grant = record.recoveryAuthorisation; + demand(grant && record.reservation, 'recovery_authorisation_required'); + const producingAttempt = record.reservation.githubRunAttempt; + demand(record.reservation.githubRunId === claims.run_id && + Number.isSafeInteger(producingAttempt) && producingAttempt >= 1 && + Number(claims.run_attempt) >= producingAttempt, 'recovery_reader_binding'); + validateRecoveryGrant(grant, record, + {...claims, run_attempt: String(producingAttempt)}, env, releaseTime(grant.issuedAt)); +} diff --git a/services/release-provider/src/worker.mjs b/services/release-provider/src/worker.mjs index 61fef6e..71cfd93 100644 --- a/services/release-provider/src/worker.mjs +++ b/services/release-provider/src/worker.mjs @@ -1,4 +1,4 @@ -import { recoveryPolicy, validateRecoveryGrant } from "./recovery.mjs"; +import { recoveryPolicy, validateRecoveryGrant, validateRecordedRecoveryGrant } from "./recovery.mjs"; import { digest, stableJson } from "../../../dist/hash.js"; import { validateReleaseObservation } from "../../../dist/release-v1-contract.js"; import { authenticate, githubToken, demand, ReleaseRefusal } from "./identity.mjs"; @@ -111,9 +111,11 @@ export class ReleaseFence { return { state: record.state, request: { ...record.seed, approval: record.approval }, reservation: record.state === "reserved" ? record.reservation ?? null : null }; } - async observeAndSettle(seed, token) { + async observeAndSettle(seed, token, claims) { let record = this.get(); sameSeed(record, seed); + if (record.state === "attempted" || record.state === "verified") + validateRecordedRecoveryGrant(record, claims, this.env); if (record.state === "verified") return { state: record.state, completion: record.completion, publicationResponse: record.publicationResponse ?? null }; if (record.state !== "attempted") return { state: record.state, completion: null }; let after; @@ -160,7 +162,7 @@ export class ReleaseFence { sameSeed(previous, seed); demand(stableJson(previous.approval) === stableJson(request.approval), "approval_not_reserved", 403); - if (previous.state !== "reserved") return this.observeAndSettle(seed, token); + if (previous.state !== "reserved") return this.observeAndSettle(seed, token, claims); demand(stableJson(previous.recoveryPolicy ?? null)===stableJson(recoveryPolicy(this.env)), "recovery_policy_changed"); if(previous.recoveryPolicy) { @@ -180,7 +182,7 @@ export class ReleaseFence { this.set({ ...record, state: "attempted", attemptedAt: new Date().toISOString() }); return true; }); - if (!claimed) return this.observeAndSettle(seed, token); + if (!claimed) return this.observeAndSettle(seed, token, claims); const publisher = provider(seed.subject, token); try { // Exactly one GitHub PATCH in the entire lifetime of this target fence. @@ -197,7 +199,7 @@ export class ReleaseFence { this.set({ ...record, publicationResponse: publisher.publicationResponse }); }); } - return this.observeAndSettle(seed, token); + return this.observeAndSettle(seed, token, claims); } async fetch(request) { try { @@ -208,7 +210,7 @@ export class ReleaseFence { if (path === "/v1/execute") return reply(await this.execute(body.request, claims, token)); demand(path === "/v1/reconcile", "route_not_found", 404); const { seed } = validateSignedRequest(body.request, claims, this.env, { historical: true }); - return reply(await this.observeAndSettle(seed, token)); + return reply(await this.observeAndSettle(seed, token, claims)); } catch (error) { return failure(error); } } } diff --git a/services/release-provider/test/provider.test.mjs b/services/release-provider/test/provider.test.mjs index e8848fe..518340d 100644 --- a/services/release-provider/test/provider.test.mjs +++ b/services/release-provider/test/provider.test.mjs @@ -282,4 +282,27 @@ test("recovery reservation cannot execute until a distinct signed committed-auth issuer:'agentproof.release-canary.authority.v1',authorityFingerprints:[approvalKey.fingerprint], signerFingerprint:receiptKey.fingerprint,signerKeyId:'agentproof.release-canary.receipts.v1'}).trusted,true); await call('approval',a,'/v1/execute',{request:prepared.request});assert.equal(api.mutationCount,1); + const laterAttempt={...claims(),run_attempt:'2'}; + const committed=a.ledger.get('release'); + // Simulate losing settlement after the sole external effect. + a.ledger.set('release',{...committed,state:'attempted',completion:null,after:null,verifiedAt:null}); + const reconciled=await call('approval',a,'/v1/reconcile',{request:prepared.request},laterAttempt); + assert.equal(reconciled.status,200);assert.equal(reconciled.json.state,'verified'); + assert.equal(reconciled.json.completion.recoveryAuthorisationDigest,digest(grant)); + assert.equal(api.mutationCount,1); + const retained=a.ledger.get('release'); + assert.deepEqual(retained.recoveryAuthorisation,grant); + a.ledger.set('release',{...retained,recoveryAuthorisation:{...grant,signatureBase64:'A'.repeat(86)+'=='}}); + for(const path of ['/v1/reconcile','/v1/execute']){ + const invalid=await call('approval',a,path,{request:prepared.request},laterAttempt); + assert.equal(invalid.status,403);assert.equal(api.mutationCount,1); + } + a.ledger.set('release',retained); + const afterExpiry=Date.parse(grant.expiresAt)+1000; + t.mock.method(Date,'now',()=>afterExpiry); + const historical=await call('approval',a,'/v1/reconcile',{request:prepared.request},laterAttempt); + assert.equal(historical.status,200); + assert.deepEqual(historical.json.completion,reconciled.json.completion); + assert.equal(api.mutationCount,1); + });