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

467 lines
20 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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):
```json
{
"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:
```json
{
"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](confidentiality.md) 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.