open-standards/wai/extensions/jwp-receipts.md
Transaction Science 3453a6601d wai/jwp-receipts: document opt-in wall-clock freshness on the live edge
Section 6.1 already prescribed the monotonic group_id anti-replay guard;
add the companion optional freshness check — a verifier MAY bound the
signed group_end against its own clock for the group it treats as the
current tip, never for historical resync (whose timestamps are legitimately
old). Matches the reference verifier's opt-in checks.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-04 20:15:43 -04:00

20 KiB
Raw Permalink Blame History

WAI Extension: JWP Receipt Chain

Status: Draft. Sub-spec of WAI v1.0. Mirrors the Rust reference impl at https://github.com/Transaction-Science/open-standards/tree/main/wai/extensions/jwp-receipts.

Keywords MUST, MUST NOT, SHOULD, MAY, REQUIRED, OPTIONAL are RFC 2119 / RFC 8174.


1. Scope and model

This extension defines a JWP-shaped receipt chain that attests per-object delivery of WAI envelope objects through a chain of relays. A receipt names the bytes a sink received, the publisher that produced them, the energy spent producing them, and the chain of relays the bytes traversed — all under a single Ed25519 signature per group.

In scope:

  • A per-object content commitment (BLAKE3-256) over WAI envelope bytes.
  • A per-group Merkle root binding every object in a group under one signature.
  • A per-group Ed25519 signature over canonical group metadata.
  • A typed receipt envelope (JSON or equivalent CBOR) with explicit parent-receipt linkage for cross-hop preservation.
  • Verifier behaviour, relay preservation requirements, and conformance test vectors.

Out of scope:

  • A transport. This extension does not define how receipts move on the wire; receipts ride alongside WAI envelopes in the carrier protocol (e.g. a MoQT object header field, an HTTP trailer, a sidecar file).
  • A codec. The receipt chain is opaque to the WAI capability dispatch in WAI SPEC.md §4. A receipt attests bytes; it does not interpret them.
  • An envelope. WAI SPEC.md §2 defines the envelope; this extension sits above it. A receipt's content hash is computed over the full WAI envelope (WAI1 magic + manifest + payload), not over the payload alone.

Relationship to WAI SPEC.md:

  • WAI SPEC.md §2 (container) — receipts hash the container bytes end-to-end; a sink that mutates the envelope (re-orders manifest keys, re-frames the payload) breaks the content hash and MUST be rejected by §3 step 4 below.
  • WAI SPEC.md §4 (capability dispatch) — receipts attest what was delivered to the dispatcher, not what the dispatcher produced. A sink that successfully verifies a receipt then dispatches the envelope per §4 unchanged.

2. Wire format

2.1. Per-object content hash

For each WAI envelope object the publisher emits, the publisher MUST compute a 32-byte BLAKE3-256 content hash:

content_hash = BLAKE3(domain || envelope_bytes)
domain       = "moq-jwp:object\x01"

envelope_bytes is the full WAI envelope per WAI SPEC.md §2 (the WAI1 magic, the manifest-length prefix, the manifest, the payload- length prefix, the payload — concatenated, with no insertion or reframing). The domain string is appended as a domain separator to prevent cross-protocol hash collisions.

2.2. Per-group Merkle root

A group is an ordered list of one or more objects under a single publisher key. The publisher MUST build a BLAKE3 binary Merkle tree over the object content hashes in their group order. Leaves and internal nodes use distinct domain separators:

leaf(content_hash)        = BLAKE3("moq-jwp:merkle-leaf\x01" || content_hash)
node(left, right)         = BLAKE3("moq-jwp:merkle-node\x01" || left || right)

When the leaf count at any level is odd, the publisher MUST duplicate the final leaf to obtain an even pair. The root is the single hash at the top of the tree. The empty-group root is the 32-byte all-zero value 0x00..00.

2.3. Per-group Ed25519 signature

