feat: normative receipt canonicalization, test vectors, publishable package - #4
Merged
Merged
Conversation
…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>
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.
@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.RECEIPT-VERIFICATION.md states it normatively for the first time. It is
RFC 8785 (JCS) over the envelope's
receiptobject — NOTjson-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:
signaturelives at the envelope level, andsha256_hash/ed25519_sigare appended by the capture response and are NOT part of thesigned payload. An implementer working from a capture response must strip them;
one working from the verify endpoint need not. That distinction was written
nowhere.
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
Remaining, needs your hands
npm publish --access public— needs npm auth; a release decision, not mine to makesigningKeyId: "cd_root_2026"and theexamples/verify-onlyrewrite 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;
--versionreturns 0 correctly andmain()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