Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions docs/CURRENT-STATUS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
13 changes: 13 additions & 0 deletions services/release-provider/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
23 changes: 22 additions & 1 deletion services/release-provider/src/policy.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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) {
Expand All @@ -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" &&
Expand All @@ -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");
Comment thread
AyobamiH marked this conversation as resolved.
validateReleaseObservation(completion.before, request.subject, false);
validateReleaseObservation(completion.after, request.subject, true);
demand(typeof completion.attemptedAt === "string" &&
Expand Down Expand Up @@ -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,
Comment thread
AyobamiH marked this conversation as resolved.
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") };
}
54 changes: 54 additions & 0 deletions services/release-provider/src/recovery.mjs
Original file line number Diff line number Diff line change
@@ -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));
}
51 changes: 40 additions & 11 deletions services/release-provider/src/worker.mjs
Original file line number Diff line number Diff line change
@@ -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), {
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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);
Expand All @@ -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;
Expand All @@ -133,14 +140,35 @@ 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();
demand(previous, "approval_reservation_missing", 403);
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);
Comment thread
AyobamiH marked this conversation as resolved.
}
// 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);
Expand All @@ -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.
Expand All @@ -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); }
}
}
Expand Down
Loading
Loading