The publisher MUST sign the canonical signing payload with an Ed25519 key per RFC 8032 (Pure Ed25519, no prehash). The signing payload is the big-endian concatenation:

signing_payload =
    "moq-jwp:group-sign\x01"          // domain
 || u64_be(track_alias)                // optional carrier-scoped id (0 if unused)
 || u64_be(group_id)                   // monotonic per-track group counter
 || merkle_root[32]                    // §2.2
 || u64_be(leaf_count)                 // number of objects in the group
 || u64_be(total_joules_micro)         // sum of microjoules across the group
 || u32_be(len(group_start)) || group_start_utf8   // RFC 3339 start timestamp
 || u32_be(len(group_end))   || group_end_utf8     // RFC 3339 end timestamp
 || publisher_key[32]                  // Ed25519 verifying key (RFC 8032)
 || u32_be(len(publisher_id)) || publisher_id_utf8 // bound identity string

The signature is exactly 64 bytes. Implementations MUST reject any group receipt whose signature length is not 64.

2.4. Receipt envelope (typed)

Two receipt kinds are defined: group (one per group) and object (one per object). Both are typed JSON; equivalent CBOR is permitted when the carrier mandates it, provided the field set and semantics are identical.

2.4.1. kind: "group"

Required fields:

field type meaning
kind string MUST equal "group".
track_alias u64 Carrier-scoped track identifier; 0 if unused.
group_id u64 Monotonic group counter under publisher_key.
root_hash 32 B hex §2.2 Merkle root over object content hashes.
leaf_count u64 Number of objects covered by root_hash.
joules_total u64 Sum of microjoules across the group's objects.
group_start string RFC 3339 timestamp of the first object.
group_end string RFC 3339 timestamp of the last object.
signer_pubkey 32 B hex Ed25519 verifying key per RFC 8032.
signer_id string Bound identity string (DID, URN, or display).
sig 64 B hex Ed25519 signature over §2.3 payload.
parent_receipt_hash 32 B hex or null BLAKE3 of the previous group receipt's canonical bytes under this publisher key; null for the first group.

2.4.2. kind: "object"

Required fields:

field type meaning
kind string MUST equal "object".
track_alias u64 Mirrors the parent group receipt.
group_id u64 Mirrors the parent group receipt.
object_id u64 Monotonic from 0 within the group.
content_hash 32 B hex §2.1 BLAKE3 over the WAI envelope bytes.
joules_micro u64 Microjoules consumed producing this object.
origin string Datacenter / origin label.
merkle_proof object Inclusion proof: { "siblings": [32 B hex, …], "direction_bits": u64, "leaf_index": u64 }. direction_bits bit i is 1 iff the current node at level i is the right child.

2.5. Worked example

Manifest JSON (group receipt, formatted for readability — wire form serializes with no insignificant whitespace and preserves key insertion order):

{
  "kind": "group",
  "track_alias": 7,
  "group_id": 3,
  "root_hash": "5d2c7b1f9a04e2a16c83b3d774a09f12c44b65a3e8d11f88c2901a47b50d2c61",
  "leaf_count": 2,
  "joules_total": 250,
  "group_start": "2026-04-25T12:00:00Z",
  "group_end":   "2026-04-25T12:00:01Z",
  "signer_pubkey": "d75a980182b10ab7d54bfed3c964073a0ee172f3daa62325af021a68f707511a",
  "signer_id": "did:joule:publisher-1",
  "sig": "9f3a4d6b1c0e8742f1b6b94e7d1c2a85f5103e4c8b2d61a93f0e4b8f2c70a1d61f6a2b71d4eecbb0d7a2e90c4b1bf2c8a0e4d63b9101fa0e2c4d83b1f70e9a103",
  "parent_receipt_hash": null
}

Object receipt for the second object in that group:

