Summary
The pp-cli reference verifier only checks required signed-field presence with field in receipt, while the canonicalizer omits any signed field whose value is undefined or null.
As a result, a receipt can satisfy required-field validation with requestJson: null even though requestJson is absent from the canonical bytes covered by the Ed25519 signature. pp verify then returns verified: true for a receipt that has no signed operation scope.
Why this matters
Deploy Gate is meant to bind a receipt to the exact action being authorized. The v1 spec describes requestJson as required operation context with typical fields such as signer, action, repo, and commitSha. It also says verifiers must reject receipts that omit required v1 signed fields and that signed fields are the exact set cryptographically protected by the signature.
The current implementation creates a gap between those two guarantees:
- Required-field validation accepts
null as present.
- Canonicalization removes
null fields from the signature input.
- Verification can therefore succeed for a receipt whose required signed operation context was not signed at all.
This is distinct from #51: there is no nested __proto__ content or prototype behavior involved. This is top-level required signed fields being accepted as null and omitted from signature bytes.
Affected code
Repository: permission-protocol/pp-cli
src/verify.ts:
for (const field of REQUIRED_SIGNED_FIELDS) {
if (!(field in receipt)) {
return { verified: false, ... };
}
}
src/canonicalize.ts:
for (const field of SIGNED_FIELDS) {
const value = receipt[field];
if (value !== undefined && value !== null) {
canonical[field] = serializeValue(value);
}
}
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'));
// Simulate a signature over bytes where the required operation scope is absent.
delete receipt.requestJson;
receipt.signatureValue = sign(
null,
canonicalizeReceiptBytes(receipt),
createPrivateKey(readFileSync('tests/fixtures/private-key.pem')),
).toString('base64');
// Add a null field after signing. Required-field validation now sees the field,
// but canonicalization still omits it from the signature input.
receipt.requestJson = null;
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_valid_001",
"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: the verifier should reject this as malformed because requestJson is required, must be a JSON object, and is part of the signed operation scope.
The same pattern applies to other required signed fields that are not independently checked after verification: a null value satisfies field in receipt, but is omitted from canonical bytes.
Suggested fix
Fail closed before canonicalization/signature verification when a required signed field is missing, inherited, undefined, or null. For top-level v1 fields, also enforce the documented minimum types:
for (const field of REQUIRED_SIGNED_FIELDS) {
if (!Object.hasOwn(receipt, field) || receipt[field] === undefined || receipt[field] === null) {
return {
verified: false,
exitCode: 3,
errorCode: 'MALFORMED_RECEIPT',
errorMessage: `missing required signed field: ${field}`,
receiptId: typeof receipt.id === 'string' ? receipt.id : undefined,
};
}
}
At minimum, requestJson should be a non-null object and reasonCodes should be an array. The string-valued fields should be non-empty strings where the v1 format requires strings.
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 only checks required signed-field presence withfield in receipt, while the canonicalizer omits any signed field whose value isundefinedornull.As a result, a receipt can satisfy required-field validation with
requestJson: nulleven thoughrequestJsonis absent from the canonical bytes covered by the Ed25519 signature.pp verifythen returnsverified: truefor a receipt that has no signed operation scope.Why this matters
Deploy Gate is meant to bind a receipt to the exact action being authorized. The v1 spec describes
requestJsonas required operation context with typical fields such assigner,action,repo, andcommitSha. It also says verifiers must reject receipts that omit required v1 signed fields and that signed fields are the exact set cryptographically protected by the signature.The current implementation creates a gap between those two guarantees:
nullas present.nullfields from the signature input.This is distinct from #51: there is no nested
__proto__content or prototype behavior involved. This is top-level required signed fields being accepted asnulland omitted from signature bytes.Affected code
Repository:
permission-protocol/pp-clisrc/verify.ts:src/canonicalize.ts:Reproduction
From a fresh
permission-protocol/pp-clicheckout:Observed output:
{ "verified": true, "receiptId": "rcpt_valid_001", "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: the verifier should reject this as malformed because
requestJsonis required, must be a JSON object, and is part of the signed operation scope.The same pattern applies to other required signed fields that are not independently checked after verification: a
nullvalue satisfiesfield in receipt, but is omitted from canonical bytes.Suggested fix
Fail closed before canonicalization/signature verification when a required signed field is missing, inherited,
undefined, ornull. For top-level v1 fields, also enforce the documented minimum types:At minimum,
requestJsonshould be a non-null object andreasonCodesshould be an array. The string-valued fields should be non-empty strings where the v1 format requires strings.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.