Skip to content

fix: take a receipt's encryption claims from the manifest, not the request - #33

Merged
nishchal-gond merged 5 commits into
masterfrom
fix/receipt-claims
Sep 21, 2026
Merged

nishchal-gond merged 5 commits into
masterfrom
fix/receipt-claims

Conversation

@nishchal-gond

@nishchal-gond nishchal-gond commented Sep 21, 2026 •

Copy link
Copy Markdown
Owner

Requested by LPH · project thread

Before: a receipt's encrypted and suite were whatever the uploader typed. They sit inside a statement the gateway stamps verified: true, so a reader takes them as checked — but nothing checked them. A client could post "suite": "totally-made-up-suite-v9" for a document whose manifest says aes-256-gcm and get it signed over the gateway's own signature. The honest direction was just as wrong: a client that simply omitted encrypted had a sealed document receipted as "encrypted": false, which is the more dangerous of the two, because it is the lie a careful client tells by accident.

After: both come from the manifest header the gateway already fetches and validates before it signs anything, and normalizeDocument() strips whatever the client sent alongside verified, for the same reason. With no verifier configured — OREOCHAIN_VERIFY_MANIFESTS=false — neither field appears in the statement at all. That is the honest answer: this gateway did not look, where false and null would be assertions it cannot make.

How

issueReceipt() passes undefined through rather than coercing, and canonicalize() drops undefined keys, so an omitted field is genuinely absent rather than null. That is the same additive rule verified already relies on, which is why receipts issued before any of these fields existed still verify. RECEIPT_VERSION is unchanged, deliberately: nothing in an existing receipt changes meaning.

What this does and does not claim is worth stating plainly. The receipt is now self-consistent with the manifest its own CID commits to — it says "the manifest at this CID says aes-256-gcm", not "this file is genuinely aes-256-gcm". The manifest is still client-authored. That is a real gain, because the CID is in the signed statement and cannot be swapped afterwards, but it is not an authenticity guarantee about the ciphertext and should not be read as one.

The shipped browser client sends both fields already, computed from the same packing the manifest records, so the values it sends and the values now signed agree; the endpoint's documentation keeps them in the sample body with a paragraph saying they are accepted and discarded.

Review notes

Reviewed from a second session, and checked by running rather than by reading.

Beyond the reproduction — post {encrypted: false, suite: "totally-made-up-suite-v9"} for a manifest that says aes-256-gcm, and get the manifest's values back in a statement that verifies against the published key — I checked the things that would make this change unsafe rather than merely correct:

  • verifier.verify() already returned {manifest} before this change, so nothing new is being relied on.
  • validateManifestHeader() requires encrypted to be a boolean and suite to be a string whenever encrypted is true, so Boolean(manifest.encrypted) and manifest.encrypted ? manifest.suite || null : null are well-typed. Forcing suite to null on an unencrypted manifest is right, because that field is unvalidated in that case.
  • No test anywhere stubs the verifier, so the new destructure cannot meet an undefined.
  • The only client readers of encrypted (js/chunked-app.js:675 and :845) read the on-chain record, not the receipt statement, so nothing on the page depends on the field being present.

The four new tests were mutation-checked: with server/proofs.mjs and js/core/receipt.js reverted to master, all four fail (not ok 6..9) and the rest of the file still passes.

Merging this

Count-only: the two generated totals in Readme.md and index.html were the only conflicts, resolved per hunk and regenerated to 537 — master's 533 plus this change's 4. Every master line the merge drops is one of the six this change exists to replace (await verifier.verify(anchorable), the three-line comment and destructure in normalizeDocument(), and the two coerced fields in issueReceipt()); the merged branch differs from master in exactly the four files the change touches.

Tests

537 tests pass on Node 18, 20, 22 and 24, duplicate-declaration scan clean, every module passes node --check.

nishchal-gond and others added 5 commits September 21, 2026 05:50
…quest

Before: a receipt's `encrypted` and `suite` were whatever the uploader put in
the request body, signed as-is inside a statement stamped `"verified": true`.
A client could post `"suite": "totally-made-up-suite-v9"` for a document whose
manifest says `aes-256-gcm` and be handed a signed receipt saying so. The
honest direction was just as wrong: `issueReceipt` coerced a missing
`encrypted` to `false`, so a client that said nothing had its sealed document
receipted as plaintext, over a signature.

After: both fields come from the manifest header the gateway already fetches
and validates in `record()`, and `normalizeDocument()` strips them from the
request alongside `verified` — the same argument, one step removed. With no
verifier configured they are left undefined, `canonicalize()` drops them, and
the statement simply does not carry them: the honest answer is "this gateway
did not look", not `false` and `null`, which are assertions it cannot make.

The omission is additive on exactly the terms `verified` already established,
so RECEIPT_VERSION does not move and every receipt already in a user's hands
still verifies. The shipped browser client sends both fields and is unaffected:
it computes them from the same packing the manifest records, so the values it
sends and the values now signed agree.

How: `record()` keeps the `{manifest}` the verifier returns and sets
`encrypted` and `suite` from it; `issueReceipt` passes `undefined` through
instead of coercing. Four tests in test/verify.test.js cover the forged suite,
the plaintext-claimed-as-sealed direction, the omitted-field direction, and the
no-verifier case, where the receipt is round-tripped through JSON to confirm
the keys are absent and the signature still verifies. All four were confirmed
to fail against the unfixed source.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XxTVcUSsapm2VLtKfSdskp
504 = 500 on master plus the four receipt-claim tests. Generated by
`npm run test-count`, never hand-edited.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XxTVcUSsapm2VLtKfSdskp
Count-only, as expected: the only conflicts were the two generated test-count
lines, taken from master and regenerated afterwards.

server/proofs.mjs merged without one, and both sides survived — the keyring's
import and its openKeyring() call, and this branch's manifest-sourced
`encrypted` and `suite`. The only master lines the merge replaced are the four
this change exists to replace.

509 = 505 on master plus the four receipt-claim tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XxTVcUSsapm2VLtKfSdskp
Count lines only. The two conflicts were the generated totals in Readme.md
and index.html, resolved per hunk and regenerated; every master line the
merge drops in server/proofs.mjs and js/core/receipt.js is one of the six
this change exists to replace.
@nishchal-gond
nishchal-gond merged commit dc60df0 into master Sep 21, 2026
9 checks passed
nishchal-gond added a commit that referenced this pull request Sep 21, 2026
Count-only conflicts in Readme.md and index.html, resolved per hunk and
regenerated. 549 = master's 537 plus this branch's 12. #33 changes what the
gateway signs into a receipt, so the browser suite was re-run against it
rather than trusted: 14/14.
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