{
  "kind": "object",
  "track_alias": 7,
  "group_id": 3,
  "object_id": 1,
  "content_hash": "1c3f4a2e88b0d77192aabd0c66f12345e89c7b41ab2cd35f7e80b912ad6c9012",
  "joules_micro": 150,
  "origin": "joule-cloud-us-west-2",
  "merkle_proof": {
    "siblings": [
      "a07b4d2c91f5e3110687f4b210ff8e1133acdb0741e3920c19acf8b3110d57e2"
    ],
    "direction_bits": 1,
    "leaf_index": 1
  }
}

The signature hex shown is illustrative; conformance is checked against the test vectors in §5.


3. Verifier algorithm

A conforming verifier MUST execute the following six steps in order for every group receipt and every object receipt it accepts. Steps match the algorithm style of WAI SPEC.md §4 (numbered, terminating).

  1. Parse and shape-check. Read the group receipt. If kind != "group", reject. If sig is not 64 bytes, reject. If signer_pubkey is not a valid 32-byte Ed25519 verifying key (RFC 8032), reject. If any required field from §2.4.1 is absent, reject.

  2. Verify the Ed25519 group signature. Construct the canonical signing payload per §2.3 from the receipt's fields. Verify sig against signer_pubkey per RFC 8032 Pure Ed25519. If verification fails, reject. After this step, the verifier holds a verified root: the receipt's root_hash field, now bound to signer_pubkey under signature.

  3. Check parent linkage. If the verifier maintains receipt-chain state for signer_pubkey, compute BLAKE3("moq-jwp:receipt-link\x01" || canonical_bytes(previous_group_receipt)) and compare to parent_receipt_hash. If they differ, reject (chain break). If no previous receipt is known and parent_receipt_hash is null, accept as the chain head. A non-null parent_receipt_hash without a corresponding previously-verified receipt is a recoverable condition: the verifier MAY request the missing receipt before accepting; it MUST NOT silently accept.

  4. Verify the object's content hash. For each accompanying kind: "object" receipt: recompute content_hash per §2.1 over the received WAI envelope bytes and compare to the receipt's content_hash field. If they differ, reject the object (the envelope was mutated in transit).

  5. Verify the Merkle inclusion proof. Compute the leaf as BLAKE3("moq-jwp:merkle-leaf\x01" || content_hash). Walk the merkle_proof.siblings list from level 0 upward; at each level i, combine the current value with the sibling using the direction_bits bit i (1 = current is right child, 0 = current is left child) and the moq-jwp:merkle-node\x01 domain. After walking all siblings, compare the resulting hash to the verified root from step 2. If they differ, reject.

  6. Accept. The object's bytes are bound to signer_pubkey at (track_alias, group_id, object_id), with the publisher's declared joules_micro and origin. The verifier MAY now persist the group receipt as the new chain head for signer_pubkey and dispatch the WAI envelope per WAI SPEC.md §4.

A verifier MUST treat any rejection in steps 1–5 as a hard failure for the object in question. A verifier MUST NOT accept a partial verification (e.g. valid Merkle proof against an unverified root).


4. Relay preservation requirements

A conforming relay forwards WAI envelopes from one or more publishers to one or more subscribers. To preserve the receipt chain across hops, a conforming relay:

4.1. MUST

  • Forward every group receipt and every object receipt unaltered to every subscriber that requested receipts for the corresponding track. Byte-for-byte equality of receipt bytes between ingress and egress is REQUIRED.
  • Verify the group receipt's Ed25519 signature per §3 step 2 before the first object of the group is fanned out. A relay that cannot verify the signature MUST drop the group and emit a structured error to upstream operators.
  • Forward the parent_receipt_hash field as received. A relay that has previously forwarded the chain MAY additionally check the link per §3 step 3 and refuse to fan out a broken chain.
  • Preserve the byte-exact WAI envelope (WAI1 magic, manifest bytes, payload bytes) end-to-end. Any rewriting at the envelope layer invalidates §2.1 content hashes.

