# CertNode Receipt Format — cn.receipt.v1

**Spec version:** 1.0.0 · **Status:** Published · **Date:** 2026-06-11
**Conformance vectors (normative):** [`/.well-known/conformance/v1/`](/.well-known/conformance/v1/MANIFEST.json)
**Reference verifier:** [`@certnode/verify`](https://www.npmjs.com/package/@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):

```jsonc
{
  // ...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:

```jsonc
{
  "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`](/.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: `alg` ≠ `ES256` (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.

```text
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

| 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`](/verify-offline.html) — a single static file; works from `file://`.
- **Conformance:** download [`/.well-known/conformance/v1/MANIFEST.json`](/.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.*
