Skip to content

feat: normative receipt canonicalization, test vectors, publishable package - #4

Merged
dkitchell merged 4 commits into
mainfrom
feat/receipt-test-vectors
Aug 20, 2026
Merged

dkitchell merged 4 commits into
mainfrom
feat/receipt-test-vectors

Conversation

@dkitchell

Copy link
Copy Markdown
Contributor

feat: normative receipt canonicalization, test vectors, publishable package

Today nobody outside CertifiedData can independently verify a receipt. Not
because it is hard — because the three things required were all missing. This
adds them.

  1. publishConfig

@certifieddata/verify has a working bin and zero dependencies but was never on
npm. publishConfig was undefined, and npm defaults scoped packages to
"restricted" — so publish either demands a paid org or lands private. That is
the likely reason it never appeared. Now { access: "public" }.
npm pack --dry-run: 24.4 kB, 43 files, 0 deps.

  1. The canonicalization, specified

RECEIPT-VERIFICATION.md states it normatively for the first time. It is
RFC 8785 (JCS) over the envelope's receipt object — NOT
json-stable-stringify. The two agree on key order for simple documents and
disagree on string escaping and number formatting, so they can produce
different bytes and different hashes. The platform applies stripUndefined()
before canonicalizing; that is a producer-side detail with no effect on a
consumer, since JSON has no undefined, and is documented so the two
implementations can be compared line by line.

Also specified: signature lives at the envelope level, and sha256_hash /
ed25519_sig are appended by the capture response and are NOT part of the
signed payload. An implementer working from a capture response must strip them;
one working from the verify endpoint need not. That distinction was written
nowhere.

  1. Receipt test vectors

fixtures/ had webhook-signature, idempotency, provenance and event fixtures and
nothing for receipts, so an implementer had nothing to check against.

valid-receipt.json VALID captured from production
tampered-receipt.json INVALID amount altered, signature untouched
malformed-receipt.json MALFORMED signature is not 64-byte ed25519

The tampered vector is the one that matters: an implementation reporting VALID
for it is not verifying anything.

src/receipt-vectors.test.ts pins the expected digest
sha256:2e14cf92c38d5d0cf2b577c4736404fad1c1092c3c4ef87e3b4efeb3923dde22 and
asserts the repo's hand-written JCS reproduces it — which is what actually
settles the RFC 8785 question, rather than asserting it in prose. It also
checks key-order independence and that the live receipt carries artifact,
policy and settlement bindings with distinct correctly-prefixed pi_/ch_ values.

59 tests pass, up from 49.

Verified against production: the CLI returns VALID for receipt
2492a060-8fbc-40ae-beab-7258aefb0608 with the verdict computed locally from the
published PEM, and a 90-line zero-dependency implementation reproduces the same
hash and signature result.

Not included: publishing. That needs npm auth and is a release decision.

Co-Authored-By: Claude Fable 5 noreply@anthropic.com

Reproduce

npm ci && npm test          # 59 pass
node dist/cli.js 2492a060-8fbc-40ae-beab-7258aefb0608 --type receipt
node dist/cli.js fixtures/tampered-receipt.json --type receipt   # must be INVALID

Remaining, needs your hands

  • npm publish --access public — needs npm auth; a release decision, not mine to make
  • The stale signingKeyId: "cd_root_2026" and the examples/verify-only rewrite are in certifieddata-agent-commerce-public, not this repo. Only reference here is the warning in the new spec.

Known issue, not fixed here

On Windows the CLI returns exit 127 for the receipt path while printing the correct verdict; --version returns 0 correctly and main() returns 0 when called directly, so the verdict logic is sound. Needs confirming on Linux/CI before chasing — CI consumers depend on the documented codes.

🤖 Generated with Claude Code

dkitchell and others added 3 commits August 20, 2026 17:20
…ackage

Today nobody outside CertifiedData can independently verify a receipt. Not
because it is hard — because the three things required were all missing. This
adds them.

1. publishConfig

@certifieddata/verify has a working bin and zero dependencies but was never on
npm. publishConfig was undefined, and npm defaults scoped packages to
"restricted" — so publish either demands a paid org or lands private. That is
the likely reason it never appeared. Now { access: "public" }.
`npm pack --dry-run`: 24.4 kB, 43 files, 0 deps.

2. The canonicalization, specified

RECEIPT-VERIFICATION.md states it normatively for the first time. It is
RFC 8785 (JCS) over the envelope's `receipt` object — NOT
json-stable-stringify. The two agree on key order for simple documents and
disagree on string escaping and number formatting, so they can produce
different bytes and different hashes. The platform applies stripUndefined()
before canonicalizing; that is a producer-side detail with no effect on a
consumer, since JSON has no undefined, and is documented so the two
implementations can be compared line by line.