4.2. MAY

  • Batch multiple kind: "object" receipts into a carrier-level frame to amortize per-receipt overhead, provided every receipt's bytes are preserved (no field elision, no canonicalization rewrite).
  • Emit relay-scoped telemetry alongside the forwarded receipts: cumulative joules_total across observed groups, per-publisher delivery counts, fan-out fan-in ratios. Such telemetry is local to the relay and MUST NOT be conflated with publisher-issued receipts.
  • Reorder objects within a group on egress when the carrier protocol permits — Merkle inclusion proofs are position-bound by leaf_index, so reordering does not invalidate verification at the subscriber. The relay MUST NOT renumber object_id.

4.3. MUST NOT

  • Modify the WAI envelope payload bytes in any way (no transcoding, no manifest rewrite, no key reordering, no whitespace insertion). Envelope mutation is detected at §3 step 4.
  • Re-issue the Merkle root or re-compute a new tree over a subset of objects. The publisher is the sole authority for the group root.
  • Re-sign the group receipt under a relay-owned key. There is one signer per group, and it is the publisher named in signer_pubkey. Relay attestation, when desired, MUST be carried as a separate, parallel receipt and MUST NOT replace the publisher signature.
  • Strip the parent_receipt_hash field, even when its value is null. The field is required by §2.4.1 and its absence is a shape-check failure at §3 step 1.
  • Truncate the receipt to fewer fields than §2.4 requires, even when the carrier framing has size pressure.

5. Test vectors

Conforming implementations MUST verify against the canonical test vector set generated by joulesperbit/crates/joule-moq-publisher/examples/jwp_receipts_vectors.rs. The generator emits five vectors covering the cases below. Each vector consists of: the WAI envelope bytes for every object in the group, the canonical group-receipt JSON, the canonical object-receipt JSON for every object, and a result.json declaring the expected verifier outcome (accept or one of the named rejection reasons).

5.1. Single-object group (vector v1_single_object)

One WAI envelope (wai.text.zstd, payload "hello" zstd-encoded). Group receipt with leaf_count = 1, parent_receipt_hash = null. Object receipt with object_id = 0, merkle_proof.siblings = [], direction_bits = 0, leaf_index = 0. Expected outcome: accept.

5.2. Multi-object group with proof verification (vector v2_multi_object)

Five WAI envelopes (wai.audio.opus, 20 ms frames). Group receipt with leaf_count = 5 (the publisher duplicates the trailing leaf internally when building the tree). Five object receipts, each with a non-empty merkle_proof. Expected outcome: accept for each object receipt.

5.3. Cross-hop preservation through 2 relays (vector v3_cross_hop)

Three publisher-issued group receipts in chain order G0, G1, G2 with parent_receipt_hash chaining each to its predecessor. Two relay hops are simulated: ingress bytes → relay A egress bytes → relay B egress bytes are emitted as three byte-equal copies. The verifier walks the chain in order; expected outcome: accept at every hop, and the chain head after G2 equals the BLAKE3 of G2's canonical bytes per §3 step 3.

5.4. Invalid signature rejection (vector v4_bad_sig)

A well-formed group receipt with a single bit flipped in the sig field. Expected outcome: reject at §3 step 2 with reason signature_verify_failed.

5.5. Missing parent_receipt_hash rejection (vector v5_missing_parent)

Two group receipts G0, G1. G1 omits the parent_receipt_hash field entirely (rather than setting it to null or a hash). Expected outcome: reject at §3 step 1 with reason missing_required_field.

The generator writes vectors to joulesperbit/crates/joule-moq-publisher/examples/jwp_receipts_vectors/ as one directory per case. A conforming verifier MUST accept the accept cases bit-exactly and MUST reject the named cases at the declared step with the declared reason. The Rust reference verifier in inv-moq::receipt::ReceiptVerifier exercises all five vectors under cargo test --lib -p inv-moq.


6. Security considerations

6.1. Replay

