Skip to content

Receipt verification (normative)

Audience: anyone verifying an Occam receipt — including implementers in other languages.

An Occam receipt is self-verifying: given the receipt JSON and the signer's public key, a third party can verify integrity offline (signature + optional content hash / Merkle root) with no re-fetch. This does not prove origin authenticity, fetch occurrence, or that the extracted content is true — only that these bytes were signed by the holder of the supplied key and were not altered since. This document is the byte-level contract so you can re-implement the check in any language. The bundled verifier (FFOccamMcp.Core verify, see receipts.md) is the reference implementation.

Key trust is out of scope (v1). This proves the holder of key k1:… signed this, not who that holder is. Pin the public key out of band (the operator publishes it, or you get it with FFOccamMcp.Core keys export). A signed registry with reputation is deferred (a future public key registry).

Primitives

Primitive Value
Signature ECDSA P-256 (NIST prime256v1), digest SHA-256
Signature encoding IEEE P1363 fixed-size r‖s (64 bytes), then base64url (no padding) — not DER
Hash codec "sha256:" + lowercase-hex SHA-256
Public key SPKI / SubjectPublicKeyInfo, PEM (-----BEGIN PUBLIC KEY-----)
keyId "k1:" + first 16 hex chars of SHA-256 over the SPKI DER

1. Content hash

contentHash = "sha256:" + hex(SHA256(utf8(markdown))). To confirm the markdown you hold is the one signed, recompute and compare to receipt.signed.contentHash.

2. Signature (the core check)

The signature covers the canonical bytes of the signed envelope — a hand-fixed field order, not JSON.stringify. Serialize a compact UTF-8 JSON object (no whitespace) emitting only these keys, in exactly this order, skipping any that are absent, and excluding sig:

v (number), kind, url, finalUrl, backend, ts, toolchain,
playbook{ id, version }   // object, only if present
contentHash, blockMerkleRoot, tokens (number), failureCode,
statusCode (number), confidence (number),
keyId, alg

Rules: emit a key only when its value is non-null; numbers are written bare; strings are JSON-escaped; playbook is a nested object with id then version. Then:

signatureValid = ECDSA_P256_SHA256_verify(
    publicKey, canonicalBytes, base64urlDecode(receipt.signed.sig))

A receipt with no sig, or v ≠ 1, is invalid (not merely unverified). A negative receipt (kind:"negative", a signed honest ok:false) is verified the same way — it carries failureCode /statusCode instead of contentHash.

3. Block Merkle root & citations

When the extraction ran with json_blocks, the receipt binds each block. The leaf of block i is:

leaf_i = hex(SHA256(utf8(text_i + "" + (source_selector_i or ""))))

The tree is an ordered binary SHA-256 Merkle tree; when a level has an odd count the last node is duplicated. A parent is SHA256(left_bytes ‖ right_bytes) (over the 32-byte digests, not hex). The root is "sha256:" + hex(top). The unsigned receipt.blockLeaves sidecar is consistent with the signed root when root(blockLeaves) == receipt.signed.blockMerkleRoot (which is signed).

Citation (prove one block was in the signed extraction, without the page or other blocks): recompute leaf from the block text + selector, then fold the proof path — for each step cur = step.siblingIsRight ? SHA256(cur ‖ sibling) : SHA256(sibling ‖ cur) — and check "sha256:"+hex(cur) == blockMerkleRoot, with the receipt signature valid.

4. Dataset manifest (multi-URL sets)

An occam_dataset_export binds a set of extractions. Each row leaf is a SHA-256 over the row fields joined by "\n" in this exact order (empty string for a null field):

rowLeaf = hex(SHA256(utf8(
    url + "\n" + finalUrl + "\n" + (ok ? "1" : "0") + "\n" +
    (contentHash or "") + "\n" + (blockMerkleRoot or "") + "\n" + (failureCode or ""))))

Build the ordered Merkle root over the row leaves (§3 rules; row order is significant) — it must equal manifest.manifestRoot. The manifest signature is a detached ECDSA signature over the manifest's canonical bytes (fixed order, sig excluded):

v (number), createdAt, rowCount (number), manifestRoot, keyId, alg

Verified ⇔ the root reconstructs from the rows and the detached signature checks out. Adding, dropping, editing, or reordering any row breaks it.

5. Watch-history chain

An occam_watch history is a hash-chained, per-entry-signed log. For each entry verify its own signature (canonical over the entry fields, sig excluded) and that prevEntryHash equals the hash of the previous signed entry — so reorder/insert/drop/edit break a link. See Watch/WatchHistory.cs for the entry canonical order.

6. Time anchor (optional)

If receipt.timeAnchor is present (type:"rfc3161"), it is an RFC 3161 TSA token whose message imprint is SHA256(base64urlDecode(receipt.signed.sig)). A valid token proves the receipt existed no later than its genTime, attested by the TSA — independent of the signer's clock. TSA chain-trust (who the TSA is) is out of scope for v1; the tsaSubject is surfaced for you to judge.


Reference implementation: src/FFOccamMcp.Core/Receipts/ (ReceiptCanonicalizer, MerkleTree, ReceiptVerifier), Dataset/DatasetManifest.cs, and the CLI verbs in Cli/OccamCliVerbs.cs. The gate freezes a canonical-bytes golden vector (L_RECEIPT_OK) so this contract cannot drift silently.