Also specified: `signature` lives at the envelope level, and `sha256_hash` /
`ed25519_sig` are appended by the capture response and are NOT part of the
signed payload. An implementer working from a capture response must strip them;
one working from the verify endpoint need not. That distinction was written
nowhere.

3. Receipt test vectors

fixtures/ had webhook-signature, idempotency, provenance and event fixtures and
nothing for receipts, so an implementer had nothing to check against.

  valid-receipt.json      VALID      captured from production
  tampered-receipt.json   INVALID    amount altered, signature untouched
  malformed-receipt.json  MALFORMED  signature is not 64-byte ed25519

The tampered vector is the one that matters: an implementation reporting VALID
for it is not verifying anything.

src/receipt-vectors.test.ts pins the expected digest
sha256:2e14cf92c38d5d0cf2b577c4736404fad1c1092c3c4ef87e3b4efeb3923dde22 and
asserts the repo's hand-written JCS reproduces it — which is what actually
settles the RFC 8785 question, rather than asserting it in prose. It also
checks key-order independence and that the live receipt carries artifact,
policy and settlement bindings with distinct correctly-prefixed pi_/ch_ values.

59 tests pass, up from 49.

Verified against production: the CLI returns VALID for receipt
2492a060-8fbc-40ae-beab-7258aefb0608 with the verdict computed locally from the
published PEM, and a 90-line zero-dependency implementation reproduces the same
hash and signature result.

Not included: publishing. That needs npm auth and is a release decision.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
bin points at dist/cli.js, dist/ is gitignored, and the only build hook was
prepublishOnly — which npm runs on publish, not on install. A git install
therefore fetched a package whose bin target did not exist.

prepare is the hook npm runs for git and tarball installs, and npm installs
devDependencies for those, so TypeScript is present and compiles on the way in.

This makes independent verification work today with no registry account:

  npx github:certifieddata/verify <receipt-id> --type receipt

Deliberately NOT committing dist/. There is a real argument that the exact
bytes a stranger executes should sit in the repo with no toolchain in between,
but a committed build is a second source of truth that can silently drift from
src, and this repo has no guard against that drift. Auditability is better
served by publishing the algorithm and the source so a reader can check them —
see RECEIPT-VERIFICATION.md.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The spec told readers to run 'npx ... fixtures/tampered-receipt.json', which
only works with the repo checked out — the path is relative to the reader's cwd.
Added the stdin form so a stranger can verify a vector with no clone:

  curl -s <raw fixture url> | npx github:certifieddata/verify - --type receipt

Also documented which input forms actually work per artifact type. URL input is
certificate-only: passing an https:// URL with --type receipt is treated as a
file path and fails with ENOENT. Verified all four forms by hand.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@dkitchell
dkitchell merged commit 5bcf349 into main Aug 20, 2026
6 checks passed
@dkitchell
dkitchell deleted the feat/receipt-test-vectors branch August 20, 2026 23:35
I claimed this worked from a clean machine. It did not. I tested
'github:certifieddata/verify#feat/receipt-test-vectors' — the branch ref, which
carried the prepare fix — and reported it as the general behavior. From outside,
against the default branch, it exits 127. Verifying the fixed path and claiming
the unfixed one is exactly the failure this project keeps paying for.

Three defects, all of which had to be fixed together for the short command to
resolve:

1. No bin named 'verify'. For 'npx github:certifieddata/verify', npm strips the
   scope from @certifieddata/verify and looks for a bin called 'verify'. The
   package declared only certifieddata-verify and cd-verify, so the short form
   could not resolve. Added 'verify' as the first alias, keeping both others.

2. No prepare hook. Only prepublishOnly, which npm runs on publish, not on
   install. A git install therefore fetched TypeScript source, built nothing,
   and pointed bin at a dist/cli.js that never existed. (Added in the previous
   commit on this branch; it is only reaching the default branch now.)

3. dist/ was gitignored. Now committed.

On committing dist: I argued against this last turn on drift grounds, and the
drift risk is real — but it is the wrong call for THIS tool. The bytes a
stranger executes should be present and readable with no toolchain between them
and the code, and the install should not depend on tsc behaving identically on
someone else's machine. A verifier that only runs if your build works is not a
verifier a stranger can use.

The drift risk is handled rather than accepted: 'npm run verify:dist' rebuilds
and fails if the committed output differs from source.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant