Skip to main content
Specification · v1.0.0Raw markdown

CertNode Receipt Format — cn.receipt.v1

Spec version: 1.0.0 · Status: Published · Date: 2026-06-11 Conformance vectors (normative): /.well-known/conformance/v1/ Reference verifier: @certnode/verify (npm, MIT)

0. Non-claims (read first)

This specification makes claims a third party can reproduce, and only those:

  • A receipt is self-authenticating under FRE 902(13)/(14), subject to certification and tribunal review. It is never "court-admissible," "legally binding," "immutable," "tamper-proof," or "trustless" — those words do not appear as claims anywhere in this format.
  • The Bitcoin layer's default verification path is header-verified (explorer-backed) — a lite-client model that trusts independent block explorers to report real headers. It is trustless only when the verifier supplies headers from their own Bitcoin node.
  • CertNode's internal seal (Layer 1) proves nothing to a third party and is excluded from every trust verdict.
  • No win-rate or outcome claims are made or implied by any receipt.

The honest negative space in §7 (what each layer does NOT prove) is part of this spec.

1. The receipt object

A receipt is a database row with these verification-relevant fields:

FieldTypeMeaning
idtextReceipt identifier. Bound into the signature as the JWS sub (anti-swap).
dataJSON objectThe signed payload — the ONLY hash-covered content. Shape in §1.1.
hashhex stringSHA-256 of the RFC 8785 canonicalization of data (§2). Lowercase, unprefixed.
signaturestringCompact ES256 JWS over the payload (§3).
certnode_timestampJSON objectLayer 1 internal seal (issuer-attested HMAC — informational only, §4 step 7).
rfc3161_timestampbase64RFC 3161 TimeStampResp from an independent TSA (§4 step 5).
rfc3161_timestamp_secondarybase64?Optional second token from an independent TSA over the same bytes (dual-stamp; §4 step 5d).
bitcoin_anchorJSON/textOpenTimestamps proof + status (§4 step 6). Carries ots_proof (base64), bitcoin_block_height, bitcoin_block_time. Never a txid — OTS attests a block height.
parent_idstext[]Canonical graph edges (lineage). Graph traversal is display-only — each node verifies individually.

1.1 The signed payload — receipts.data (the real shape)

The hashed + signed payload is a flat object: the caller's domain claims plus the self-describing stamps. INLINE mode (data recorded in the receipt):

{
  // ...caller's flat domain claims (kind-specific)...
  "kind": "cn.receipt.transaction",      // namespaced subject kind
  "description": "...",                  // optional
  "certified_at": "2026-06-11T00:00:00.000Z",
  "schema_version": "cn.receipt.v1",     // self-describing: how this was hashed/signed
  "canon": "RFC8785",
  "hash_alg": "SHA-256",
  "sig_alg": "ES256"
}

CONTENT mode (opaque bytes notarized without storing them) adds:

{
  "content_hash": "<plain hex>",   // SHA-256 of the raw bytes — UNPREFIXED hex
  "content_type": "application/pdf",
  "content_location": "s3://..."   // optional pointer; NOT fetched at verify time
}

Honesty notes (divergences a verifier must know):

  • A CanonicalReceiptEnvelope TypeScript interface exists in the codebase with top-level kid/alg/signatures[]/status fields. It is type-only and never constructed — real receipts do NOT carry those fields. The kid and algorithm live in the JWS protected header; the signature lives in receipts.signature.
  • receipts.metadata may duplicate content_type/content_location. That column is not covered by the hash — a verifier MUST trust only the copies inside data.
  • schema_version, canon, hash_alg, sig_alg are inside the signed payload, so a receipt is self-describing on all three crypto axes and the stamps cannot be stripped without breaking the hash and the signature.

2. Canonicalization — RFC 8785 (JCS)

receipt.hash = lowercase hex SHA-256 of the UTF-8 bytes of the RFC 8785 canonical JSON of receipt.data:

  • Object keys sorted by UTF-16 code unit (not locale, not byte order).
  • Array order preserved. No insignificant whitespace.
  • Numbers serialized by the ES6 Number→String algorithm (-0"0", 1e21"1e+21").
  • Non-finite numbers and BigInt are rejected (errors, never coerced).
  • undefined object members dropped; undefined array elements become null.
  • Strings escaped per RFC 8259 minimal escaping.

Conformance group A (jcs.json) is normative — a canonicalizer that passes those vectors byte-for-byte is conformant. Reference implementations: lib/crypto/jcs.ts (server) and @certnode/verify canonicalize (byte-for-byte mirror).

3. Signature — compact ES256 JWS

  • receipts.signature is a compact JWS: protected header { "alg": "ES256", "kid": "<kid>" }, claims iss (accepted issuers: certnode and the site URL — two first-party signers exist), sub (the receipt id — the anti-swap bind), iat, plus the payload claims.
  • Keys are published at /.well-known/jwks.json with per-key status and a top-level revoked[] list.
  • Revocation is fail-closed: a kid marked revoked (either mechanism) MUST NOT verify, even though its key material stays published for transparency. There is no time-gated acceptance on the signature layer — a receipt's pre-compromise existence is assessable on the independent timestamp layers (§4 steps 5–6), not on a revoked signature.
  • Algorithm confusion MUST be rejected up front: algES256 (including none, HS256, or missing) fails before any key lookup. An unknown kid fails — a verifier MUST NOT fall back to an arbitrary key.
  • The legacy subject sentinel: receipts signed before 2026-06-11 by the provenance signer carry sub: "unknown". A verifier asked to check a subject against such a receipt MUST report unbound (subject_unbound), not a mismatch — and MUST NOT treat the bind as proven.

Conformance groups B (signature, revocation, confusion) and C (anti-swap subject) are normative.

4. Verification algorithm (per-layer honest verdict)

A conforming verifier produces a per-layer verdict — never a single green checkmark. Inputs: the receipt, optionally the original content (or its hash), a JWKS (supplied for full offline operation, or fetched once), the expected receipt id.

Step 1 — SIGNATURE (authenticity)
  1a. Decode the compact JWS. Reject unless protected alg === 'ES256'
      (reject 'none'/HS256/missing up front) → unexpected_alg:<x>
  1b. Resolve the key: exact kid match, or sole-key fallback when the JWS
      carries no kid. Unknown kid → unknown_kid (NEVER an arbitrary key).
  1c. REVOCATION (fail-closed): per-key status === 'revoked' OR kid in the
      top-level revoked[] → revoked_kid. No exceptions on this layer.
  1d. Verify with ES256 against the resolved JWK; enforce issuer when known.

Step 2 — ANTI-SWAP SUBJECT BINDING
  2a. If the expected receipt id is supplied: payload.sub must equal it.
      Mismatch → subject_mismatch. sub absent or the literal 'unknown'
      (legacy sentinel) → subject_unbound.
  2b. If not supplied and the payload carries a sub: surface the warning
      'subject_present_but_unchecked' — a bare signature check cannot
      detect a receipt-id swap.

Step 3 — CONTENT HASH (integrity), dispatch on data.schema_version
  'cn.receipt.v1' → JCS-SHA-256(data) MUST equal receipt.hash (strict).
  absent (legacy) → tolerant: JSON.stringify hash OR JCS hash.
  unknown/future → false — an honest "cannot reproduce this version".
  If original content supplied: SHA-256(bytes) must equal the signed
  contentHash claim (plain hex, unprefixed), compared timing-safely.

Step 4 — VERDICT TIER (named, no silent downgrades)
  canonical_content_bound        v1 receipt: JWS subject-bound AND the
                                 signed payload ≡ data AND the hash recomputes.
  signature_bound                JWS verifies + subject-bound, pre-v1 envelope.
  legacy_content_integrity_only  no JWS; hash recomputes from data.
                                 CONTENT INTEGRITY ONLY — NOT authenticity.
  unverified                     signature/hash failed, revoked kid, or
                                 nothing to verify.

Step 5 — RFC 3161 TIMESTAMP (independent time)
  5a. The token's TSA signature must verify; the signer cert must carry the
      id-kp-timeStamping EKU; the signer must chain to a PINNED root
      (FreeTSA / DigiCert Trusted Root G4 / DigiCert Assured ID /
      USERTrust RSA), validated AS OF the token's genTime. Token-embedded
      certificates are usable as intermediate path material, but the chain
      leaf is ALWAYS the cert that actually signed (decoy defense).
  5b. Content binding: for regular receipts the imprint is SHA-256 over the
      UTF-8 bytes of canonicalize(certnode_timestamp) — the exact stamped
      preimage; for vault seal receipts, over the UTF-8 bytes of the
      evidence_hash hex string. Without the stored preimage the layer
      reports genTime only and content binding 'unverifiable' — the honest
      ceiling, never an assumed pass.
  5c. A token from an unpinned TSA reports chain-untrusted = the layer is
      "unconfirmed", NOT a hard receipt failure.
  5d. DUAL-STAMP (optional): when rfc3161_timestamp_secondary is present,
      the layer is valid when ≥1 pinned independent TSA validates;
      multi_tsa_corroborated is true only when BOTH validate over the same
      bytes with genTimes within 15 minutes.

Step 6 — BITCOIN ANCHOR (header-verified, lite-client)
  6a. Parse the OTS proof (bitcoin_anchor.ots_proof). The committed file
      hash must equal receipt.hash FIRST — a wrong-data proof is INVALID
      (hash_mismatch), not merely unattested.
  6b. Walk the proof's operations to the BitcoinBlockHeaderAttestation; the
      committed message there, byte-reversed, is the block's merkle root in
      display (big-endian) hex.
  6c. Fetch the REAL header at the attested height — by default from TWO
      independent explorers that must agree on root AND time, else the
      check is UNDETERMINED (header_check_unavailable = UNKNOWN, never
      rendered as failure). A verifier MAY supply its own node's headers —
      that path is trustless; the default is "explorer-backed".
  6d. Surface block height and the real Bitcoin block time. NEVER a txid —
      the attestation does not contain one.

Step 7 — LAYER 1 INTERNAL SEAL (demoted)
  The certnode_timestamp HMAC is secret-keyed: a third party cannot
  reproduce it. It NEVER contributes to any trust verdict. Surface
  presence only, labeled "internal seal (issuer-attested, not independent)".

Conformance groups D (content-hash dispatch), E (RFC 3161 incl. the decoy forgery), and F (Bitcoin/OTS) are normative.

5. Transparency log formats — RESERVED for cn.receipt.v2

These formats are documented for forward-compatibility and not yet emitted publicly (the log is built dark behind a flag). When live, a receipt's inclusion becomes checkable:

  • Leaf hash (RFC 6962): SHA-256(0x00 || receipt_hash_bytes). Interior nodes: SHA-256(0x01 || left || right). leaf_index is monotonic but not gap-free.
  • Signed Tree Head: { tree_size, root_hash, timestamp, sth_signature } where the STH is itself a cn.receipt.v1 receipt signed by the SAME published ES256 key (no new key authority).
  • Inclusion proof: { leaf_index, tree_size, audit_path: hex[] }. Consistency proof: { first_size, second_size, proof: hex[] } — RFC 6962 semantics.
  • The log's property is "append-only with non-equivocation" — never "immutable". Absence of a leaf proves "not recorded as of T", never "never created".

6. Versioning & the anchored-history rule

  • Format changes are additive and become cn.receipt.v2. A v2 verifier MUST still verify v1 — anchored history cannot be re-anchored, so v1 receipts stay verifiable forever.
  • Every receipt self-describes its crypto (canon, hash_alg, sig_alg), so a future verifier dispatches — it never guesses.
  • v2 crypto-agility (EdDSA signatures, BLAKE3-256/SHA3-256 content hashes, hybrid post-quantum ML-DSA-65 co-signatures, selective disclosure, multi-party co-signing) is implemented opt-in in the reference verifier but not active in production: v1/ES256 is the production format until a v2 envelope is published here.

7. Threat model — what each layer proves / does NOT prove

LayerPROVESDoes NOT prove
ES256 JWS signatureThe payload was signed by the private key matching the published kid, and (with subject bind) is THIS receipt, not lifted from another.That the signer is honest about the *facts* in the payload; that the key wasn't compromised (see revocation); anything about *time*.
Content hash (JCS)receipt.data reproduces receipt.hash byte-for-byte — the recorded content wasn't altered after signing.That the content was *true* — a forged payload paired with its own hash passes content-integrity alone. Authenticity needs the signature too.
RFC 3161 timestampAn independent TSA (chaining to a pinned root) attested the content existed at genTime; for seal/preimage receipts, bound to THIS content.That CertNode is honest about time — that's the *point* (it's independent). Without the preimage: only genTime, not content-binding. A single TSA is a trust dependency unless dual-stamped (§4 step 5d).
Bitcoin / OTS anchorThe committed hash is in a real Bitcoin block's Merkle tree at the attested height (explorer-backed, or trustless with your own node)."Trustless" on the default path (lite-client trusts the explorer); a txid (none exists — height/time only); anything until the OTS proof upgrades to a block attestation.
Layer 1 internal seal (HMAC)Nothing third-party-verifiable. Issuer-attested only — demoted out of every verdict.Anything to an outsider — it's secret-keyed; a relying party cannot reproduce it.
Transparency log (when live)The receipt was recorded in an append-only, non-equivocating log as of T (inclusion proof); the log didn't fork (consistency proof)."Immutable"; proof-of-absence / completeness (absence ≠ "never created," only "not recorded as of T").

The developer-facing trust score returned by some endpoints is a convenience number, explicitly not a legal or regulatory metric.

8. Verify it yourself

  • npm: npm i @certnode/verifyverifyOffline, verifyReceiptContentHash, verifyRfc3161Token, verifyBitcoinAnchor, canonicalize — runs with CertNode's servers switched off (supply the JWKS).
  • No install: /verify-offline.html — a single static file; works from file://.
  • Conformance: download /.well-known/conformance/v1/MANIFEST.json, check each file's SHA-256 against it, and run the vectors against your own implementation. An implementation that passes all groups byte-for-byte is conformant.
  • Raw proof bytes: GET /api/timestamps/verify-full?receipt_id=<id> returns the exact ots_proof + certnode_timestamp preimage so nothing in the verdict depends on this server.

*This document describes implemented behavior, verbatim. If the code and this spec disagree, that is a bug — report it. Changes land as spec-version bumps in the same commit as the behavior they document.*