diff --git a/docs/CURRENT-STATUS.md b/docs/CURRENT-STATUS.md index 714ac61..612b8cb 100644 --- a/docs/CURRENT-STATUS.md +++ b/docs/CURRENT-STATUS.md @@ -112,3 +112,30 @@ 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. + +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/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 6270bbd..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"; @@ -117,6 +118,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 +128,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 +137,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" && @@ -215,3 +219,20 @@ 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", recoveryPolicy: recoveryPolicy(env), + 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, + }; + 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/recovery.mjs b/services/release-provider/src/recovery.mjs new file mode 100644 index 0000000..367091d --- /dev/null +++ b/services/release-provider/src/recovery.mjs @@ -0,0 +1,54 @@ +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'); +} + +// 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 98a61fe..71cfd93 100644 --- a/services/release-provider/src/worker.mjs +++ b/services/release-provider/src/worker.mjs @@ -1,9 +1,10 @@ +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"; 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), { @@ -43,11 +44,11 @@ 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" ? - ["/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. @@ -89,7 +90,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,16 +101,21 @@ 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, + recoveryPolicy: recoveryPolicy(this.env), recoveryAuthorisation: null, 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) { + 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; @@ -133,6 +140,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(); @@ -140,7 +162,13 @@ 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) { + 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); @@ -154,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. @@ -171,17 +199,18 @@ export class ReleaseFence { this.set({ ...record, publicationResponse: publisher.publicationResponse }); }); } - return this.observeAndSettle(seed, token); + return this.observeAndSettle(seed, token, claims); } async fetch(request) { try { 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 }); - 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 796c24e..518340d 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, 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"; 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,97 @@ 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.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)); + 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); + 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); + +}); 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 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");