A receipt without sequencing information is replayable: an attacker who captures group receipt G can re-present it to a downstream verifier and bind unrelated bytes to the publisher's identity at a later time. This extension mitigates replay with two mechanisms:

  • parent_receipt_hash (§2.4.1) links each group receipt to its predecessor under the same signer_pubkey, so a chain replay requires forging every intermediate hash.
  • group_id is monotonic per publisher key. Verifiers SHOULD track the highest group_id seen per signer_pubkey and reject any receipt with a non-increasing group_id. This binds the chain to forward progress.
  • group_end (§2.3) carries a signed RFC 3339 timestamp. A verifier MAY additionally bound it against its own clock for the group it treats as the current tip — rejecting a receipt whose group_end is older than a deployment freshness window (a relay holding content back) or implausibly far in the future (skew). This wall-clock check is OPTIONAL and MUST NOT be applied to historical receipts replayed for late-joiner resync, whose timestamps are legitimately old — apply it only to the live edge. The reference verifier exposes both the monotonic group_id guard (per track) and this freshness bound as opt-in checks, disabled by default so durable re-verification of an old receipt stays a feature.

6.2. Forgery

The signing payload (§2.3) is bound under Ed25519 (RFC 8032). At classical security levels, Ed25519 provides ~128-bit unforgeability under chosen-message attack (EUF-CMA). Forging a group receipt is equivalent to recovering the publisher's private key or breaking Ed25519. Implementations MUST source signing keys from a CSPRNG and MUST NOT reuse keys across distinct publisher identities.

6.3. Cross-group confusion

The signing payload binds track_alias, group_id, and signer_pubkey under the same signature. An attacker who attempts to re-present a group receipt under a different (track_alias, group_id) tuple cannot do so without re-signing — the receipt's fields are signature-covered, and any field mutation breaks verification at §3 step 2. The Merkle proof is similarly bound: leaf_index is verifier-checked during the proof walk, so an object's proof cannot be transplanted to a different position within the same group.

6.4. Envelope mutation in transit

Any mutation of the WAI envelope bytes (manifest rewrite, payload re-frame, whitespace insertion) changes the §2.1 content hash and is detected at §3 step 4. Relays that re-canonicalize the manifest SHOULD be treated as broken: they break content-hash equality even when the semantic meaning is unchanged. WAI SPEC.md §2 already requires that the manifest be serialized with no insignificant whitespace and that encoders preserve key insertion order; receipt verification depends on relays honouring that requirement.

6.5. Quorum and multi-signer receipts

v1.0 of this extension is single-signer: every group receipt is signed by exactly one Ed25519 key. Multi-signer / quorum receipts (threshold Ed25519, BLS aggregate signatures) are explicitly out of scope for v1.0. A future minor revision MAY extend §2.3 with a multi-signer container; the v1.0 wire format remains a strict subset.

6.6. Out of scope

  • Post-quantum group-signing ciphersuites. Migration to hash-based signatures (SLH-DSA), lattice-based signatures (ML-DSA), or hybrid Ed25519 + post-quantum receipts is deferred to a future major revision of this extension. Implementers building for long-lived archives SHOULD plan key-rotation policies that allow ciphersuite migration when the upgrade lands.
  • Confidentiality of receipts. Receipts carry identity, joules, and origin labels in cleartext. Carriers that require receipt confidentiality (e.g. trade-secret origin labels) MUST layer their own encryption above this extension; v1.0 receipts are integrity-protected, not confidentiality-protected. Note this is distinct from payload confidentiality: the Confidentiality extension seals the object bytes a receipt attests, and this chain deliberately commits over that ciphertext so a relay signs delivery + energy without ever reading the content.
  • Pricing semantics for joules. joules_micro and joules_total are unit-of-energy fields, not unit-of-account fields. Conversion to currency, carbon, or grid-mix CO₂ is performed by downstream aggregators and is not normative here.