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.
Summary
The
pp-clireference verifier treatsreceiptVersiononly as a required signed field, not as a required v1 constant. A signed, unexpired receipt withreceiptVersion: "v2"is accepted bypp verifywith exit code 0 as long ascanonicalizationisjcs_v1,signatureAlgised25519, the signature verifies, and the receipt is not expired/revoked.That means a verifier claiming
pp-receipt-v1compatibility can silently accept receipts from an unsupported receipt-version namespace.Why this matters
The v1 receipt spec says:
receiptVersionfor this specification is"v1".pp-receipt-v1compatibility must verify required fields and required constants.Downstream gates commonly treat
pp verifysuccess / exit code 0 as an authorization decision. If an issuer, migration path, imported receipt, or future-version compatibility layer produces a signed receipt under anotherreceiptVersion, 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-clisrc/verify.tschecks presence ofreceiptVersionviaREQUIRED_SIGNED_FIELDS, but only enforces these two signed constants:There is no equivalent
receipt.receiptVersion === 'v1'check before returningverified: true.Reproduction
From a fresh
permission-protocol/pp-clicheckout: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
receiptVersionis not"v1".Suggested fix
Fail closed before key resolution/signature verification unless the verifier explicitly supports the receipt version:
A regression test should re-sign a fixture with
receiptVersion: "v2"and assertverifyReceipt()returnsverified: 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/verifyendpoint, 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.