Skip to content

Reference verifier accepts unsupported receiptVersion values as valid #53

Description

@oathis

Summary

The pp-cli reference verifier treats receiptVersion only as a required signed field, not as a required v1 constant. A signed, unexpired receipt with receiptVersion: "v2" is accepted by pp verify with exit code 0 as long as canonicalization is jcs_v1, signatureAlg is ed25519, the signature verifies, and the receipt is not expired/revoked.

That means a verifier claiming pp-receipt-v1 compatibility can silently accept receipts from an unsupported receipt-version namespace.

Why this matters

The v1 receipt spec says:

  • receiptVersion for this specification is "v1".
  • Breaking changes to field semantics, required fields, signature rules, or verifier acceptance criteria must use a new receipt version namespace.
  • An implementation claiming pp-receipt-v1 compatibility must verify required fields and required constants.

Downstream gates commonly treat pp verify success / exit code 0 as an authorization decision. If an issuer, migration path, imported receipt, or future-version compatibility layer produces a signed receipt under another receiptVersion, the v1 verifier should fail closed unless it explicitly implements that version's semantics.

This is distinct from #49 and #52: the issue is unsupported receipt-version acceptance, not lifecycle-status acceptance or key/algorithm confusion.

Affected code

Repository: permission-protocol/pp-cli

src/verify.ts checks presence of receiptVersion via REQUIRED_SIGNED_FIELDS, but only enforces these two signed constants:

if (receipt.canonicalization !== CANONICALIZATION_VERSION) { ... }

if (receipt.signatureAlg !== 'ed25519') { ... }

There is no equivalent receipt.receiptVersion === 'v1' check before returning verified: true.

Reproduction

From a fresh permission-protocol/pp-cli checkout:

npm ci
npm run build
node --input-type=module <<'NODE'
import { readFileSync } from 'node:fs';
import { createPrivateKey, sign } from 'node:crypto';
import { spawnSync } from 'node:child_process';
import { canonicalizeReceiptBytes } from './dist/canonicalize.js';

const receipt = JSON.parse(readFileSync('tests/fixtures/valid.json', 'utf8'));
receipt.id = 'rcpt-version-bypass-poc';
receipt.receiptVersion = 'v2';
receipt.summary = 'PoC: unsupported receiptVersion v2 is accepted by the v1 verifier';
receipt.signatureValue = sign(
  null,
  canonicalizeReceiptBytes(receipt),
  createPrivateKey(readFileSync('tests/fixtures/private-key.pem')),
).toString('base64');

const child = spawnSync(
  process.execPath,
  ['dist/cli.js', 'verify', '-', '--key-file', 'tests/fixtures/public-key.pem', '--no-network', '--json'],
  { input: JSON.stringify(receipt), encoding: 'utf8' },
);

process.stdout.write(child.stdout);
process.stderr.write(child.stderr);
process.exit(child.status ?? 1);
NODE

Observed output:

{
  "verified": true,
  "receiptId": "rcpt-version-bypass-poc",
  "signer": "alice@corp.com",
  "action": "deploy:production",
  "repo": "acme/billing-api",
  "commitSha": "9f2c1a7",
  "policy": "prod-deploy-v2",
  "signedAt": "2026-04-30T16:23:11.000Z",
  "expiresAt": "2026-12-31T00:00:00.000Z",
  "canonicalization": "jcs_v1",
  "signatureAlg": "ed25519",
  "keyId": "pp-test-2026-q2",
  "keySource": "tests/fixtures/public-key.pem"
}

Expected behavior: a v1 verifier should reject the receipt as malformed/unsupported because receiptVersion is not "v1".

Suggested fix

Fail closed before key resolution/signature verification unless the verifier explicitly supports the receipt version:

const RECEIPT_VERSION = 'v1';

if (receipt.receiptVersion !== RECEIPT_VERSION) {
  return {
    verified: false,
    exitCode: 3,
    errorCode: 'MALFORMED_RECEIPT',
    errorMessage: 'unsupported receipt version',
    receiptId: receipt.id as string,
  };
}

A regression test should re-sign a fixture with receiptVersion: "v2" and assert verifyReceipt() returns verified: false.

Scope

I reproduced this against the local/reference verifier path (pp verify / verifyReceipt). I have not demonstrated a forged receipt against the hosted /api/v1/receipts/verify endpoint, which may have a separate implementation.

Bounty note

Submitted for assessment under #36 as a distinct verification-flow fail-closed bug. Payout details can be provided privately if accepted.

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