Skip to content

Reference verifier accepts null required signed fields omitted from signature bytes #54

@oathis

Description

@oathis

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions