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:
| Field | Type | Meaning |
|---|---|---|
id | text | Receipt identifier. Bound into the signature as the JWS sub (anti-swap). |
data | JSON object | The signed payload — the ONLY hash-covered content. Shape in §1.1. |
hash | hex string | SHA-256 of the RFC 8785 canonicalization of data (§2). Lowercase, unprefixed. |
signature | string | Compact ES256 JWS over the payload (§3). |
certnode_timestamp | JSON object | Layer 1 internal seal (issuer-attested HMAC — informational only, §4 step 7). |
rfc3161_timestamp | base64 | RFC 3161 TimeStampResp from an independent TSA (§4 step 5). |
rfc3161_timestamp_secondary | base64? | Optional second token from an independent TSA over the same bytes (dual-stamp; §4 step 5d). |
bitcoin_anchor | JSON/text | OpenTimestamps proof + status (§4 step 6). Carries ots_proof (base64), bitcoin_block_height, bitcoin_block_time. Never a txid — OTS attests a block height. |
parent_ids | text[] | 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
CanonicalReceiptEnvelopeTypeScript interface exists in the codebase with top-levelkid/alg/signatures[]/statusfields. 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 inreceipts.signature. receipts.metadatamay duplicatecontent_type/content_location. That column is not covered by the hash — a verifier MUST trust only the copies insidedata.schema_version,canon,hash_alg,sig_algare 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).
undefinedobject members dropped;undefinedarray elements becomenull.- 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.signatureis a compact JWS: protected header{ "alg": "ES256", "kid": "<kid>" }, claimsiss(accepted issuers:certnodeand 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.jsonwith per-keystatusand a top-levelrevoked[]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:
alg≠ES256(includingnone,HS256, or missing) fails before any key lookup. An unknownkidfails — 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_indexis monotonic but not gap-free. - Signed Tree Head:
{ tree_size, root_hash, timestamp, sth_signature }where the STH is itself acn.receipt.v1receipt 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
| Layer | PROVES | Does NOT prove |
|---|---|---|
| ES256 JWS signature | The 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 timestamp | An 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 anchor | The 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/verify→verifyOffline,verifyReceiptContentHash,verifyRfc3161Token,verifyBitcoinAnchor,canonicalize— runs with CertNode's servers switched off (supply the JWKS). - No install:
/verify-offline.html— a single static file; works fromfile://. - 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 exactots_proof+certnode_timestamppreimage 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.*