wai: wai.capture.take — a device's master recording bound to a live room (L4) #289

Merged
dcharlot merged 21 commits from wai/capture-take into main 2026-10-17 09:22:42 -04:00
37 changed files with 24256 additions and 10 deletions

View file

@ -66,6 +66,15 @@ on:
- 'wai/extensions/jwp-receipts.md'
# The quantum referee requires this extension's Appendix B to state its phase tables.
- 'wai/extensions/quantum-sim.md'
# tests/live_events.rs requires this extension's §9 to list the codes the reference
# reports, and the registry's wire identifiers test reads its sections.
- 'wai/extensions/live-events.md'
# tests/live_room.rs requires this extension's §11 to list the codes the reference
# reports, and the registry's wire identifiers test reads its sections.
- 'wai/extensions/live-rooms.md'
# tests/capture_take.rs requires this extension's §12 to list the codes the reference
# reports, and the registry's wire identifiers test reads its sections.
- 'wai/extensions/capture-binding.md'
- 'wai/wai-web/**'
# The audio provenance check below reads the corpora above with this tool.
- 'wai/tools/wai_audio_provenance_check.py'
@ -103,6 +112,15 @@ on:
- 'wai/extensions/jwp-receipts.md'
# The quantum referee requires this extension's Appendix B to state its phase tables.
- 'wai/extensions/quantum-sim.md'
# tests/live_events.rs requires this extension's §9 to list the codes the reference
# reports, and the registry's wire identifiers test reads its sections.
- 'wai/extensions/live-events.md'
# tests/live_room.rs requires this extension's §11 to list the codes the reference
# reports, and the registry's wire identifiers test reads its sections.
- 'wai/extensions/live-rooms.md'
# tests/capture_take.rs requires this extension's §12 to list the codes the reference
# reports, and the registry's wire identifiers test reads its sections.
- 'wai/extensions/capture-binding.md'
- 'wai/wai-web/**'
# The audio provenance check below reads the corpora above with this tool.
- 'wai/tools/wai_audio_provenance_check.py'
@ -753,7 +771,9 @@ jobs:
# verdicts required to equal wai_signer_vectors' line for line), and
# int_payload_verify.py (the integer payloads and their refusal fixtures), and
# staged_verify.py (the staged-delivery corpus), and http_verify.py (the HTTP delivery
# corpus), and quantum-conformance/tools/referee.py (both quantum-sim capabilities).
# corpus), and quantum-conformance/tools/referee.py (both quantum-sim capabilities), and
# live_events_verify.py (the live-events corpus), and live_rooms_verify.py (the
# live-rooms corpus), and capture_take_verify.py (the capture-binding corpus).
# Each exits non-zero on any failure or any difference, and the step fails with it.
# One platform: what they check is the reading of the text, not the architecture.
independent-verifiers:
@ -874,6 +894,56 @@ jobs:
for seed in 1 2; do
python3 staged-measure-conformance/sim_diff.py wai-rs/target/debug/wai_stage_measure "$seed" 1500
done
# wai.live.events (extensions/live-events.md): the reference module's unit tests and
# tests/live_events.rs (the committed corpus regenerates byte for byte from its fixed
# seeds, wai-rs gives every vector, policy case and binding case its stated outcome,
# the corpus covers every kind and reason, and §9 lists exactly the reference's
# codes); then the corpus's second implementation, live_events_verify.py, written
# from the extension's text and sharing no code with wai-rs, which verifies the same
# cases with WAI's strict Ed25519 (claim-conformance/_ed25519.py) and BLAKE3
# (claim-conformance/_blake3.py), standard library only, run isolated (-I).
- name: live events · wai-rs and live_events_verify.py
working-directory: wai
shell: bash
run: |
set -euo pipefail
(cd wai-rs && cargo test --no-default-features --features live_events --lib live_events::)
(cd wai-rs && cargo test --no-default-features --features live_events --test live_events)
python3 -I live-conformance/live_events_verify.py live-conformance/events
# wai.live.room (extensions/live-rooms.md): the reference module's unit tests and
# tests/live_room.rs (the committed corpus regenerates byte for byte from its fixed
# seeds, wai-rs gives every vector its stated outcome, every reason of §11 refuses a
# vector, and §11 lists exactly the reference's codes); then the corpus's second
# implementation, live_rooms_verify.py, written from the extension's text and sharing
# no code with wai-rs, which verifies every event with live_events_verify.py and every
# signature with WAI's strict Ed25519 (claim-conformance/_ed25519.py), standard
# library only, run isolated (-I).
- name: live rooms · wai-rs and live_rooms_verify.py
working-directory: wai
shell: bash
run: |
set -euo pipefail
(cd wai-rs && cargo test --no-default-features --features live_room --lib live_room::)
(cd wai-rs && cargo test --no-default-features --features live_room --test live_room)
python3 -I live-conformance/live_rooms_verify.py live-conformance/rooms
# wai.capture.take (extensions/capture-binding.md): the reference module's unit tests,
# the output tree's (whose RFC 6962 hash and RFC 9162 consistency proofs the take tree
# reuses), and tests/capture_take.rs (the committed corpus regenerates byte for byte
# from its fixed seeds, wai-rs gives every vector its stated outcome, every reason of
# §12 appears in it, and §12 lists exactly the reference's codes); then the corpus's
# second implementation, capture_take_verify.py, written from the extension's text and
# sharing no code with wai-rs, which verifies each room with live_rooms_verify.py and
# every signature with WAI's strict Ed25519 (claim-conformance/_ed25519.py), and
# requires every mutated copy of the corpus to fail; standard library only, run
# isolated (-I).
- name: capture take · wai-rs and capture_take_verify.py
working-directory: wai
shell: bash
run: |
set -euo pipefail
(cd wai-rs && cargo test --no-default-features --features capture_take --lib -- capture_take:: output_tree::)
(cd wai-rs && cargo test --no-default-features --features capture_take --test capture_take)
python3 -I live-conformance/capture_take_verify.py live-conformance/takes
# The quantum corpus's second implementation (quantum-sim extension), integers only:
# it derives the circuit/1 phase table by its recurrence and the circuit2 table by an
# interval-certified procedure, and requires both literal tables and their digests,

View file

@ -6,6 +6,112 @@ wire format may change and such changes are recorded here.
## v0.4.0 — current
### `wai.capture.take`: a device's master recording bound to a live room
- **The extension** ([capture-binding](extensions/capture-binding.md)) registers the
record `wai.capture.take`, for the case of a lesson or an audition where the live call is
low quality and the master is made on the performer's own device and uploaded after the
session. A take answers a `record_start` addressed to the device; its id is derived from
the room, the device and that event. The device's grant binds it to the principal it
captures for (the grant attribute `subject`), and may mark that principal a minor
(`minor`).
- **Commitments.** While it captures, the device emits a `take_commit` event every commit
interval of the room's recording policy (live-events §4.12's payload, defined here): the
take id, the chunk range it adds, the root over every chunk so far, its offset to the room
clock, and an RFC 9162 consistency proof that the root extends the last one. The take
tree is staged-measurement Appendix C's (RFC 6962's Merkle tree hash, its node) over
leaves that bind each chunk's index, capture range, length and hash; the output tree
exposes that hash and gains consistency proofs for it.
- **Upload and manifest.** The master is uploaded resumably by chunk hash; the store, a key
the room's key log lists with the new authority `capture/store`, signs `WUR1` receipts of
the upload's state machine (open, progress, resume, sealed) within the room's region pin.
The device signs a `WTM1` take manifest listing the chunks, their root, every
commitment's digest and each subject's `WCN1` consent (purposes live, record, distribute
and train; a guardian's signature for a minor; revocable through the key log).
- **Verdicts.** A take verifier replays the room (live-rooms §9) and gives `single-take`,
`aligned`, `complete` and `publishable` for each purpose a verdict, each failure a named
reason (§12): a commitment not delivered is `delivery_gap`; a part not committed at its
index is altered, reordered or extra; a missing part fails `complete`, not `single-take`.
A room-clock instant maps into the master, so a marker set on the live call lands in it.
- **Live rooms.** The recording policy gains `commit_interval_ms` and `jitter_ms`
(live-rooms §3.7; 2 000 and 500 by default), and the rooms corpus is regenerated.
- **C2PA live video** (C2PA Technical Specification 2.4 §19): the mapping of a take and its
CMAF output onto per-segment manifests and Verifiable Segment Info, session keys, AI
disclosure and sustainability is specified; the crate has no CBOR or COSE encoder, so it
implements only the stream id.
- **Reference and corpus.** `wai-rs` feature `capture_take` (`src/capture_take.rs`);
`live-conformance/takes/`, written from fixed seeds by `wai_capture_take_vectors`, and
checked by `tests/capture_take.rs` and by `live-conformance/capture_take_verify.py`, a
second implementation that shares no code with `wai-rs` and requires every mutated copy
of the corpus to fail. The registry lists the record and its magics (`WTM1`, `WUR1`,
`WCN1`).
### `wai.live.room`: room descriptors, room grants and the room verifier
- **The extension** ([live-rooms](extensions/live-rooms.md)) registers the record
`wai.live.room`. A room is fixed by a signed descriptor, `WRD1`, whose digest is the
room id every `WEV1` event names: its authority, its clock and `t0`, the roles a
principal may hold (publish per media kind, subscribe up to a layer, the event kinds it
may send, and the moderate, direct, compose and record powers), its limits and event
ceiling, the slots several publishers may stand behind, its publish profiles, its
review policy and its recording policy with a region pin.
- **Grants.** A principal takes part under a signed room grant, `WRG1`: its role and
capabilities, a window of at most fourteen days, its delivery origins, a single-use
nonce, a viewer id, the `WEP1` event policy of live-events §5 it is held to, its joule
and funds ceilings, the profiles it publishes with, and at most 1 KB of composition
attributes. A grant is exchanged for one chained to it at a tick of the room clock,
recorded as the `grant_exchange` lifecycle event, and revoked through the authority's
key log: the head's `revoked_modules` lists revoked grants and viewers, and the
authority's key holds the new authority `live/room` (jwp-receipts §7.2, §7.5, §7.6).
- **Tracks, slots and profiles.** A track is named by the MOQT namespace tuple (room,
principal or slot, media kind) and its layer. A slot is a virtual track: a publisher of
higher precedence takes it over with no new session, and a standby takes it on failover
under the slot's rule. The lifecycle kinds' `detail` is defined. The publish profiles
`speech`, `music`, `screen`, `camera-720` and `camera-1080` fix codecs, layers and
limits as data.
- **Room verification.** Given a descriptor, grants, a key-log head and every principal's
chain, a verifier merges the chains by room clock, verifies each event with the
live-events verifier under the policy of the grant in effect at its `t`, and replays the
lifecycle into room state; each refusal has a named reason (§11).
- **Bindings**, as tables: the Common Access Token's claims (CTA-5007-B), the `moqt` claim
of `draft-ietf-moq-c4m-01` (whose scopes the reference derives from each grant), URI
Signing (RFC 9246) and Privacy Pass (`draft-ietf-moq-privacy-pass-auth-03`). No CAT
encoding: the crate has no CBOR.
- **Reference and corpus.** `wai-rs` feature `live_room` (`src/live_room.rs`);
`live-conformance/rooms/`, written from fixed seeds by `wai_live_room_vectors`, and
checked by `tests/live_room.rs` and by `live-conformance/live_rooms_verify.py`, a second
implementation that shares no code with `wai-rs`. The registry lists the record and its
magics (`WRD1`, `WRG1`).
### `wai.live.events`: a signed, timeline-bound event track
- **The extension** ([live-events](extensions/live-events.md)) registers the record
`wai.live.events`. Timed metadata, ad and chapter cues (with an SCTE 35-1
`splice_info_section()` or an SCTE 35-2 message carried verbatim), chat, reactions,
polls, markers, remote-control commands, captions with their machine-generation label,
moderation decisions, take commitments and room lifecycle are each one `WEV1` record:
signed by its principal, hash-chained per principal (`seq`, `prev`), stamped with the
room clock and optionally anchored to one MoQ object. Its digest is SHA-256 over the
record without the signature, and its signature is verified strictly (SPEC §7
*Signatures*).
- **Policy and verification.** The event policy `WEP1` states the kinds a principal may
send and the payload, text and rate bounds, every bound a default a room may change; the
room grant of a later extension carries the same fields. A verifier refuses a record
with a named reason (§9), and reports a delivery gap as a gap, never as corruption.
- **Polls** replay as `wai.feed.state` cells through `wai.world.sim.feed0` (`ADD_INT`):
a tally is replay-equivalent, and a tally a poll's author states is checked.
- **Bindings**, field semantics only: an MSF event timeline over MoQ (`eventType`
`urn:wai:event:1`, one track per principal, one-minute groups, a join set repeated at
each group start), `EXT-X-DATERANGE` (`CLASS="science.transaction.wai.event"`,
`X-WAI-EVENT`, with `SCTE35-OUT`/`-IN`/`-CMD` for cues), a version 1 `emsg`
(`scheme_id_uri` `urn:wai:event:1`), a WebRTC data channel and a recording.
- **Reference and corpus.** `wai-rs` feature `live_events` (`src/live_events.rs`);
`live-conformance/events/`, written from fixed seeds by `wai_live_events_vectors`, and
checked by `tests/live_events.rs` and by `live-conformance/live_events_verify.py`, a
second implementation that shares no code with `wai-rs`. The registry lists the record
and its wire identifiers (`WEV1`, `WEP1`, `urn:wai:event:1`,
`science.transaction.wai.event`, `X-WAI-EVENT`).
### wai-quantum 0.6.2: the HIP member's matrix-instruction kernel, and the Metal member's tensor-operation kernel
Additive. Every pinned digest is unchanged.

View file

@ -39,7 +39,9 @@ Every registered string has one kind.
capability decoded and names no payload format.
- **record** — a signed record a WAI tool emits and verifies (a receipt kind:
`wai.asset.provenance`, the quantum-operations receipts, the delivery records
`wai.video.manifest` and its siblings). It names no payload format.
`wai.video.manifest` and its siblings; the live records `wai.live.events`,
`wai.live.room` and `wai.capture.take`).
It names no payload format.
A derivation or record string is not a payload capability, so it is not
dispatchable (SPEC §4): dispatch never takes an envelope's capability that names
@ -58,8 +60,10 @@ Two further tables hold strings that look like capabilities and are not:
A third holds the **wire identifiers**: the names the container, its delivery
bindings ([http-delivery](extensions/http-delivery.md),
[moq-streaming-format](extensions/moq-streaming-format.md)), the key discovery
they serve ([jwp-receipts](extensions/jwp-receipts.md) §7) and
[packaging](extensions/packaging.md) put on the wire outside a
they serve ([jwp-receipts](extensions/jwp-receipts.md) §7),
[packaging](extensions/packaging.md), [live-events](extensions/live-events.md),
[live-rooms](extensions/live-rooms.md) and [capture-binding](extensions/capture-binding.md)
put on the wire outside a
manifest — magic numbers, a media type, `codecs` prefixes, paths, playlist tags and
attributes, document kinds and catalog fields — each with its kind, the section that
defines it, and whether it is defined, provisional (test material only until
@ -164,6 +168,7 @@ and a role are fixed once registered.
| `wai.audio.scene` | payload | registered | audio | standalone | royalty-free | standard-defined | — | §5 Interactive, temporal & computed content |
| `wai.audio.score` | payload | registered | audio | standalone | royalty-free | standard-defined | — | §5 Interactive, temporal & computed content |
| `wai.avatar.pose` | payload | registered | interactive | standalone | royalty-free | standard-defined | — | §5 Interactive, temporal & computed content |
| `wai.capture.take` | record | registered | — | — | — | — | — | §5 Interactive, temporal & computed content |
| `wai.derive.crop` | derivation | registered | — | — | patent-pool | — | — | §5 Prior artifacts, general tensors and derivation operations |
| `wai.derive.dissolve` | derivation | registered | — | — | patent-pool | — | — | §5 Prior artifacts, general tensors and derivation operations |
| `wai.derive.grid_composition` | derivation | registered | — | — | patent-pool | — | — | §5 Prior artifacts, general tensors and derivation operations |
@ -187,6 +192,8 @@ and a role are fixed once registered.
| `wai.image.jpegai` | payload | registered | image | standalone | royalty-free | neural-entropy-exact | — | §5 Neural capabilities |
| `wai.image.jxl` | payload | registered | image | standalone | royalty-free | standard-tolerance | recommended | §5 Recommended modern set |
| `wai.image.png` | payload | registered | image | standalone | royalty-free | standard-defined | floor | §5 Mandatory floor |
| `wai.live.events` | record | registered | — | — | — | — | — | §5 Interactive, temporal & computed content |
| `wai.live.room` | record | registered | — | — | — | — | — | §5 Interactive, temporal & computed content |
| `wai.mesh.draco` | payload | registered | volumetric | standalone | royalty-free | standard-unclassified | — | §5 Immersive audio, cinema, volumetric, haptics, HDR |
| `wai.mesh.glb` | payload | registered | volumetric | standalone | royalty-free | standard-unclassified | — | §5 Interactive, temporal & computed content |
| `wai.meta.dovi` | companion | registered | metadata | companion | proprietary | standard-unclassified | — | §5 Immersive audio, cinema, volumetric, haptics, HDR |
@ -281,19 +288,30 @@ and a role are fixed once registered.
| `WAI1` | magic | defined | SPEC §2 |
| `WAI2` | magic | defined | SPEC §7.1 |
| `WAI_RECEIPT` | catalog | defined | moq-streaming-format §5 |
| `WCN1` | magic | defined | capture-binding §8.1 |
| `WEP1` | magic | defined | live-events §5 |
| `WEV1` | magic | defined | live-events §3.1 |
| `WHP1` | magic | defined | http-delivery §6.2 |
| `WRD1` | magic | defined | live-rooms §3.1 |
| `WRG1` | magic | defined | live-rooms §4.1 |
| `WSIX` | magic | defined | packaging §6 |
| `WTM1` | magic | defined | capture-binding §7.1 |
| `WUR1` | magic | defined | capture-binding §6.2 |
| `X-WAI-EVENT` | playlist-attribute | defined | live-events §8.3 |
| `X-WAI-FLOOR` | playlist-attribute | defined | http-delivery §11.1 |
| `application/wai` | media-type | proposed | http-delivery §3 |
| `mr/` | path | defined | http-delivery §8.6 |
| `rcpt/` | path | defined | http-delivery §8.1 |
| `science.transaction.wai.class` | data-id | defined | packaging §8 |
| `science.transaction.wai.event` | playlist-attribute | defined | live-events §8.3 |
| `science.transaction.wai.multivariant` | data-id | defined | http-delivery §11.1 |
| `science.transaction.wai.stages` | data-id | defined | packaging §8 |
| `tag:transaction.science,2026:wai/class` | scheme | defined | packaging §8 |
| `tag:transaction.science,2026:wai/floor` | scheme | defined | http-delivery §11.1 |
| `tag:transaction.science,2026:wai/http-delivery` | scheme | defined | http-delivery §11.1 |
| `tag:transaction.science,2026:wai/stages` | scheme | defined | packaging §8 |
| `urn:wai:event:1` | scheme | defined | live-events §8.4 |
| `urn:wai:event:1` | catalog | defined | live-events §8.1 |
| `wai` | catalog | defined | moq-streaming-format §3 |
| `wai-key-status` | document-kind | defined | jwp-receipts §7.9 |
| `wai-module-grant` | document-kind | defined | jwp-receipts §7.9 |

View file

@ -591,7 +591,8 @@ kind:
- a **record**: a signed record a WAI tool emits and verifies (a receipt kind —
`wai.asset.provenance`, `wai.quantum.calibration`, `wai.quantum.job`, and the
delivery records `wai.video.manifest`, `wai.video.ssai`, `wai.video.keys`,
`wai.video.package` and `wai.video.channel`), which names no payload format.
`wai.video.package` and `wai.video.channel`; and the live records `wai.live.events`,
`wai.live.room` and `wai.capture.take`), which names no payload format.
Only payload, companion and refinement capabilities have a media class, a
fidelity tier, a replicate class and a role. A derivation or record string is not a payload
@ -855,6 +856,44 @@ grant's joule ceiling (gate ⊗ meter ⊗ receipt in one object). WAI owns the
receipt; the sink's control system runs the hardware:
[`extensions/quantum-ops.md`](extensions/quantum-ops.md).
The same verify half, for **live events** — `wai.live.events`, a signed,
timeline-bound event track: timed metadata, ad and chapter cues, chat, reactions,
polls, markers, remote-control commands, captions, moderation decisions and room
lifecycle, each one `WEV1` record signed by its principal, hash-chained to that
principal's previous event, stamped with the room clock and optionally anchored to
one media object, so a delivery gap is told apart from corruption and a poll's tally
replays as `wai.feed.state` cells. The same bytes ride an MSF event timeline, an
`EXT-X-DATERANGE` tag, a version 1 `emsg` box, a data channel and a recording:
[`extensions/live-events.md`](extensions/live-events.md).
And for the **rooms** those events belong to — `wai.live.room`: the signed room
descriptor whose digest is the room id, fixing the room's authority, its clock and `t0`,
the roles a principal may hold, its limits, the slots several publishers may stand
behind, its publish profiles, and its review and recording policies (with a region pin
for data residency); and the signed room grant under which one principal takes part,
carrying its capabilities, a window of at most fourteen days, a single-use nonce, the
event policy it is held to, its joule and funds ceilings and its composition attributes.
A grant is exchanged at a tick of the room clock and revoked through the authority's key
log; a room verifier replays every principal's events under the grant in effect at each
instant. The grant maps onto existing token formats rather than defining one:
[`extensions/live-rooms.md`](extensions/live-rooms.md).
And for a **take** recorded in a room — `wai.capture.take`: a device's full-quality master
recording, made on the device beside a live call of lower quality, bound to the room while
it is captured. Every commit interval of the room's recording policy the device emits a
`take_commit` event in the room's event track, carrying the take id (derived from the
director's `record_start`), the chunks it adds, the root of a Merkle tree over every chunk
so far with a consistency proof that it extends the last, and the device's offset to the
room clock, so the room clock stamps each commitment and every participant holds it. The
master is uploaded by chunk hash under the store's signed receipts, and the device signs a
take manifest listing the chunks, their root, every commitment and the consent of each
subject, a guardian's for a minor, revocable through the key log. A verifier checks it
against the room's events and gives each claim its verdict: `single-take` (one continuous
take, every chunk committed while it was captured, every part uploaded the chunk committed
at its index), `aligned` (capture began at the count-in's instant), `complete`, and
`publishable` for each purpose. The take maps onto C2PA live video output:
[`extensions/capture-binding.md`](extensions/capture-binding.md).
### Payloads of the WAI-native integer codecs
This revision pins a payload format to each WAI-native integer codec whose row named

View file

@ -0,0 +1,727 @@
# WAI Extension: Capture Binding
> Status: Draft. Registers the record `wai.capture.take`: the binding of a device's
> full-quality **master** recording to a live room, so that the master, uploaded after
> the session, proves it is one continuous take committed while it was being captured.
> While it records, the device publishes rolling commitments to its master, `take_commit`
> events ([live-events](live-events.md) §4.12) in the room's event track, where they are
> stamped with the room clock and held by the relay and by every other participant. After
> the session the device uploads the master, resumably and by content address, the store
> records the upload in signed receipts, and the device signs a **take manifest** listing
> the chunks, their root, every commitment and the consent of every principal the master
> records. A verifier checks the manifest against the room's events and gives each claim
> its verdict: `single-take`, `aligned`, `complete` and `publishable` for each purpose.
> The reference case is a lesson or an audition, where the live call is low quality and
> the master is made on the performer's own device. No new transport: the commitments are
> events, and the mapping onto C2PA live video output is field semantics (§11). The
> reference implementation is the `capture_take` module of `wai-rs` (feature
> `capture_take`); the corpus is [`live-conformance/takes/`](../live-conformance/takes/),
> and its second implementation is `live-conformance/capture_take_verify.py`.
> Keywords **MUST**, **MUST NOT**, **SHOULD**, **MAY** are RFC 2119/8174.
>
> Texts read for this revision: RFC 6962 §2.1 and RFC 9162 §2.1.4 (the Merkle tree hash
> and its consistency proofs); ISO/IEC 23000-19 (CMAF) and ISO/IEC 14496-12; and the C2PA
> Technical Specification, version 2.4: §13.2.1, §18.15, §18.25, §18.26, §18.28 and §19.
> A section number of that specification in this text is that version's.
## 1. Scope
In scope: how a device and a take bind to the principal they capture for, the take id,
and what a `record_start` and `record_stop` pair brackets (§3); the chunks of a master and
the tree over them (§4); the `take_commit` payload and the chain of a take's commitments
(§5); the upload, by content address, and the store's signed receipts (§6); the take
manifest (§7); the consent record (§8); verification of a take and the verdict of each
claim (§9); the mapping from a room-clock instant to a position in the master, and its
bound (§10); and the mapping of a take and its CMAF output onto C2PA live video (§11).
Out of scope: a transport; the master's codec and container (a take commits to its bytes,
whatever they encode); how a store is operated; the room's own recording (a later
extension); per-object receipts of the relay that witness when a commitment was seen
(a later extension); and what a party does with a verdict.
Relationship to the core spec: `wai.capture.take` is a **record** ([SPEC](../SPEC.md) §5):
a signed record a WAI tool emits and verifies. It names no payload format and is not
dispatchable.
## 2. Terms
- **Room**, **room clock**, **principal**, **event**, **chain**, **grant**, **grant in
effect**, **room key**, **merged order**: as in live-events §2 and live-rooms §2, §9.2.
- **Device**: the Ed25519 key (RFC 8032) of the device that captures. It takes part in the
room under a grant (live-rooms §4) as a principal: it signs its `take_commit` events and
its take manifest.
- **Master**: the encoded recording the device makes, at the quality it captures.
- **Device clock**: the clock the device stamps its capture with, in microseconds. Its
epoch is the device's.
- **Offset**: the room clock less the device clock, in microseconds, as the device measures
it: a device-clock instant *d* is the room-clock instant *d* + offset.
- **Chunk**: a contiguous run of the master's bytes (§4.1).
- **Take**: one continuous capture on one device, answering one `record_start` (§3).
- **Store**: the service the master is uploaded to, named by its Ed25519 key (§6).
- **Subject**: a principal the master depicts or records.
- **Purpose**: a use a subject consents to: `live` (1), `record` (2), `distribute` (4),
`train` (8), each a bit of a purposes byte.
- *I* and *J*: the room's `commit_interval_ms` and `jitter_ms` (live-rooms §3.7), each
times 1000, in microseconds.
Integers are little-endian. A digest is SHA-256 (FIPS 180-4), 32 bytes. An event's digest
is live-events §3.2's.
## 3. The device, its principal and the take
### 3.1 Binding a device to a principal
A device captures for one principal, which its grant names: the device's grant in effect
**binds** it to principal *P* when the grant's attributes (live-rooms §4.4) hold the key
`subject` whose value is *P*'s key as 64 lowercase hexadecimal digits, or, when they hold
no `subject`, when *P* is the device's own key. The room authority signs the grant, so the
binding is the authority's statement that this device captures for *P*: a device can be
a key of its own (one per room, live-events §11) and still capture for a principal its
participants know.
A principal is **marked a minor** in a take when the device's grant that binds it holds
the attribute `minor` with the value `true`, or when its consent record says so (§8.1).
### 3.2 The take id
A take is named by its **take id**, derived and never chosen: SHA-256 over the 18 bytes
`wai:capture-take` (ASCII) and 0x01, then the room id, the device's key, and the digest of
the `record_start` event the take answers. One `record_start` addressed to one device
names one take.
### 3.3 What a take brackets
A take answers a `control` event (live-events §4.9) whose command is `record_start` and
whose `target` is the device, sent by a principal with the record power (live-rooms §3.3,
§9.2 step 5). Its **start** is that event. Its **capture instant** is the room-clock
instant `t` + `count_in_ms` × 1000 of the start: the count-in fixes when capture begins,
for every participant alike.
Its **stop** is the first `control` event in the merged order after the start whose command
is `record_stop` and whose `target` is the device; a take need not have one. A second
`record_start` to the same device before the stop starts another take.
The device's first commitment of the take **opens** it, in answer to the start; its last
**closes** it, after the stop (§5.3). A room whose recording policy has `mode` 0 is never
recorded, and no take in it is permitted (§9.1). The interval *I* and the jitter *J* a
take is held to are the room's (live-rooms §3.7), so no device chooses its own.
## 4. Chunks and the take tree
### 4.1 Chunks
The device cuts its encoded master into chunks, by bytes or by duration, in the order the
master's bytes run. Where the master is CMAF (ISO/IEC 23000-19), chunk 0 is the CMAF
header followed by the first CMAF segment, and each later chunk is one CMAF segment, so
that the header, whose edit lists could reorder what a segment presents, is committed
with the first chunk. A chunk is described by 64 bytes, its **descriptor**:
| offset | size | field | meaning |
|---|---|---|---|
| 0 | 8 | `index` | u64: the chunk's position in the master, from 0 |
| 8 | 8 | `start` | u64: the device-clock instant its capture begins |
| 16 | 8 | `end` | u64: the device-clock instant its capture ends (exclusive) |
| 24 | 8 | `len` | u64: its length in bytes, at least 1 |
| 32 | 32 | `hash` | the digest of its bytes |
A chunk holds the encoded samples captured from `start` to `end`. Its capture range is
the device's statement; the commitments fix it while the capture runs (§5), and the
verdicts hold the device to it (§9.4).
### 4.2 The take tree
The take tree is the tree of [staged-measurement](staged-measurement.md) Appendix C: RFC
6962's Merkle tree hash over SHA-256, its node that appendix's, over leaves of the take's
own domain:
```
leaf = SHA-256("wai:take-leaf\x01" ‖ descriptor) 64-byte descriptor, §4.1
node = SHA-256("wai:out-node\x01" ‖ left ‖ right) staged-measurement Appendix C
```
The **root** over the descriptors of chunks 0 to *n* − 1, *n* ≥ 1, is RFC 6962 §2.1's
`MTH`: one leaf is itself; otherwise `node(MTH(D[0:k]), MTH(D[k:n]))`, with *k* the
largest power of two below *n*. No leaf is duplicated, so a root and its size fix the
list of descriptors. The root over no chunk is 32 zero bytes. A **commitment** is a size
and its root; as there, a root alone is not one. The leaf binds each chunk's index, its
capture range, its length and its hash, so a commitment fixes when each chunk was captured
as well as what it holds.
### 4.3 Consistency
A commitment (*n*, root′) **extends** (*m*, root) when the first *m* chunks under root′ are
the chunks under root. The proof of it is RFC 9162 §2.1.4.1's consistency proof, the
`PROOF(m, D[n])` of RFC 6962 §2.1.2, a list of nodes, checked as RFC 9162 §2.1.4.2 checks
one, with the node of §4.2 in place of its `HASH(0x01 ‖ …)`. RFC 9162 leaves two cases to
the caller, and this extension decides them: every commitment extends (0, zero), with an
empty proof; and (*m*, root′) extends (*m*, root), with an empty proof, exactly when the
roots are equal. Otherwise 0 < *m* < *n* and the proof is not empty.
A witness that holds only the previous commitment checks that the next extends it: the
relay and every participant can check, while the take runs, that the device never
rewrites what it has committed. A proof does not fix either size, as an audit path does
not (staged-measurement Appendix C): a commitment states its size, and a verifier that
holds the chunks recomputes every root under the size stated (§9.4).
## 5. Commitments
### 5.1 The `take_commit` payload
Every *I* while it captures, the device emits a `take_commit` event (live-events §4.12) in
the room, on its own events track. Its payload:
| field | size | meaning |
|---|---|---|
| `take` | 32 | the take id (§3.2) |
| `n` | 4 | u32: the commitment's number in the take, from 0 |
| `phase` | 1 | 1 open, 2 running, 3 close |
| `prev` | 32 | the digest of the take's commitment `n` − 1; 32 zero bytes when `n` is 0 |
| `first` | 8 | u64: the index of the first chunk this commitment adds |
| `size` | 8 | u64: the chunks committed so far; this commitment adds chunks `first` to `size` − 1 |
| `root` | 32 | the root over chunks 0 to `size` − 1 (§4.2) |
| `offset` | 8 | i64, two's complement: the offset (§2) the device measures at the event |
| `proof_len` | 1 | the number of nodes in `proof`, at most 64 |
| `proof` | 32 × `proof_len` | that (`size`, `root`) extends the previous commitment (`first`, its root) (§4.3) |
The fixed part is 126 bytes, so a proof of up to 28 nodes fits live-events' default
`max_payload` of 1024. The chunk range it names, `first` to `size` − 1, is what a witness
that missed the previous commitment still learns.
### 5.2 Reading a payload
A reader reads the payload in the order of §5.1 and refuses it as `commit_malformed`: when
it ends before a field or holds bytes after `proof`; when `phase` is not 1, 2 or 3; when
`size` is below `first`; when `proof_len` is above 64; or when `size` is 0 and `root` is
not 32 zero bytes. A room verifier (live-rooms §9) carries the payload opaque, as
live-events §4.12 has it; a take verifier reads it (§9.2).
### 5.3 The chain of a take's commitments
A take's commitments are the `take_commit` events of the device whose `take` is the take
id, in the order of the device's chain. They form their own chain:
- The **opening** commitment is the first: `n` 0, `phase` open, `prev` zero, `first` and
`size` 0, and no proof. The device emits it when the start reaches it, before its
capture begins.
- Each later commitment `n` has `n` one above the previous one's, `prev` the previous
one's digest, `first` the previous one's `size`, and `phase` running or close. It adds
the chunks the device has completed since the previous one, perhaps none, and proves
that its commitment extends the previous one (§4.3).
- The **closing** commitment, `phase` close, is the last: after the stop, it adds the
chunks captured up to the stop, and no commitment of the take follows it.
The chain needs nothing a witness does not hold: `n` and `prev` make a commitment that was
not delivered a gap in the take, as live-events §6.2 makes it one in the device's chain,
and the proof makes a rewritten history a commitment that does not extend.
## 6. Upload
### 6.1 Parts
After the session the device uploads its master to a store, chunk by chunk, each chunk a
**part**: its index and its bytes, addressed by the chunk's hash. The upload is
resumable: a part that did not arrive is sent again, and a part sent twice is the same
part. A verifier fetches the parts from the store by hash and is given them as a list of
(index, bytes) in the order it received them.
### 6.2 The upload receipt
The store records the upload's state in signed **upload receipts**, `WUR1`, one at each
change of state:
| offset | size | field | meaning |
|---|---|---|---|
| 0 | 4 | `magic` | `WUR1` (ASCII) |
| 4 | 1 | `version` | 1 in this revision |
| 5 | 32 | `store` | the store's Ed25519 key |
| 37 | 32 | `take` | the take id |
| 69 | 8 | `seq` | u64: the receipt's position in the upload's chain, from 0 |
| 77 | 32 | `prev` | the digest of receipt `seq` − 1; 32 zero bytes when `seq` is 0 |
| 109 | 8 | `at` | u64: the store's clock, in microseconds since 1970-01-01T00:00:00Z |
| 117 | 1 | `state` | 1 open, 2 progress, 3 resume, 4 sealed |
| 118 | 8 | `held` | u64: the chunks the store holds, from index 0 with none missing |
| 126 | 32 | `root` | the root over those chunks (§4.2), zero when `held` is 0 |
| 158 | 2 | `region` | ISO 3166-1 alpha-2: where the store keeps the chunks |
| 160 | 64 | `sig` | Ed25519 by `store` |
The **digest** of a receipt is SHA-256 over its bytes before `sig`. The **signature** is
Ed25519 by `store` over the 17 bytes `wai:take-upload` (ASCII) and 0x01, followed by the
bytes the digest covers, verified strictly (SPEC §7 *Signatures*).
A reader reads exactly the bytes it is given, as live-events §3.3 reads a record with
`WUR1` for `WEV1`: `truncated`, `malformed` or `unknown_version`.
### 6.3 The state machine
The receipts of an upload are a chain, and its states move only this way:
- receipt 0 is `open`, with `held` 0;
- `progress`: `held` above the previous receipt's, as parts arrive;
- `resume`: `held` equal to the previous receipt's: an upload interrupted and resumed,
which continues from the chunk at `held`;
- `sealed`: `held` equal to the previous receipt's; the store holds the master, and no
receipt follows.
A verifier, given the receipts in a list, checks each in the list's order and refuses at
the first that fails, naming it by its index: the record reads (§6.2); its `state` is 1 to
4, its `region` two capitals `A`–`Z`, and its `root` zero when `held` is 0, otherwise
`upload_receipt_invalid`; its `store` and `take` are the manifest's (§7),
`upload_receipt_invalid`; its signature verifies (`bad_key`, `bad_signature`); its `seq`
is its index, its `prev` the previous receipt's digest, its `at` not below the previous
one's, its `region` receipt 0's, and its `state` one the list above allows after the
previous receipt, otherwise `upload_receipt_invalid`; its `held` is at most the
manifest's chunk count and its `root` the root over the manifest's first `held` chunks,
otherwise `upload_receipt_mismatch`; and, where the room's recording policy pins regions
(live-rooms §3.7), its `region` is one of them, otherwise `store_outside_region`.
### 6.4 The store
A room whose descriptor names an `origin` (live-rooms §5) names its stores in that
origin's key log: a store's key is an entry of the head whose `may_sign` holds the
authority `capture/store` ([jwp-receipts](jwp-receipts.md) §7.5) and whose `status` is
not `revoked`. A verifier refuses a take whose store the head does not so list as
`store_not_listed`. In a room with no origin the store is the one the manifest names, and
its receipts are its statement alone.
## 7. The take manifest
### 7.1 Layout
When the upload is complete, the device signs a take manifest, `WTM1`:
| field | size | meaning |
|---|---|---|
| `magic` | 4 | `WTM1` (ASCII) |
| `version` | 1 | 1 in this revision |
| `room` | 32 | the room id |
| `device` | 32 | the device's key, which signs the manifest |
| `principal` | 32 | the principal the take is captured for (§3.1) |
| `start` | 32 | the digest of the take's start (§3.3) |
| `take` | 32 | the take id (§3.2) |
| `store` | 32 | the store's key (§6) |
| `claims` | 1 | the claims the manifest makes: bit 0 `single-take`, bit 1 `aligned`, bit 2 `complete` |
| `purposes` | 1 | the purposes it claims the master `publishable` for (§2) |
| `chunks` | 4 + 64 × *n* | a u32 count *n*, then each chunk's descriptor (§4.1), in index order |
| `root` | 32 | the root over the *n* chunks (§4.2) |
| `commits` | 4 + 32 × *m* | a u32 count *m*, then the digest of each of the take's commitments, in the order of `n` |
| `depicted` | 1 + 64 × *k* | a u8 count *k*, then each subject: its key (32) and the digest of its consent record (32; zero for none) (§8) |
| `sig` | 64 | Ed25519 by `device` |
The **digest** of a manifest is SHA-256 over its bytes before `sig`; its **signature** is
Ed25519 by `device` over the 21 bytes `wai:capture-manifest` (ASCII) and 0x01, followed by
the bytes the digest covers, verified strictly (SPEC §7 *Signatures*).
### 7.2 Reading and rules
A reader reads exactly the bytes it is given, as live-events §3.3 reads a record with
`WTM1` for `WEV1` (`truncated`, `malformed`, `unknown_version`), and refuses a manifest
that breaks any of these rules as `manifest_invalid`:
- `claims` sets no bit above bit 2, and `purposes` none above bit 3;
- *n* is at least 1, each chunk's `index` is its position in the list, and each `len` is
at least 1;
- `root` is the root over the chunks;
- *m* is at least 1, and no digest is listed twice;
- *k* is 1 to 32, the subjects' keys in strictly ascending byte order, and `principal` is
one of them;
- `take` is the take id of `room`, `device` and `start` (§3.2).
It then refuses a manifest whose `room` is not the room's as `wrong_room`, and one whose
signature does not verify as `bad_key` or `bad_signature`.
The manifest lists every subject the master depicts or records, the take's principal
among them, and binds each to its consent. A claim bit or a purpose bit states what the
device claims; a verifier gives every claim its verdict whether the manifest makes it or
not (§9.4).
## 8. Consent
### 8.1 The consent record
A subject's consent is a signed record, `WCN1`:
| offset | size | field | meaning |
|---|---|---|---|
| 0 | 4 | `magic` | `WCN1` (ASCII) |
| 4 | 1 | `version` | 1 in this revision |
| 5 | 32 | `subject` | the subject's key |
| 37 | 1 | `minor` | 1 when the subject is a minor, otherwise 0 |
| 38 | 32 | `guardian` | the key of the subject's guardian; 32 zero bytes for none |
| 70 | 1 | `purposes` | the purposes consented to (§2) |
| 71 | 32 | `room` | the room it is for; 32 zero bytes for any room |
| 103 | 8 | `nbf` | u64: seconds since 1970-01-01T00:00:00Z from which it holds |
| 111 | 8 | `exp` | u64: the second before which it holds |
| 119 | 16 | `nonce` | distinguishes two consents with the same fields |
| 135 | 64 | `sig` | Ed25519 by the guardian when `guardian` is not zero, by the subject otherwise |
The **digest** of a consent is SHA-256 over its bytes before `sig`. The **signature** is
over the 20 bytes `wai:capture-consent` (ASCII) and 0x01, followed by the bytes the digest
covers, verified strictly (SPEC §7 *Signatures*): a minor's consent is the guardian's
act, any other the subject's own.
### 8.2 Reading
A verifier, given consent records in a list, checks each in its order and refuses at the
first that fails, naming it by its index: the record reads, as live-events §3.3 reads one
with `WCN1` for `WEV1`; it holds these rules, otherwise `consent_invalid`: `minor` is 0 or
1, `guardian` is zero when `minor` is 0, `guardian` is not `subject`, `purposes` is not 0
and sets no bit above bit 3, and `nbf` is below `exp`; and its signature verifies
(`bad_key`, `bad_signature`). A record marked a minor with no guardian reads: it is a
record no minor's consent can rest on (§9.4).
### 8.3 Revocation
A consent is revoked through the room authority's key log, as a grant is (live-rooms §5):
its digest is added to the head's `revoked_modules`, which is append-only, so a
revocation stays. A subject or guardian that withdraws consent asks the authority to list
it. In a room whose descriptor names no origin no consent can be revoked, and a room that
records subjects **SHOULD** name one.
## 9. Verification
### 9.1 Inputs and steps
A take verifier is given the room (a descriptor, grants, the head of the authority's key
log or none, and the chains, as live-rooms §9.1), the take manifest, the parts (§6.1), the
upload receipts, the consent records, and an **evaluation instant** `at`, in seconds since
1970-01-01T00:00:00Z: the time its verdicts are about. It runs these steps and stops at
the first refusal, which it reports with the reason and where:
1. **The room** (live-rooms §9): a refusal is the take's, at `room`, with the room's own
place. A gap is not a refusal: the steps go on over the records the room verifier
holds, and §9.4 says what a gap fails.
2. **The manifest** (§7.2): at `manifest`.
3. **The take in the room** (§9.2): at `manifest`, or at the record it names.
4. **The store** (§6.4): at `store`. **The receipts** (§6.3): at the receipt's index.
5. **The consents** (§8.2): at the record's index.
6. **The verdicts** (§9.4).
### 9.2 The take in the room
Over the records the room verifier accepted, in the merged order (live-rooms §9.2), with
each one's grant:
1. The recording policy's `mode` is not 0: otherwise `recording_not_permitted`.
2. The manifest's `start` is the digest of an accepted record that is a `control` event
whose command is `record_start` and whose `target` is the manifest's `device`:
otherwise `take_unknown`.
3. Every accepted `take_commit` event of the device has a payload §5.2 reads: otherwise
`commit_malformed`, at that record. Those whose `take` is the take id are the take's
**held commitments**, in the device's chain order.
4. At each held commitment, the device's grant in effect binds it to the manifest's
`principal` (§3.1): otherwise `device_not_bound`, at that record.
5. The held commitments are the manifest's `commits`, in its order, with those not held
left out; a listed digest that is not held is the digest of no accepted record, and
the room verifier reports a gap in the device's chain. Otherwise `manifest_mismatch`.
A commitment listed and not held is so a commitment the relay did not deliver, and the
device's chain shows the gap; a manifest cannot list one the room holds out of its place,
leave one out, or list one no gap can account for.
### 9.3 Mapping and timing
The take's clock mapping is the offset *o* of its opening commitment: a device-clock
instant *d* maps to the room-clock instant *d* + *o*, computed without overflow (the
reference computes in 128-bit integers). A chunk's **mapped end** is its `end` mapped. For
each commitment, its `t` is the room-clock instant of its event; the capture instant, the
start and the stop are §3.3's. Every comparison in this section is between room-clock
instants, in microseconds.
### 9.4 The verdicts
Each claim holds, or fails with the first reason below that applies to it.
**`single-take`** holds when the master is one continuous take, every chunk of it
committed while it was captured, and every part uploaded is the chunk committed at its
index:
1. `delivery_gap`: a commitment the manifest lists is not held.
2. `commit_chain_broken`: the held commitments are not a chain of §5.3: the first is not
an opening one, a later one's `n`, `prev` or `first` does not follow its predecessor,
a later one is an opening one, or a commitment follows a closing one.
3. `root_not_extending`: a commitment does not extend its predecessor (§4.3): its proof
does not verify, a proof is given where §4.3 gives none, or a commitment that adds no
chunk changes the root.
4. `take_not_closed`: the last held commitment is not a closing one, or the take has no
stop.
5. `manifest_not_committed`: the manifest's chunk count is not the closing commitment's
`size`, or, for a held commitment whose `size` is above 0, the root over the
manifest's first `size` chunks is not its `root`.
6. `clock_unstable`: a commitment's offset differs from *o* by more than *J*.
7. `capture_discontinuous`: a chunk's `start` is not below its `end`, or a chunk's
`start` is not the previous chunk's `end`.
8. `chunk_too_long`: a chunk's `end` − `start` is above *I*.
9. `commit_early`: the opening commitment's `t` is below the start's; the closing one's
is below the stop's; or a commitment's `t` + *J* is below the mapped end of a chunk it
adds (it committed a chunk before the chunk was captured).
10. `commit_late`: a commitment's `t` is above the mapped end of a chunk it adds plus *I*
plus *J*.
11. Over the parts, in the order given, the first that is not the chunk committed at its
index: `chunk_extra` when its index is not below the chunk count; `chunk_reordered`
when its bytes' digest and length are another committed chunk's; `chunk_altered`
otherwise.
By rules 8 and 10, every byte of the master was committed within 2 × *I* + *J* of its
capture on the take's clock mapping, and by rule 6 that mapping held to within *J*
throughout.
A part missing from the upload does not fail `single-take`: what was uploaded is what was
committed, and the commitments verify. It fails `complete`. A party promised an unedited
single take needs `single-take` and `complete` together: `single-take` alone holds of an
upload that left chunks out.
**`aligned`** holds when capture began at the instant the count-in fixed:
1. `delivery_gap`: the first commitment the manifest lists is not held.
2. `commit_chain_broken`: the first held commitment is not an opening one.
3. `manifest_not_committed`: no held commitment adds a chunk, or the root over the
manifest's first `size` chunks is not the root of the first held commitment that does.
4. `misaligned`: chunk 0's `start`, mapped, differs from the capture instant by more than
*J*.
**`complete`** holds when the upload covers every committed chunk and the store sealed it:
with *s* and *r* the `size` and `root` of the last held commitment,
1. `manifest_not_committed`: *s* is 0, *s* is above the manifest's chunk count, or the
root over the manifest's first *s* chunks is not *r*.
2. `chunk_missing`: an index below *s* has no part whose bytes' digest and length are that
chunk's.
3. `upload_not_sealed`: the last receipt is not `sealed`, or its `held` is not *s*.
**`publishable`** for purpose *p* holds when every subject the manifest lists consented to
the recording and to *p*. For each subject, in the manifest's order, the first that
fails:
1. `consent_missing`: its consent digest is zero, or no consent record given has it, or
that record's `subject` is not this subject, or its `room` is neither zero nor the room
id.
2. `guardian_missing`: the subject is marked a minor (§3.1: by the record's `minor`, or,
for the take's principal, by the `minor` attribute of the device's grant in effect at
its first held commitment) and the record's `guardian` is zero.
3. `consent_revoked`: the head's `revoked_modules` lists the record's digest (§8.3).
4. `consent_expired`: the record does not hold (`nbf` ≤ *s* < `exp`) at the start's
instant, *s* = ⌊(`t0` + `t`) / 10^6⌋ of the start, or at `at`.
5. `purpose_not_consented`: its `purposes` lack `record`, or lack *p*.
### 9.5 Outcome
A take verification gives one outcome:
- **refuse**: the reason and where (§9.1).
- **verdicts**: the verdict of `single-take`, `aligned`, `complete`, and `publishable`
for each of the four purposes, each `holds` or its reason; and whether the manifest
**stands**: every claim it makes, and `publishable` for every purpose it names, holds.
## 10. Dual-tier binding
The live tracks of the room and the master share the room clock: every event of the room
has its `t` on it (live-events §3.1), and the take's mapping places the master on it
(§9.3). A room-clock instant *t* within the take maps to the master:
- the device-clock instant is *d* = *t* − *o*;
- the chunk is the one whose `start` ≤ *d* < `end`;
- the position is *d* − that chunk's `start` into the chunk, and *d* − chunk 0's `start`
into the master.
An instant before chunk 0's mapped start or at or after the last chunk's mapped end is
outside the master. A marker set on the live call at room time *t* (live-events §4.8) so
lands at its instant in the master. The bound: where `single-take` and `aligned` hold, the
mapping strays from the device's own clock mapping at any commitment by at most *J* (rule
6), and the master's start from the capture instant by at most *J*. The marker's `t` is
its author's statement of the room clock (live-events §11), and a marker set by watching
the live call lags what it marks by that call's own delay; a marker anchored to a live
object (live-events §3.1) names the object instead, and a sink that knows that object's
capture instant on the room clock maps that instant.
## 11. C2PA live video
This section maps a take, and a CMAF output of a room, onto the C2PA Technical
Specification's live video provenance (§19 there). It is field semantics, and this
revision implements none of it beyond the stream id: `wai-rs` has no CBOR or COSE
encoder, and the C2PA signer `c2pa_emit` uses writes whole-asset manifests and fragmented
BMFF manifests (one manifest in the initialization segment, a Merkle tree over the
fragments), with no live video segment assertion, no session keys assertion and no
Verifiable Segment Info. The take's commitments are the WAI side's witness; the C2PA
output is what a C2PA validator reads, and each is checked on its own.
### 11.1 What maps to what
- **The stream.** A take is one live video session. Its `streamId` (§19.3.2) is the take
id's first 16 bytes as an RFC 9562 UUID of version 8: byte 6's high four bits set to
`1000`, byte 8's high two bits to `10`, written in the 8-4-4-4-12 lowercase form. The
corpus states it for each take.
- **The segments.** Where the master is CMAF, chunk *i* holds the segment whose movie
fragment the device numbers *i* + 1 in its `mfhd` `sequence_number`
(ISO/IEC 14496-12), and that is the segment's `sequenceNumber` in both methods. Chunk 0
holds the initialization segment too (§4.1), with the C2PA Manifest Box §19.2.3
requires.
- **The hashes.** A chunk's `hash` covers every byte of the chunk, its C2PA boxes
included; the segment's `c2pa.hash.bmff.v3` excludes them by §19's exclusions. The two
are different digests of different spans.
### 11.2 Per-segment manifests (§19.3)
Each segment carries a C2PA Manifest Box in a `uuid` box, whose manifest holds a
`c2pa.hash.bmff.v3` over the segment and the assertion `c2pa.livevideo.segment` with
`sequenceNumber` as above, `streamId` the take's, `continuityMethod` `c2pa.manifestId`,
and `previousManifestId` the manifest identifier of chunk *i* − 1's segment. The
initialization segment's manifest carries `c2pa.actions.v2` whose first action is
`c2pa.created` with the IPTC digital source type `digitalCapture` (§18.15.2).
Each segment's manifest **MAY** also carry the assertion `science.transaction.wai.take`,
a map of `take` (the take id), `room` (the room id), `device` (the device's key) and
`commit` (the digest of the device's last `take_commit` before the segment was written,
absent before the first), each a byte string, so that a C2PA validator finds the room
events that witness the take.
### 11.3 Verifiable Segment Info (§19.4)
- **The session key** (§18.25, `c2pa.session-keys`, in the initialization segment's
manifest): the device's key, as an OKP COSE_Key of curve Ed25519, EdDSA being among
§13.2.1's algorithms, with `kid` the key's 32 bytes; `minSequenceNumber` 1; `createdAt`
the opening commitment's instant, `t0` + `t`; `validityPeriod` from then to the `exp`
of the device's grant; and `signerBinding`, a COSE_Sign1 by the session key over the
claim signer's end-entity certificate, as §18.25 has it. The device's key is then
bound to the room by its grant and to the C2PA signer by the binding.
- **Each segment** carries an `emsg` box of version 0, `scheme_id_uri`
`urn:c2pa:verifiable-segment-info`, `value` `fseg`, `presentation_time_delta` 0,
`timescale` and `event_duration` covering the segment, `id` the `sequenceNumber`, and
`message_data` a COSE_Sign1_Tagged by the session key whose payload holds
`sequenceNumber`, `bmffHash` (the segment's, excluding its C2PA `emsg` boxes, with no
`merkle` field) and `manifestId` (§19.4.1, §19.4.2).
- A COSE Sig_structure begins with the context string `Signature1`, and every message
WAI signs begins with a `wai:` domain, so no signature of one is a signature of the
other.
### 11.4 A room's CMAF output
A CMAF rendition a packager makes of a room's live tracks maps the same way, as its own
live video session: its own `streamId`, its own session key, its own claim signer. That
is the packager's provenance of its output, not the device's of its take.
### 11.5 AI disclosure and sustainability
- **AI disclosure** (§18.28, `c2pa.ai-disclosure`). A take's manifest has no field for a
trained model, and its C2PA output carries no `c2pa.ai-disclosure`: its source type is
a digital capture. A CMAF output that carries captions a model produced (live-events
§4.10, `origin` 2 or 3) discloses them: `modelIdentifier` is `sha256:` followed by the
caption's `model` digest in lowercase hexadecimal; `contentProfile`
`humanOversightLevel` is `fully_autonomous` for `origin` 2 and `human_validated` for 3;
`modelType` is required and an event does not carry it, so an emitter writes the
assertion only where it knows the model's type (§18.28's Table 12).
- **Sustainability** (§18.26, `c2pa.environmental-sustainability`). An emitter writes
`energy_kwh` only from a measured figure with its class, under
[energy-binding](energy-binding.md)'s rules, and never `carbon_kgco2e` or
`water_litres` derived from a figure and a factor. A take carries no energy figure in
this revision, so its output carries no such assertion.
## 12. Reasons
The codes this extension adds, each with where it is raised:
| reason | raised at |
|---|---|
| `recording_not_permitted` | §9.2 step 1 |
| `take_unknown` | §9.2 step 2 |
| `commit_malformed` | §5.2, §9.2 step 3 |
| `device_not_bound` | §3.1, §9.2 step 4 |
| `manifest_invalid` | §7.2 |
| `manifest_mismatch` | §9.2 step 5 |
| `store_not_listed` | §6.4 |
| `upload_receipt_invalid` | §6.3 |
| `upload_receipt_mismatch` | §6.3 |
| `store_outside_region` | §6.3 |
| `consent_invalid` | §8.2 |
| `commit_chain_broken` | §9.4 `single-take` 2, `aligned` 2 |
| `root_not_extending` | §4.3, §9.4 `single-take` 3 |
| `take_not_closed` | §9.4 `single-take` 4 |
| `manifest_not_committed` | §9.4 `single-take` 5, `aligned` 3, `complete` 1 |
| `clock_unstable` | §9.4 `single-take` 6 |
| `capture_discontinuous` | §9.4 `single-take` 7 |
| `chunk_too_long` | §9.4 `single-take` 8 |
| `commit_early` | §9.4 `single-take` 9 |
| `commit_late` | §9.4 `single-take` 10 |
| `chunk_extra` | §9.4 `single-take` 11 |
| `chunk_reordered` | §9.4 `single-take` 11 |
| `chunk_altered` | §9.4 `single-take` 11 |
| `misaligned` | §9.4 `aligned` 4 |
| `chunk_missing` | §9.4 `complete` 2 |
| `upload_not_sealed` | §9.4 `complete` 3 |
| `consent_missing` | §9.4 `publishable` 1 |
| `guardian_missing` | §9.4 `publishable` 2 |
| `consent_revoked` | §9.4 `publishable` 3 |
| `consent_expired` | §9.4 `publishable` 4 |
| `purpose_not_consented` | §9.4 `publishable` 5 |
The first eleven are refusals; the rest are verdicts. A take verifier also raises every
code a room verifier raises (live-rooms §11 and the live-events codes it lists), at
`room`; `truncated`, `malformed`, `unknown_version`, `bad_key` and `bad_signature` for a
manifest, a receipt or a consent record that does not read or whose signature fails;
`wrong_room` for a manifest of another room; and `delivery_gap` as a verdict.
## 13. Conformance
[`live-conformance/takes/`](../live-conformance/takes/) holds the corpus, in one file,
`vectors.json`, written from fixed seeds by `wai_capture_take_vectors gen`. Each vector is
a room, a manifest, parts, receipts, consent records and an evaluation instant, with the
outcome §9.5 gives them; one with verdicts also states the take id, its commitment count
and digests, its size and root, its stream id (§11.1), and the master position of every
marker of the room within the take (§10).
- **Good takes**: a lesson take (the director's remote `record_start` with a count-in,
the performer's chunks and commitments, the director's markers, `record_stop`, the
upload and the manifest), whose every claim holds but `publishable` for `train`, which
the performer did not consent to; the same take with an upload interrupted and resumed;
and a minor's take under a guardian's consent.
- **Negatives**, each a control of one defect: a commitment not delivered, a spliced
chunk, a reordered one, an extra one never committed, a missing one (`complete` fails,
`single-take` holds), a late commitment, a root that does not extend its predecessor,
a capture that ignored the count-in, a consent missing, expired or revoked, a minor
without a guardian, a device not bound to the principal; and every other reason of §12,
with a manifest of another room and one whose signature fails.
A conforming verifier gives every vector the outcome, the reason, the place, the verdicts
and the data the corpus states. `wai_capture_take_vectors verify` and the test
`tests/capture_take.rs` check that the file regenerates byte for byte, that `wai-rs` gives
every vector its outcome, that every reason of §12 appears in the corpus, and that §12
lists exactly the reference's codes. `live-conformance/capture_take_verify.py` is the
second implementation, written from this text: it shares no code with `wai-rs`, verifies
signatures with WAI's strict rule written from RFC 8032 (`claim-conformance/_ed25519.py`),
verifies the room with the live-rooms corpus's own second implementation
(`live_rooms_verify.py`, which verifies each event with `live_events_verify.py`), and gives
every vector the same outcome; it also mutates copies of the corpus and requires every
copy to fail.
## 14. Security and privacy considerations
- The commitments are events: signed by the device, chained, stamped with the room clock
and held by every participant. A relay can drop one, which the take's chain and the
device's chain both show, but cannot alter one. A device that edits its master after
committing it cannot upload the edit as its take: the edited chunk is not the committed
one, and a history it rewrites does not extend what it committed.
- A commitment's `t` is its author's statement (live-events §11). A device that
pre-dates its commitments is held by the room's other records: its events take their
place in the merged order among everyone's, and the per-hop receipts of a later
extension witness when each was seen. The take's timing rules bound everything to the
room clock from the start's instant on, within *I* and *J*.
- The capture range of a chunk is the device's statement. The rules bind it to the room
clock at every commitment (rules 6 to 10 of `single-take`) and to the count-in
(`aligned`), so a device can neither stretch a chunk over time it did not capture nor
commit a chunk before its capture ends; it can still capture something other than the
room, which no commitment proves.
- Content addressing makes a part its own check: a part whose bytes do not hash to its
chunk's `hash` is not that chunk, whoever stored it.
- The store's receipts state the upload's state machine; they cannot make a part the
committed chunk, and a store's statement of `held` is checked against the manifest's
chunks. The region pin binds where the store keeps the master, as the room's policy
requires (live-rooms §3.7).
- A consent is the subject's act, or a guardian's for a minor, and revocable through the
key log as a grant is. `publishable` reads the head the caller gives: a revocation the
head does not yet list is not seen. A minor is marked by the room authority in the
device's grant, and by the record itself, so a minor's consent signed by the minor
alone does not stand.
- A take links the device's key, the principal it captures for and every subject's
consent. A manifest names subjects by key; who a key is stays with the room authority
and the store that keep the master. The master is personal data of everyone it records:
the region pin and the room's `retention_days` (live-rooms §3.7) apply to it, and a
consent's `purposes` and `exp` bound what may be done with it.

View file

@ -2864,7 +2864,7 @@ is a JSON object with exactly these members, under §2.4.3's encodings:
| `root` | 32 B hex | The Ed25519 key that signs this revision. |
| `next_root` | 32 B hex | The commitment (below) to the key that may sign as `root` after this one; never the commitment to `root`. |
| `keys` | array | The key entries, below. |
| `revoked_modules` | array of 32 B hex | The SHA-256 digests of the modules and pinned parameter sets the origin has revoked (§7.9), none twice. |
| `revoked_modules` | array of 32 B hex | The SHA-256 digests of the modules and pinned parameter sets the origin has revoked (§7.9), of the room grants and viewers it has revoked ([live-rooms](live-rooms.md) §5), and of the consent records withdrawn in its rooms ([capture-binding](capture-binding.md) §8.3), none twice. |
| `grant_max_s` | integer | The longest module grant, in seconds, the origin issues: at least 1 and at most §7.9's ceiling. |
| `status_max_age_s` | integer | How long, in seconds, a key status stays fresh: at least 1 and at most §7.9's ceiling. |
| `sig` | 64 B hex | `root`'s signature, below. |
@ -3113,6 +3113,8 @@ A confirmation needs every one of them:
| the energy figure of a `reconstruct-session` claim whose `energy` is not `null` | `reconstruct-session`, and `energy/` followed by `energy.energy_provenance` |
| a receipt of another WAI family that signs its own `signer_pubkey` and `signer_id` | `receipt`; and `energy/<class>` for each class its figures carry a label for |
| a module grant (§7.9) | `module/decoder` or `module/reconstruct`, by its `form` |
| a room descriptor or room grant ([live-rooms](live-rooms.md) §3, §4) | `live/room` |
| an upload receipt of a take ([capture-binding](capture-binding.md) §6) | `capture/store` |
A class registered in §2.4.4 registers its authority here with it, and a
family that needs a narrower authority than `receipt` registers one here.
@ -3209,6 +3211,12 @@ its keys is that party is the origin's decision, and its log records it:
names; `module/reconstruct` lets it grant a reconstruct module (§7.9).
Neither is pinned-only; §7.8 says what a module authorised under a
fetched anchor is.
- **Rooms.** `live/room` lets a key act as a room authority: sign room descriptors and
the grants under them ([live-rooms](live-rooms.md) §5). It is not pinned-only, and it
is not `delivery`.
- **Stores.** `capture/store` lets a key act as a store a room's takes are uploaded to:
sign the upload receipts of a take ([capture-binding](capture-binding.md) §6.4). It is
not pinned-only, and it is not `delivery`.
- **Pre-emption** ([unidirectional-delivery](unidirectional-delivery.md)
§3), relay attestation (§4.3) other than a `delivery-gap` claim, and
reconciliation have no class and so no authority. When one is registered for pre-emption it is pinned-only,
@ -3233,6 +3241,10 @@ its keys is that party is the origin's decision, and its log records it:
- **A module or a pinned parameter set** is revoked by adding its digest
to `revoked_modules` (§7.9). A revocation, like a key's, stays in every
later revision.
- **A room grant or a viewer** is revoked by adding the grant's digest, or the viewer's
tag, to `revoked_modules` ([live-rooms](live-rooms.md) §5); **a consent record** is
withdrawn by adding its digest there ([capture-binding](capture-binding.md) §8.3). The
list holds digests of each kind under one rule: append-only, none twice.
- **The root** is pre-committed: each revision commits to the next root,
and only a rotation changes the commitment. Whoever steals the root key
in use can sign revisions that add keys, but can neither rotate nor

View file

@ -0,0 +1,675 @@
# WAI Extension: Live Events
> Status: Draft. Registers the record `wai.live.events`: a signed, timeline-bound event
> track. Timed metadata, ad and chapter cues, chat, reactions, polls, markers,
> remote-control commands, captions, moderation decisions and room lifecycle are all
> *events*, and an event is one record, `WEV1`: signed by its principal, hash-chained to
> that principal's previous event, stamped with the room clock, and optionally anchored to
> one media object. The same bytes ride an MSF event timeline over MoQ, an
> `EXT-X-DATERANGE` tag, a CMAF `emsg` box, a WebRTC data channel and a recording. No new
> transport: WAI defines none by charter, and this extension does not change that. Field
> semantics and rules over them only. The reference implementation is the `live_events`
> module of `wai-rs` (feature `live_events`); the corpus is
> [`live-conformance/events/`](../live-conformance/events/), and its second implementation
> is `live-conformance/live_events_verify.py`.
> Keywords **MUST**, **MUST NOT**, **SHOULD**, **MAY** are RFC 2119/8174.
>
> Drafts read for this revision: `draft-ietf-moq-msf-01` (MSF),
> `draft-wilaw-moq-scte35-event-timeline-00`, `draft-wilaw-moq-webvtt-msf-00`,
> `draft-jennings-moq-mocha-chat-00` and `draft-jennings-moq-mocha-reactions-00`, all
> Internet-Drafts, none past Working Group Last Call; with RFC 8216 §4.3.2.7 and AOM
> id3-emsg v1.0.0. A section number of one of those drafts in this text is that
> revision's.
## 1. Scope
In scope: the event record and its digest and signature (§3); the kinds and their
payloads (§4); the event policy a verifier holds each principal to (§5); verification of
one record and of each principal's chain, with a delivery gap told apart from corruption
(§6); poll tallies as `wai.feed.state` cells (§7); and the field mappings onto MoQ, LL-HLS,
CMAF and DASH, WebRTC and a recording, with what a subscriber joining mid-stream receives
(§8).
Out of scope: a transport; the room descriptor, which fixes a room's id and its clock, and
the room grant, which carries a principal's policy (both belong to a later extension);
the contents of a `take_commit` (§4.12), which [capture binding](capture-binding.md)
§5 defines; moderation
workflow; and how a sink renders any event.
Relationship to the core spec: `wai.live.events` is a **record** ([SPEC](../SPEC.md) §5):
a signed record a WAI tool emits and verifies. It names no payload format and is not
dispatchable.
## 2. Terms
- **Room**: a multi-party live session. Its id is 32 bytes. The room extension defines it
as the digest of the signed room descriptor; here it is opaque, and a verifier is given
it.
- **Room clock**: microseconds since the room's `t0`, a UTC instant the room fixes. A
binding that needs a wallclock is given `t0` as microseconds since 1970-01-01T00:00:00Z;
one that needs a media time is given the presentation time of `t0`.
- **Principal**: an Ed25519 public key (RFC 8032), 32 bytes. Its `did:key` form names the
same key.
- **Event**, **record**: one `WEV1` (§3).
- **Chain**: one principal's events in one room, ordered by `seq`.
- **Head**: the event of a chain a verifier accepted last.
- **Stream order**: the order in which a verifier is offered one principal's records. Each
binding says what it is (§8).
## 3. The event record
### 3.1 Layout
Integers are little-endian.
| offset | size | field | meaning |
|---|---|---|---|
| 0 | 4 | `magic` | `WEV1` (ASCII) |
| 4 | 1 | `version` | 1 in this revision |
| 5 | 32 | `room` | the room's id |
| 37 | 32 | `principal` | the author's Ed25519 public key |
| 69 | 8 | `seq` | u64: the event's position in its chain, from 0 |
| 77 | 32 | `prev` | the digest (§3.2) of the chain's event `seq − 1`; 32 zero bytes when `seq` is 0 |
| 109 | 8 | `t` | u64: the room clock at the event |
| 117 | 1 | `anchor_flag` | 0: no anchor; 1: an anchor follows |
| 118 | 48 | `anchor` | only when `anchor_flag` is 1: `track` (32 bytes), `group` u64, `object` u64 |
| *a* | 1 | `kind` | §4 |
| *a* + 1 | 2 | `payload_len` | u16 |
| *a* + 3 | `payload_len` | `payload` | §4 |
| *a* + 3 + `payload_len` | 64 | `sig` | §3.2 |
*a* is 118 without an anchor and 166 with one.
An **anchor** binds an event to one MoQ object, so that a marker, a cue or a caption lands
on an exact frame. `track` is SHA-256 over the track's namespace as its MSF catalog entry
has it (its own, or the catalog track's that it inherits, MSF §5.2.2), one zero byte, and
the track's name; `group` and `object` are the object's MOQT Group ID and Object ID. An
anchor names an object on MoQ; the other bindings carry it unchanged.
`payload_len` lets a payload be up to 65535 bytes, the format's limit; the bound a
verifier applies is the policy's `max_payload` (§5).
### 3.2 Digest and signature
The **digest** of an event is SHA-256 (FIPS 180-4) over its record from `magic` through
`payload`: every byte but `sig`. It is written as 64 lowercase hexadecimal digits. An event
is named by its digest wherever another event refers to it: `prev`, a vote's poll, a
reaction's or a decision's target.
The **signature** is Ed25519 (RFC 8032, pure Ed25519) by `principal`, over the 15 bytes
`wai:live-event` (ASCII) and 0x01, followed by the bytes the digest covers. It is verified
strictly, as [SPEC](../SPEC.md) §7 *Signatures* requires: a key that fails step 1 or 2 is
`bad_key`, and a signature that fails a later step is `bad_signature`.
### 3.3 Reading a record
A reader reads exactly the bytes it is given as one record, in the order of §3.1, and
refuses at the first of these that holds:
1. Fewer than 4 bytes: `truncated`. A magic other than `WEV1`: `malformed`.
2. No byte after the magic: `truncated`. A version other than 1: `unknown_version`. A
reader reads no further field, since a later version's layout is its own.
3. The bytes end before a field the layout requires: `truncated`. An `anchor_flag` other
than 0 or 1, when it is read: `malformed`.
4. Bytes after `sig`: `malformed`.
## 4. Kinds and payloads
| code | kind | payload |
|---|---|---|
| `0x01` | `meta` | §4.2 |
| `0x02` | `cue` | §4.3 |
| `0x03` | `chat` | §4.4 |
| `0x04` | `reaction` | §4.5 |
| `0x05` | `poll_open` | §4.6 |
| `0x06`, `0x07` | `vote`, `poll_close` | §4.7 |
| `0x08` | `marker` | §4.8 |
| `0x09` | `control` | §4.9 |
| `0x0A` | `caption` | §4.10 |
| `0x0B` | `decision` | §4.11 |
| `0x0C` | `take_commit` | §4.12 |
| `0x10`–`0x16` | `join`, `leave`, `publish`, `unpublish`, `grant_exchange`, `takeover`, `failover` | §4.13 |
No other code is defined in this revision; a record of another kind is refused
`unknown_kind` (§6.1). Codes `0x17` to `0x1F` are reserved for lifecycle kinds.
A payload is read in the order its fields are written, and each field's rule applies when
it is read. A payload that ends before a field, that holds bytes after its last field, or
whose field holds a value this section does not allow, is `malformed_payload`; a text
field's own rules are §4.1's. Integers are little-endian. A digest field is 32 bytes.
### 4.1 Text
A text field is a u16 length followed by that many bytes. Its rules, in order:
1. The bytes are UTF-8 (RFC 3629): no overlong form, no surrogate, nothing above U+10FFFF.
Otherwise `bad_utf8`.
2. They hold at most the policy's `max_text_cp` code points (§5; 500 by default).
Otherwise `text_too_long`.
Each text field is bounded on its own. Empty text is allowed.
### 4.2 `meta`
Opaque application metadata: the whole payload, read for its length alone. It is the timed
metadata a player hands to its application.
### 4.3 `cue`
| field | size | meaning |
|---|---|---|
| `type` | u8 | 1 out (leave the program for a break), 2 in (return to it), 3 chapter, 4 break (out and back, its duration known) |
| `cue_id` | u32 | ties an out to its in; an SCTE-35 `splice_event_id` where the message has one |
| `duration_ms` | u32 | out and break: the planned duration; in: the actual duration since its out; chapter: its length; 0 when not known |
| `form` | u8 | 0: no SCTE-35 message; 1: an SCTE 35-1 `splice_info_section()`; 2: an SCTE 35-2 event-based signaling message |
| `scte_len` | u16 | only when `form` is not 0 |
| `scte` | `scte_len` | the message, verbatim |
Form 1 is read only as far as this: `table_id` is 0xFC, and `section_length` (the low 12
bits of bytes 1 and 2) plus 3 is `scte_len`. WAI reads no command, descriptor or CRC of
it. Form 2 is at least one byte, carried opaque. A `type` or `form` not in the table is
`malformed_payload`. The summary (`type`, `cue_id`, `duration_ms`) is what a sink acts on
without parsing SCTE-35. Where a message is carried too, both describe one splice, and a
producer **MUST NOT** write a summary its message contradicts.
### 4.4 `chat`
One text field: the message.
### 4.5 `reaction`
| field | size | meaning |
|---|---|---|
| `type` | u8 | 1 emoji, 2 vote, 3 raise, 4 applause, 5 custom: the reaction types of `draft-jennings-moq-mocha-reactions-00` §4.4 |
| `value` | u16 | not 0: for emoji and raise, that draft's §4.5 integer encodings; for the others, the application's |
| `flags` | u8 | bit 0: a target follows; bit 1: this retracts the reaction it repeats; every other bit 0 |
| `target` | 32 | only when bit 0 is set: the digest of the event reacted to |
A reaction without a target is **standalone**. Standalone reactions are held to the
policy's reaction rate (§6.3), as that draft holds them to one per second per device
(§5.1 there). A room's polls use `poll_open` and `vote`, not reaction type 2.
### 4.6 `poll_open`
`question` (text), `n` (u8, at least 2), then `n` option texts. A poll is named by the
digest of its `poll_open`.
### 4.7 `vote` and `poll_close`
- `vote`: `poll` (a poll's digest), `option` (u8).
- `poll_close`: `poll`, `n` (u8, at least 2), then `n` counts (u32 each): the author's
statement of the final tally.
§7 says which votes count, which `poll_close` closes a poll, and when a stated tally is
checked.
### 4.8 `marker`
| field | size | meaning |
|---|---|---|
| `taxonomy` | u32 | what the marker observes, in the application's taxonomy |
| `polarity` | u8 | 0 neutral, 1 positive, 2 negative |
| `text` | text | a note |
| `has_attachment` | u8 | 0 or 1 |
| `attachment` | 32 | only when `has_attachment` is 1: a digest naming an attached object, such as a voice note sealed as a WAI envelope ([jwp-receipts](jwp-receipts.md) §2.1 content hash) |
### 4.9 `control`
`target` (the principal the command is for), `command` (u8), then the command's argument:
| `command` | name | argument |
|---|---|---|
| 1 | `record_start` | `count_in_ms` u32: recording starts this long after `t` |
| 2 | `record_stop` | none |
| 3 | `zoom` | `q16` u32: the zoom factor in Q16.16, not 0 |
| 4 | `mute` | `on` u8: 1 mute, 0 unmute |
| 5 | `layer_request` | `layer` u8: the layer asked for |
A control event takes effect at its `t` on the room clock, so a count-in lines up for every
participant. Whether the target acts on it is the target's; the event records that it was
asked, by whom and when.
### 4.10 `caption`
| field | size | meaning |
|---|---|---|
| `duration_ms` | u32 | how long the caption shows; 0 when not known |
| `origin` | u8 | 1 human, 2 machine, 3 machine-reviewed |
| `model` | 32 | only when `origin` is 2 or 3: a digest naming the model that produced the text |
| `lang_len` | u8 | |
| `lang` | `lang_len` | a BCP 47 language tag (RFC 5646), in ASCII |
| `text` | text | the caption |
`lang` is read here only as far as this: 2 to 35 characters; a first subtag of 2 to 8
letters; then subtags of 1 to 8 letters or digits, each after one hyphen. A tag that fails
is `malformed_payload`. `origin` is the event's machine-generation disclosure: a sink that
labels machine-generated content, in its player, in a playlist characteristic or in a
provenance assertion, takes the label from it.
### 4.11 `decision`
`verdict` (u8: 1 allow, 2 deny, 3 redact), `target` (the digest of the event decided on),
`reason` (u16: a reason code the room defines). A decision is an event of the principal
that decided, and the target stays in its chain unchanged. A sink **SHOULD NOT** show
content a decision of a principal the room authorises to moderate denies or redacts.
### 4.12 `take_commit`
Read here for its length alone, which `max_payload` bounds: a room's verifier carries it
opaque. [Capture binding](capture-binding.md) §5 defines its contents, a device's rolling
commitment to the master it is capturing, and a take verifier reads them.
### 4.13 Lifecycle
| code | kind |
|---|---|
| `0x10` | `join` |
| `0x11` | `leave` |
| `0x12` | `publish` |
| `0x13` | `unpublish` |
| `0x14` | `grant_exchange` |
| `0x15` | `takeover` |
| `0x16` | `failover` |
Payload: `subject` (the principal the event is about), then `detail`, the rest of the
payload, opaque in this revision: a track name, or the digest of a grant. The room
extension defines `detail` for each kind. A room authority, or a relay acting for it,
authors these; the default policy permits them to no one (§5).
## 5. The event policy
An event policy states what one principal may send. A verifier holds every principal to a
policy: a default one, and one of the principal's own where it has one. In this revision
the verifier's caller gives it the policies. The room grant of a later extension carries
these same fields, per principal.
The policy record, `WEP1`, is 65 bytes; integers are little-endian.
| offset | size | field | meaning | default |
|---|---|---|---|---|
| 0 | 4 | `magic` | `WEP1` (ASCII) | |
| 4 | 1 | `version` | 1 in this revision | |
| 5 | 32 | `kinds` | kind *k* is permitted when bit *k* mod 8 of byte *k* div 8 is set | every kind of §4 but the lifecycle kinds |
| 37 | 2 | `max_payload` | the longest payload, in bytes | 1024 |
| 39 | 2 | `max_text_cp` | the most code points in one text field | 500 |
| 41 | 4 | `max_events` | the most events in one window (§6.3) | 50 |
| 45 | 8 | `window_us` | the window, in microseconds of room clock | 1 000 000 |
| 53 | 4 | `max_reactions` | the most standalone reactions in one reaction window | 1 |
| 57 | 8 | `reaction_window_us` | the reaction window | 1 000 000 |
A reader refuses a policy record: with fewer than 4 bytes, `truncated`; with another magic,
`policy_malformed`; with no version byte, `truncated`; with another version,
`unknown_version`; shorter than 65 bytes, `truncated`; longer, `policy_malformed`; that
permits a kind §4 does not define, or in which any bound is 0, `policy_malformed`.
Every bound this extension gives a number for is one of these defaults, and a room
**MAY** set any other. A room authority's policy is the default with the lifecycle kinds
also permitted.
## 6. Verification
A verifier verifies one room's records, against the room id it is given. It is offered
records one at a time, each principal's in stream order. A record it refuses changes
nothing it holds.
### 6.1 One record
In this order; the first check that fails names the refusal:
1. The record reads (§3.3): `truncated`, `malformed` or `unknown_version`.
2. Its `kind` is one §4 defines: otherwise `unknown_kind`.
3. Its `room` is the verifier's room: otherwise `wrong_room`.
4. Its signature verifies (§3.2): otherwise `bad_key` or `bad_signature`.
5. Its principal's policy permits its kind: otherwise `kind_not_permitted`.
6. `payload_len` is at most the policy's `max_payload`: otherwise `payload_too_large`.
7. Its payload reads (§4), with the policy's `max_text_cp`: otherwise `malformed_payload`,
`bad_utf8` or `text_too_long`.
### 6.2 A principal's chain
For each principal the verifier keeps the digests of the events it holds, by `seq`, and
the chain's head. For a record that passed §6.1:
1. If the verifier holds an event with this `seq` and this digest, the record is a
**duplicate**: it is ignored, counted nowhere, and checked no further. The records a
group repeats at its start (§8.2) are duplicates to a subscriber that holds them.
2. With a head:
- `seq` at or below the head's: `seq_replay` when an event with this `seq` is held (a
second, different event claims a `seq` already used), and `seq_not_increasing`
otherwise (it arrives behind the head);
- `seq` one above the head's: `prev` **MUST** be the head's digest, otherwise
`chain_broken`;
- `seq` more than one above the head's: the seqs between are a gap;
- in each of the last two cases, `t` **MUST NOT** be below the head's `t`, otherwise
`time_reversed`.
3. Without a head, the first record of the principal the verifier accepts: when `seq` is
0, `prev` **MUST** be 32 zero bytes, otherwise `chain_broken`; when `seq` is above 0,
seqs 0 to `seq − 1` are a gap.
Then §6.3. An event accepted becomes the head, and a gap it opens is recorded.
A gap is a **detectable delivery gap**, reported with the code `delivery_gap`. The
verifier knows exactly which seqs it does not hold, and that the events it does hold are
the principal's, in order. A gap is not a refusal, and a verifier **MUST NOT** report it as
corruption. A receiver **MAY** fill a gap by fetching the events it lacks (MOQT FETCH of
earlier groups, or a recording) and verifying the chain again from its start. It **MUST
NOT** splice an event behind its head into a chain it has moved past: that record is
refused `seq_not_increasing`, by step 2.
### 6.3 Rates
Over the events of the principal the verifier has accepted (a duplicate or a refused
record is not counted):
1. Counting this event, more than `max_events` events whose `t` is less than `window_us`
before this event's `t`: `rate_exceeded`.
2. For a standalone reaction (§4.5), counting it, more than `max_reactions` standalone
reactions whose `t` is less than `reaction_window_us` before its `t`:
`rate_exceeded`.
An earlier event at `t_f` is within a window `w` of an event at `t` when `t − t_f < w`:
two events exactly one window apart do not share one. A verifier with a gap counts only
what it holds; one that holds the whole chain checks the rate exactly.
### 6.4 Outcomes
Verifying a sequence of records gives one outcome:
- **refuse**: the reason and the index of the first record refused. The verifier stops
there.
- **gap**: no record was refused, and the verifier does not hold every seq of every chain
it saw. It states each gap as the principal and the first and last seq it lacks, in the
order found, under the code `delivery_gap`.
- **accept**: no record was refused, there is no gap, and every stated tally equals the
replay (§7). A stated tally that does not is **refuse**, `tally_mismatch`, at the index
of its `poll_close`.
## 7. Polls replay as feed state
A poll's tally is a record of `wai.feed.state` cells ([state-feeds](state-feeds.md)): the
state an op log of `wai.world.sim.feed0` replays to. A tally is therefore
replay-equivalent, and a tally an author states is checkable.
Over the events a verifier accepted:
- A **poll** is a `poll_open`. Its **close** is the `poll_close` naming it with the lowest
`seq` among those its author wrote. A `poll_close` by any other principal closes
nothing.
- A `vote` **counts** when the verifier holds its poll, its `option` is below the poll's
`n`, its `t` is at or after the `poll_open`'s `t`, and the poll has no close or the
vote's `t` is before the close's `t`.
- A principal's vote in a poll is its last vote that counts, by `seq`; an earlier one is
replaced.
The feed: each poll is the node `mint_node_id(author, seq)` of its `poll_open`
([interactive-worlds](interactive-worlds.md) §5.4), whose record is the `Int` cells
`state, count_0, …, count_{n−1}`. Its ops are:
- `CREATE` with `Int(1)` and `n` cells `Int(0)`, the actor the poll's author and the
Lamport counter the `poll_open`'s `seq`;
- `ADD_INT(1 + option, 1)` for each principal's vote;
- `SET_CELL(0, Int(0))` when the poll has a close.
Every verifier derives these ops from signed events; they are not sent and not signed. A
vote one principal changes is, equally, `ADD_INT −1` on the old option and `+1` on the new,
and the replay reaches the same state. The tally's state hash is feed0's,
`BLAKE3("wai:world-state\x01" ‖ canonical state bytes)` ([state-feeds](state-feeds.md)
§4), over every poll the verifier holds.
A `poll_close`'s counts are its author's statement. A verifier that holds every chain
whole (no gap) checks each close: its `n` equals the poll's, and each count equals the
votes for that option. Otherwise it refuses with `tally_mismatch`, at the lowest index of
such a close. With a gap a verifier does not make this check: a tally it computes is
unchecked.
## 8. Bindings
Field semantics only. Each binding says how the record is carried, what it derives from
the record, its stream order (§6), and how a reader refuses what it cannot read
(`binding_malformed`) or what disagrees with the record it carries (`binding_mismatch`).
The record a reader takes from a binding is then verified by §6.
### 8.1 MoQ: an MSF event timeline
- Each principal publishes its events on a track of its own, as
`draft-jennings-moq-mocha-chat-00` §3.3 has each device publish on its own track. A
chain and a track are then one unit, and a relay fans tracks out without merging them.
The room extension names the tracks.
- The track is an MSF event timeline (MSF §8). Its catalog entry has `packaging`
`"eventtimeline"` (MSF §5.2.4), `eventType` `"urn:wai:event:1"` (MSF §5.2.5), `mimetype`
`"application/json"`, and `depends` naming the tracks its anchors name (MSF §8.2).
- A **record** of the timeline (MSF §8.1) has one index member and `data`:
- an anchored event whose `group` and `object` are each at most 2^53 − 1 has
`"l": [group, object]`, the MOQT Location of the anchored object;
- any other event has `"t"`: `⌊(t0 + t) / 1000⌋`, the wallclock in milliseconds since
1970 (MSF §7.1.1), which **MUST** be at most 2^53 − 1;
- `data` is `{"wev1": "<base64url>"}`, the record in base64url without padding
(RFC 4648 §5).
It is written `{"t":<t>,"data":{"wev1":"<b64>"}}` or
`{"l":[<group>,<object>],"data":{"wev1":"<b64>"}}`, with no whitespace. This binding
never writes `m`: an event has no media timestamp of its own, and an anchor names its
object exactly. An events track uses `t` and `l` record by record, as
`draft-wilaw-moq-scte35-event-timeline-00` §4.2 also does; MSF §8.1's preference for
one index type is a **SHOULD**.
- A reader parses the record as JSON and ignores members this binding does not define.
It refuses as `binding_malformed`: anything but a JSON object; no string `data.wev1`;
one that is not the canonical base64url of its bytes (the RFC 4648 §5 alphabet, no `=`,
and the encoding of what it decodes to); bytes that §3.3 does not read as a record of a
kind §4 defines; and a record with other than exactly one of `t`, `l` and `m`. It refuses
as `binding_mismatch` an index other than the one this binding writes for the event:
`t` a JSON integer equal to the event's, or `l` an array of exactly two integers equal
to its anchor's.
- **Groups.** An event's group is `⌊t / 60 000 000⌋`, the minute of room clock it falls
in, as `draft-jennings-moq-mocha-chat-00` §3.4 groups messages by minute. Group IDs
increase with `t`, since `t` never decreases along a chain. Object 0 of a group is the
independent object of MSF §8.3: a JSON array of the join set (§8.2) at the group's
start. Each event of the group is then the next object, an array of its one record, an
incremental update (MSF §8.3). MSF §8.3 has the independent object hold every record
"accumulated and accessible" so far. For an events track the records accessible at a
group boundary are its join set; earlier records stay reachable by FETCH of earlier
groups.
- **Stream order** is Location order: by group, then by object, and the records of one
object in the order written.
- **Cues.** A publisher **MAY** also publish each cue of form 1 on a timeline of
`draft-wilaw-moq-scte35-event-timeline-00`, its `scte35_payload` the base64 (RFC 4648
§4) of the cue's section and its index chosen by that draft's §4.2, so that a consumer
of SCTE-35 alone reads it unchanged. That draft registers the event type
`urn:scte:scte35:2022:bin`, and MSF §14.2's initial table spells it
`urn:scte:scte35:2013:bin`; this revision takes no position between the two.
- **Captions.** A publisher **MAY** also publish each caption as a cue of
`draft-wilaw-moq-webvtt-msf-00` on a timeline of its own. This revision defines no
mapping between the two.
- **Registration.** WAI requests `urn:wai:event:1` in the "MSF Event Timeline Types"
registry (MSF §14.2, Expert Review). The URN is the form of that registry's entries,
and it is also this extension's `emsg` scheme (§8.4). Until it is registered it is a
custom type in MSF §10.5's sense.
### 8.2 Joining mid-stream
The **join set** of a principal's track at the start of group *g* (room clock *g* × 60 s)
is drawn from the principal's events whose `t` is before that instant. In `seq` order it
holds:
- every `poll_open` whose poll has no `poll_close` of its author before the instant
(**MUST**);
- in each of those polls, the principal's last vote (**MUST**);
- every `caption` with a `duration_ms` above 0 whose interval, `t` to
`t + duration_ms`, contains the instant (**MUST**);
- every `cue` of type out or break with no later `in` of the same `cue_id` (**MUST**);
- any other event (**MAY**).
The records are repeated byte for byte, so each is a duplicate (§6.2 step 1) to a
subscriber that holds it and the state a joining subscriber starts from. This is how
`draft-wilaw-moq-webvtt-msf-00` §5 has the first object of each group carry the cues
accumulated so far. A subscriber that joins verifies from the first record it holds and
reports what it lacks as gaps (§6.2), so it knows a tally it computes is unchecked. It
learns a final tally from the poll author's `poll_close`, or checks one by fetching the
voters' earlier groups.
### 8.3 LL-HLS: `EXT-X-DATERANGE`
An event in a Media Playlist is one `EXT-X-DATERANGE` tag (RFC 8216 §4.3.2.7, which
low-latency HLS keeps). Its attributes are written in this order, separated by commas with
no spaces:
| attribute | when | value |
|---|---|---|
| `ID` | always | quoted: the event's digest, 64 lowercase hexadecimal digits |
| `CLASS` | always | quoted: `science.transaction.wai.event` |
| `START-DATE` | always | quoted: `t0 + t` as `YYYY-MM-DDTHH:MM:SS.mmmZ`, in UTC, the milliseconds floored, always three digits after the point |
| `DURATION` | a cue of type in or chapter, or a caption, with `duration_ms` above 0 | `duration_ms` as decimal seconds with three digits after the point (`30.000`) |
| `PLANNED-DURATION` | a cue of type out or break with `duration_ms` above 0 | as `DURATION` |
| `SCTE35-OUT`, `SCTE35-IN`, `SCTE35-CMD` | a cue of form 1 | `0x` and the section in uppercase hexadecimal: `SCTE35-OUT` for out and break, `SCTE35-IN` for in, `SCTE35-CMD` for chapter (RFC 8216 §4.3.2.7.1) |
| `X-WAI-EVENT` | always | quoted: the record in base64url without padding |
- RFC 8216 §4.3.2.7.1 represents a splice out/in pair with tags that share one `ID`. Here
each tag carries an event of its own under its own `ID`, because two tags of a playlist
that share an `ID` must agree on every attribute both carry, and their `X-WAI-EVENT`
values differ. `cue_id` ties the pair.
- RFC 8216 asks a client attribute's name to follow reverse-DNS form. `X-WAI-EVENT`
appears only under its `CLASS`, whose semantics every date range of that class follows
(RFC 8216 §4.3.2.7).
- A playlist that carries one of these tags **MUST** carry `EXT-X-PROGRAM-DATE-TIME`
(RFC 8216 §4.3.2.7), on the same UTC timeline as `t0`.
- **Stream order** is the order of the tags in the playlist, across its updates.
- A reader takes the tag as `#EXT-X-DATERANGE:` and an attribute list (RFC 8216 §4.2):
attribute names of `A`–`Z`, `0`–`9` and `-`, each at most once, and a quoted value that
runs to the next quote. It refuses as `binding_malformed`: another tag; an attribute list
that does not read; no `X-WAI-EVENT`, one not quoted, or one that is not canonical
base64url (§8.1); and bytes that §3.3 does not read as a record of a kind §4 defines. It
then renders the tag for that record and refuses as `binding_mismatch` unless each
attribute of the table above is, in the tag and in the rendering, either absent from
both or present in both with the same value as written. Attributes the table does not
name are ignored, and their order is not compared.
### 8.4 CMAF and DASH: `emsg` version 1
An event is one Event Message box, version 1 (ISO/IEC 23009-1, the syntax AOM id3-emsg
v1.0.0 §2.2.2 reproduces). Integers are big-endian:
| field | value |
|---|---|
| `size` | the box's length in bytes |
| `type` | `emsg` |
| `version`, `flags` | 1, 0 |
| `timescale` | 1000 |
| `presentation_time` | u64: `pt0 + ⌊t / 1000⌋`, where `pt0` is the presentation time of `t0` in milliseconds on the track's timeline |
| `event_duration` | a cue or a caption: `duration_ms`, or 0xFFFFFFFF (not known) when it is 0; any other kind: 0 |
| `id` | the first four bytes of the event's digest, as a u32 |
| `scheme_id_uri` | `urn:wai:event:1`, then a zero byte |
| `value` | the kind's name (§4), then a zero byte |
| `message_data` | the record |
- `id` lets a player drop a box it has seen. A receiver identifies an event by its digest
(§6.2), since four bytes of a digest can repeat.
- **Stream order** is presentation order: the order of the boxes in the segments, by
`presentation_time`, and within one time in the order the segment holds them.
- A reader refuses as `binding_malformed`: a box shorter than 32 bytes, whose `size` is
not its length, whose type is not `emsg` or whose version is not 1; a box without two
strings each ended by a zero byte; and `message_data` that §3.3 does not read as a
record of a kind §4 defines. It then renders the box for that record and refuses as
`binding_mismatch` unless the two are equal byte for byte.
- A publisher whose players surface only ID3 timed metadata **MAY** also carry the record
in ID3 as AOM id3-emsg v1.0.0 has it (scheme `https://aomedia.org/emsg/ID3`). This
revision defines no ID3 frame for it.
### 8.5 WebRTC
A data channel (RFC 8831), reliable and ordered, with binary messages. One message is one
record, whole. **Stream order** is message order. A sink that needs an event's media time
takes it from the anchor or from the room clock; the RTP absolute capture time header
extension, where a sender adds it, is experimental.
### 8.6 Recording
A recording holds each principal's chain whole: its records concatenated in `seq` order,
each record's length following from its own fields (§3.1). **Stream order** is that order.
In a sealed package the events are one component; packaging them is the recording
extension's, a later one. A recording verifies with the outcome accept, or states its gaps.
## 9. Reasons
| reason | raised at |
|---|---|
| `truncated` | §3.3, §5 |
| `malformed` | §3.3 |
| `unknown_version` | §3.3, §5 |
| `unknown_kind` | §6.1 step 2 |
| `wrong_room` | §6.1 step 3 |
| `bad_key` | §3.2, §6.1 step 4 |
| `bad_signature` | §3.2, §6.1 step 4 |
| `kind_not_permitted` | §6.1 step 5 |
| `payload_too_large` | §6.1 step 6 |
| `malformed_payload` | §4, §6.1 step 7 |
| `bad_utf8` | §4.1 |
| `text_too_long` | §4.1 |
| `seq_replay` | §6.2 step 2 |
| `seq_not_increasing` | §6.2 step 2 |
| `chain_broken` | §6.2 steps 2 and 3 |
| `time_reversed` | §6.2 step 2 |
| `delivery_gap` | §6.2, §6.4: a gap, not a refusal |
| `rate_exceeded` | §6.3 |
| `tally_mismatch` | §7 |
| `policy_malformed` | §5 |
| `binding_malformed` | §8.1, §8.3, §8.4 |
| `binding_mismatch` | §8.1, §8.3, §8.4 |
## 10. Conformance
[`live-conformance/events/`](../live-conformance/events/) holds the corpus, in one file,
`vectors.json`, written from fixed seeds by `wai_live_events_vectors gen`. It has:
- **vectors**: a room, the policies its principals are held to, and records in stream
order, with the outcome §6.4 gives them. An accepted vector also states each record's
digest, the tally (§7) and each record's MSF record, `EXT-X-DATERANGE` tag and `emsg`
box (§8); a vector with a gap states its gaps. Every kind has an accepted vector, and
chains of several principals are interleaved in one. Every refusal of §6 has a vector
refused with it, each a control of one defect: a flipped signature bit and three
forgeries that a lenient Ed25519 verifier accepts, a broken chain, a reorder, a replayed
`seq`, `t` going back, an oversize payload, a kind not permitted, both rates exceeded,
invalid UTF-8 and overlong text, truncated and malformed records, an unknown version and
kind, the wrong room, malformed payloads and a stated tally the replay does not reach.
- **policy cases**: policy records, read (§5) or refused with their reason.
- **binding cases**: tags, boxes and MSF records, read back to their record (§8) or
refused with their reason.
A conforming verifier gives every vector, policy case and binding case the outcome, the
reason, the index, the gaps, the digests, the tally and the renderings the corpus states.
`wai_live_events_vectors verify` and the test `tests/live_events.rs` check that the file
regenerates byte for byte and that `wai-rs` gives every case its outcome.
`live-conformance/live_events_verify.py` is the second implementation, written from this
text: it shares no code with `wai-rs`, verifies signatures with WAI's strict rule written
from RFC 8032 (`claim-conformance/_ed25519.py`) and computes BLAKE3 from its specification
(`claim-conformance/_blake3.py`). It gives every case the same outcome.
## 11. Security and privacy considerations
- Every field of an event is signed. A relay can drop an event but cannot alter one, and
a drop is a gap the receiver detects (§6.2).
- `t` is its author's claim. A room clock authority and per-hop receipts, both of later
extensions, witness when an event was seen; this revision checks only that a chain's
`t` never goes back.
- Two different events with one `seq` are refused `seq_replay`. A receiver that holds
both holds two signatures by one key over one position: evidence the author
equivocated.
- Payloads are untrusted data: an SCTE-35 message, `meta`, a lifecycle `detail` and a
`take_commit` are carried, not interpreted, as
`draft-wilaw-moq-scte35-event-timeline-00` §6.2 has SCTE-35 payloads treated.
- The policy's payload, text and rate bounds limit how much one principal can make every
receiver hold and check.
- A principal's events are linkable to one another, since one key signs them all. A room
that needs participation that cannot be linked across rooms gives principals a key per
room. Events persist in recordings, and a `decision` that redacts an event does not
remove it from the chains that hold it (§4.11).
- Confidentiality is the carrying transport's, and end-to-end encryption of tracks is
[confidentiality](confidentiality.md)'s; this extension signs events and does not
encrypt them.
## Appendix A — Registration request
For the "MSF Event Timeline Types" registry (MSF §14.2):
| Event Type | Description | Specification |
|---|---|---|
| `urn:wai:event:1` | WAI live event records (`WEV1`), one per record, in `data.wev1` | this extension |

View file

@ -0,0 +1,799 @@
# WAI Extension: Live Rooms
> Status: Draft. Registers the record `wai.live.room`: the signed **room descriptor**
> that fixes a room, its id, its clock and what each role may do; and the signed **room
> grant** under which one principal takes part, carrying the event policy
> [live-events](live-events.md) §5 holds it to. A grant is exchanged for another at a tick
> of the room clock, and revoked through the room authority's key log
> ([jwp-receipts](jwp-receipts.md) §7). Tracks are named in the MoQ namespace tuple; a
> slot lets a publisher of higher precedence take a virtual track over, and a standby
> take it on failover; the publish profiles fix codecs, layers and limits as data. A
> room verifier replays every principal's events under the grant in effect at each
> instant of the room clock. No new transport and no new token format: the grant maps
> onto existing token formats (§10). The reference implementation is the `live_room`
> module of `wai-rs` (feature `live_room`); the corpus is
> [`live-conformance/rooms/`](../live-conformance/rooms/), and its second implementation
> is `live-conformance/live_rooms_verify.py`.
> Keywords **MUST**, **MUST NOT**, **SHOULD**, **MAY** are RFC 2119/8174.
>
> Drafts read for this revision: `draft-ietf-moq-transport-22` (MOQT),
> `draft-ietf-moq-msf-01` (MSF), `draft-ietf-moq-c4m-01` and
> `draft-ietf-moq-privacy-pass-auth-03`, all Internet-Drafts, none past Working Group
> Last Call; with RFC 9246, RFC 8392, and CTA-5007-B as the IANA "CBOR Web Token (CWT)
> Claims" registry lists its claims (keys 308 to 323). A section number of one of those
> drafts in this text is that revision's.
## 1. Scope
In scope: the room descriptor, its digest (the room id) and signature (§3); the room
grant, its checks, its attributes, how a receipt names it, and its exchange (§4);
revocation through the key log (§5); track naming (§6); precedence and redundancy over
slots, and the `detail` of every lifecycle kind of live-events §4.13 (§7); the publish
profiles (§8); room verification (§9); and the bindings onto token formats (§10).
Out of scope: a transport; how a principal obtains a grant; composition, which reads the
grant's attributes (a later extension); the review workflow, of which this extension
fixes only the policy's fields and their meaning (§3.6); and what a recording holds,
which the recording policy constrains (§3.7) and later extensions define: a device's
master recording is [capture-binding](capture-binding.md)'s take.
Relationship to the core spec: `wai.live.room` is a **record** ([SPEC](../SPEC.md) §5): a
signed record a WAI tool emits and verifies. It names no payload format and is not
dispatchable.
## 2. Terms
- **Room authority**: the Ed25519 key (RFC 8032) that signs a room's descriptor and its
grants.
- **Clock**: the key, named by the descriptor, that stamps the room clock and authors the
room's lifecycle events for the authority. It **MAY** be the authority's key.
- **Room key**: the authority's key or the clock's.
- **Room clock**, **principal**, **event**, **chain**: as in live-events §2.
- **Grant in effect**: for a present principal, the grant its latest `join` or
`grant_exchange` named (§9.3).
- **Slot**: a virtual track the descriptor declares, which one live publisher stands
behind at a time (§7.1).
- **Name**: 1 to 32 bytes of `a`–`z`, `0`–`9`, `_` and `-`. A **slot name** is 1 to 32
bytes of `a`–`z`, `0`–`9` and `_`. A **token** is 1 or more bytes of `!` to `~`
(0x21–0x7E).
- **Kinds bitmap**: 32 bytes in which kind *k* is set when bit *k* mod 8 of byte *k* div
8 is set, as live-events §5 writes `kinds`.
## 3. The room descriptor
### 3.1 Layout
Integers are little-endian. A *short* field is a u8 length and that many bytes; a *long*
field is a u16 length and that many bytes.
| field | size | meaning |
|---|---|---|
| `magic` | 4 | `WRD1` (ASCII) |
| `version` | 1 | 1 in this revision |
| `authority` | 32 | the room authority's Ed25519 key |
| `clock` | 32 | the clock's Ed25519 key |
| `t0` | 8 | u64: the room's `t0`, in microseconds since 1970-01-01T00:00:00Z |
| `tick` | 8 | u64: the room-clock tick, in microseconds, that a grant exchange takes effect on (§4.6) |
| `origin` | long | the origin whose key log lists the authority (§5); empty for none |
| `max_present` | 4 | u32: the most principals present at once (§3.5) |
| `max_publishers` | 2 | u16: the most principals publishing at once (§7.3) |
| `reval` | 4 | u32: the re-validation interval, in seconds, a token of the room carries (§10.2); 0 for none |
| `ceiling` | 65 | a `WEP1` record (live-events §5): the event ceiling no grant exceeds (§3.5) |
| `roles` | 1 + … | a u8 count, then each role: `name` (short), `publish` (u8), `subscribe` (u8), `max_layer` (u8), `powers` (u8), `events` (32, a kinds bitmap) (§3.3) |
| `slots` | 1 + … | a u8 count, then each slot: `name` (short), `media` (u8), `redundancy` (u8), `failover_ms` (u32) (§7.1) |
| `profiles` | 1 + … | a u8 count, then each publish profile (§8.1) |
| `review` | 37 | `kinds` (32, a kinds bitmap), `deadline_ms` (u32), `fallback` (u8) (§3.6) |
| `recording` | 12 + … | `mode` (u8), `retention_days` (u16), `commit_interval_ms` (u32), `jitter_ms` (u32), then a u8 count and that many 2-byte `regions` (§3.7) |
| `sig` | 64 | §3.2 |
### 3.2 Digest, room id and signature
The **digest** of a descriptor is SHA-256 (FIPS 180-4) over its record from `magic`
through `recording`: every byte but `sig`. It is the **room id**, the 32 bytes every event
of the room names (live-events §3.1).
The **signature** is Ed25519 by `authority` over the 14 bytes `wai:live-room` (ASCII) and
0x01, followed by the bytes the digest covers. It is verified strictly, as
[SPEC](../SPEC.md) §7 *Signatures* requires: a key that fails step 1 or 2 is `bad_key`,
and a signature that fails a later step is `bad_signature`.
### 3.3 Roles and capability sets
A **role** maps a name to a **capability set**:
- `publish`: bit *m* − 1 for each media kind *m* the role may publish: 1 `audio`, 2
`video`, 3 `screen` (§6.1).
- `subscribe` (0 or 1) and `max_layer`: whether the role may subscribe, and the highest
layer (§6.3) it may subscribe to; 0 when it may not subscribe.
- `events`: the event kinds it may send, a kinds bitmap within the ceiling's `kinds`.
- `powers`: bit 0 **moderate** (its `decision` events, live-events §4.11, are a
moderator's), bit 1 **direct** (it may send `control` commands other than recording),
bit 2 **compose** (it may author the room's composition, a later extension), bit 3
**record** (it may send `record_start` and `record_stop`).
A role whose `events` permit `decision` has the moderate power; one whose `events` permit
`control` has direct or record. A grant narrows its role's set and never widens it
(§4.3).
### 3.4 Reading and field rules
A reader reads exactly the bytes it is given as one descriptor, in the order of §3.1, and
refuses at the first of these that holds:
1. Fewer than 4 bytes: `truncated`. A magic other than `WRD1`: `malformed`. No byte after
the magic: `truncated`. A version other than 1: `unknown_version`.
2. The bytes end before a field the layout requires: `truncated`.
3. Bytes after `sig`: `malformed`.
It then holds the descriptor to these rules, and refuses one that breaks any of them as
`descriptor_invalid`:
- `tick` is above 0; `origin` is empty or a token of at most 255 bytes; `max_present` and
`max_publishers` are above 0.
- `ceiling` is a policy live-events §5 reads, and it permits no lifecycle kind (`0x10` to
`0x1F`).
- There are 1 to 16 roles, each named by a name no other role has, with `subscribe` 0 or
1, `publish` setting no bit above bit 2, `powers` none above bit 3, `max_layer` 0 when
`subscribe` is 0, `events` within the ceiling's `kinds`, and the powers §3.3 requires
of its `events`.
- There are at most 16 slots, each named by a slot name no other slot has, of a media
kind of §6.1, with `redundancy` 1 and `failover_ms` 0, or `redundancy` 2 and
`failover_ms` above 0.
- There are at most 16 profiles, each meeting §8.1 and named by a name no other profile
has. A profile whose name §8.2 registers is that registered profile, field for field.
- The review policy and the recording policy meet §3.6 and §3.7.
Then the signature (§3.2): `bad_key` or `bad_signature`.
### 3.5 Limits
- `max_present`: a `join` that would make more principals present than this is refused
`participant_limit` (§7.2).
- `max_publishers`: §7.3.
- `ceiling`: no grant's policy exceeds it (§4.3 step 8): its `max_payload`, `max_text_cp`,
`max_events` and `max_reactions` are at most the ceiling's, and its `window_us` and
`reaction_window_us` at least the ceiling's, so every window of the ceiling's length
holds no more events than the ceiling allows. The ceiling's `kinds` bound the roles'.
### 3.6 The review policy
`kinds` names the event kinds held for review; all zero, nothing is reviewed. An event of
a reviewed kind is shown by a sink only once a principal with the moderate power has sent
a `decision` on it (live-events §4.11), or once `deadline_ms` milliseconds of room clock
have passed after its `t` with none, when the `fallback` verdict applies: 1 allow, 2 deny.
With no reviewed kind, `deadline_ms` and `fallback` are 0; with one, the kinds are within
the ceiling's, `deadline_ms` is above 0, and `fallback` is 1 or 2. This revision fixes the
policy's fields and their meaning; the review workflow and its verification are a later
extension's.
### 3.7 The recording policy
- `mode`: 0 the room is never recorded; 1 it is recorded from a `record_start` command
(live-events §4.9) of a principal with the record power; 2 it is always recorded.
- `retention_days`: the most days a recording is kept; 0 states no bound.
- `commit_interval_ms` and `jitter_ms`: how often a device capturing a take commits to it
in the room's events, and the jitter its timing is allowed
([capture-binding](capture-binding.md) §5, §7). With `mode` 0 both are 0. Otherwise
`commit_interval_ms` is above 0, 2 000 (two seconds) by default, and `jitter_ms` is at
most `commit_interval_ms`, 500 by default.
- `regions`: the **region pin**, for data residency: ISO 3166-1 alpha-2 codes, each two
capitals `A`–`Z`, in ascending order, at most 16. A recording of the room, and the
media and events a relay keeps of it beyond delivery, **MUST** be stored only within
those regions; none means no pin. A recording's receipt states where it is stored; the
recording extension checks it against the pin.
`mode` is at most 2, and `commit_interval_ms` and `jitter_ms` keep the rule above.
## 4. The room grant
### 4.1 Layout
| field | size | meaning |
|---|---|---|
| `magic` | 4 | `WRG1` (ASCII) |
| `version` | 1 | 1 in this revision |
| `room` | 32 | the room id |
| `principal` | 32 | the Ed25519 key the grant is for |
| `prev` | 32 | the digest of the grant this one is exchanged for (§4.6); 32 zero bytes for a first grant |
| `effective` | 8 | u64: for an exchanged grant, the room-clock tick it takes effect at; 0 for a first grant |
| `nbf`, `exp` | 8 each | u64: seconds since 1970-01-01T00:00:00Z, as RFC 8392's NumericDate: the grant is good from `nbf` until before `exp` |
| `role` | 1 | the index of its role in the descriptor |
| `publish`, `subscribe`, `max_layer`, `powers` | 1 each | its capability set (§3.3), within its role's |
| `precedence` | 1 | its precedence on a slot (§7.1) |
| `once` | 16 | a single-use nonce: when it is not zero, no two `join`s name grants with this `once` (§7.2); zero, the grant may be used again within its window |
| `viewer` | 16 | the viewer id revocation names (§5); zero for none |
| `policy` | 65 | a `WEP1` record (live-events §5): the event kinds it may send, its payload and text bounds, and its rate windows |
| `joules_ceiling` | 8 | u64: the energy its use may spend, in microjoules; 2^64 − 1 for no ceiling |
| `funds_ceiling` | 8 | u64: the funds its use may spend, in minor units of `currency`; 2^64 − 1 for no ceiling |
| `currency` | 3 | ISO 4217 alphabetic code; three zero bytes when there is no funds ceiling |
| `profiles` | 1 + *n* | a u8 count, then the index in the descriptor of each publish profile it publishes with |
| `origins` | 1 + … | a u8 count, then each delivery origin it is good for (short): a URI's scheme and authority, such as `https://relay.example` |
| `attrs` | long | its attributes (§4.4) |
| `sig` | 64 | §4.2 |
### 4.2 Digest and signature
The **digest** of a grant is SHA-256 over its record from `magic` through `attrs`. The
**signature** is Ed25519 by the descriptor's `authority` over the 20 bytes
`wai:live-room-grant` (ASCII) and 0x01, followed by the bytes the digest covers, verified
strictly (SPEC §7 *Signatures*).
### 4.3 Checking the grants
A verifier is given a room's grants in a list. It checks each in the list's order and
refuses at the first check that fails, naming the grant by its index:
1. The record reads, as §3.4 steps 1 to 3 read a descriptor with `WRG1` for `WRD1`:
`truncated`, `malformed` or `unknown_version`.
2. Its signature verifies under the descriptor's `authority` (§4.2): `bad_signature`.
3. When `prev` is not zero, it is the digest of a grant earlier in the list
(`exchange_unlinked`), and that grant's `room` and `principal` are this one's
(`exchange_mismatch`).
4. Its `room` is the room id: `wrong_room`.
5. Its fields keep these rules, and `grant_invalid` refuses it otherwise:
- `exp` is above `nbf`;
- a first grant's `effective` is 0, and an exchanged grant's a multiple of the
descriptor's `tick`;
- `role` names a role; `subscribe` is 0 or 1; `publish` sets no bit above bit 2,
`powers` none above bit 3; `max_layer` is 0 when `subscribe` is 0;
- `policy` is a policy live-events §5 reads, permits no lifecycle kind, and the powers
§3.3 requires of its kinds are among `powers`;
- `funds_ceiling` is 2^64 − 1 exactly when `currency` is three zero bytes, and
`currency` is otherwise three capitals `A`–`Z`;
- each entry of `profiles` names a profile of the descriptor, of a media kind the
grant may publish, and no two name profiles of one media kind;
- there are at most 8 `origins`, each a token;
- `attrs` meets §4.4;
- its digest is no earlier grant's.
6. `exp` − `nbf` is at most 1 209 600 seconds, fourteen days: otherwise `grant_too_long`.
7. It is within its role (§3.3): its `publish` and `powers` set no bit its role's do not,
it subscribes only where its role does, its `max_layer` is at most its role's, and its
policy's kinds are within its role's `events`. Otherwise `caps_exceed_role`.
8. Its policy is within the room's ceiling (§3.5): otherwise `grant_exceeds_room`.
A grant is **good at** room-clock `t` when `nbf` ≤ ⌊(`t0` + `t`) / 10^6⌋ < `exp`.
### 4.4 Attributes
`attrs` is a sequence of entries, each a key (a short field holding a slot name) and a
value (a long field holding UTF-8, RFC 3629). Keys are in strictly ascending byte order,
and the entries take at most 1024 bytes, not counting the field's own length. An entry
that runs past the field's end breaks the rule.
Attributes are for composition: a later extension lays a room out from them. It reads
`featured` (`true` or `false`), `order` (a decimal integer) and `pip` (`true` or
`false`); this revision reads their syntax alone. Capture binding
([capture-binding](capture-binding.md) §3) reads two more: `subject`, the principal a
device captures for, as 64 lowercase hexadecimal digits of its key; and `minor` (`true`
or `false`), whether the authority marks that principal a minor. Attributes are signed with the grant,
so a layout computed from them is computed from what the authority stated.
### 4.5 How a receipt names a grant
A receipt that names the grant it was carried under does so with the grant reference
WAI's receipts already carry, the members `grant_hash`, `capability`,
`joule_ceiling_micro` and `funds_ceiling` that a package receipt
([packaging](packaging.md) §9) and a playlist receipt (http-delivery, Appendix H) write:
`grant_hash` is the grant's digest (§4.2), `capability` is `wai.live.room`,
`joule_ceiling_micro` is `joules_ceiling` (2^64 − 1 for no ceiling, as there), and
`funds_ceiling` is `funds_ceiling`, or none when that is 2^64 − 1. The reference carries
no currency; the grant it names does. Every object's receipt so names the grant it was
carried under, and the grant states the ceilings a meter holds that use to.
### 4.6 Grant exchange
A principal's grant is **exchanged** for a new grant that is chained to it: the new
grant's `prev` is the old grant's digest, and its `room` and `principal` are the old
one's (§4.3 step 3). The exchange takes effect at the new grant's `effective`, a tick of
the room clock, and is recorded as the clock's `grant_exchange` lifecycle event at that
`t` (§7.2). An exchange can widen what a principal may do (a viewer promoted to publish)
or narrow it, and only the authority can make one, since only it signs grants.
An exchange **MUST NOT** leave the principal publishing what its new grant does not let
it publish: an authority that narrows a publisher's grant unpublishes first (§7.2).
## 5. Revocation through the key log
A room whose descriptor names an `origin` is revocable. Its authority's key is an entry
of that origin's key log ([jwp-receipts](jwp-receipts.md) §7.2) whose `may_sign` holds
the authority `live/room` (jwp-receipts §7.5). Revocation then goes through that log:
- **A grant** is revoked by adding its digest to the head's `revoked_modules`.
- **A viewer** is revoked by adding its **tag**, SHA-256 over the 17 bytes
`wai:live-viewer` (ASCII) and 0x01 followed by the 16-byte `viewer`, to the same list.
Every grant naming that viewer is revoked with it, in every room whose descriptor names
that origin.
- **The authority** is revoked as any key is (jwp-receipts §7.6): with the key, every
descriptor and grant it signed is refused.
The list is append-only (jwp-receipts §7.2 rule 6), so a revocation stays. A relay checks
a grant against the key status it holds ([http-delivery](http-delivery.md) §8.4) at
admission and at each re-validation (§10.2), so a grant revoked stops being honoured
within `reval` plus the head's `status_max_age_s` seconds.
A room verifier is given the **head** the caller reached for the descriptor's origin
(jwp-receipts §7.4 step 3): anchoring the origin and walking its log to the head are the
caller's, and the verifier is a function of the head it is given. When `origin` is
empty, no key log is read, a head given is not read, and nothing is revoked. Otherwise,
in this order:
1. A head is given: otherwise `key_log_missing`.
2. It is held for `origin`: jwp-receipts §7.2 rules 1 to 3 hold for it. Otherwise
`key_log_not_held`.
3. It has an entry whose `key` is the descriptor's `authority` and whose `may_sign` holds
`live/room`: otherwise `authority_not_listed`.
4. That entry's `status` is not `revoked`: otherwise `authority_revoked`.
A grant whose digest, or whose viewer's tag, the head's `revoked_modules` lists is
**revoked**. A revocation refuses every use of the grant (§7.2): like a key's, it is not
bounded by a time, since a revoked grant may have been in another party's hands. An
authority that only wants a principal gone from a room removes it with `leave` (§7.2).
## 6. Track naming
### 6.1 The Full Track Name
A track of a room is named by a MOQT Full Track Name (MOQT §2.4.1):
- its **Track Namespace** is three fields:
1. the room id, as 64 lowercase hexadecimal digits;
2. the **owner**: the publishing principal's key as 64 lowercase hexadecimal digits,
or, for a slot (§7.1), `_` followed by the slot's name;
3. the media kind: `audio` (1), `video` (2) or `screen` (3); or `events` for the
principal's events track (live-events §8.1);
- its **Track Name** is the layer (§6.3) in decimal, with no leading zero: `0`, `1`, …;
the events track's is `0`.
Every byte of these fields is one of `a`–`z`, `0`–`9` and `_`, so the bytes are their
own canonical form (MOQT §2.4.1) and their rendering in MOQT §8.8's format is themselves:
the fields joined by `-`, then `--` and the name, as
`<room>-<principal>-video--1`. No field is empty and none begins with `.` (MOQT §2.4.3,
§8.7), and the whole is 64 + 64 + 6 + 3 bytes at most, within MOQT §8.7's limit.
The layer is the Track Name, and not a fourth namespace field, because
[moq-streaming-format](moq-streaming-format.md) §4.1 has the tracks of a staged set share
one namespace. Namespace prefix matching (MOQT §2.4.2) on the first field reaches every
track of a room; on the first two, every track of one publisher or slot.
### 6.2 The catalog
A publisher's MSF catalog entry for a track carries its `namespace` (MSF §5.2.2) as the
MOQT §8.8 rendering of the namespace, and its `name` (MSF §5.2.3) as the Track Name. The
codec, dimensions, frame rate, bitrate, sample rate and channels of the entry are the
track's profile layer's (§8.1). A track that carries WAI envelopes declares `packaging`
`"wai"` ([moq-streaming-format](moq-streaming-format.md) §3), and the tracks of a staged
profile carry `wai_staged` with `layer` equal to the Track Name (moq-streaming-format
§4.1). An anchor's `track` (live-events §3.1) is SHA-256 over that rendering of the
namespace, one zero byte, and the Track Name.
### 6.3 Layers
A layer is an index from 0, the lowest. A profile's `layering` (§8.1) says what its
layers are:
- `single`: one track, layer 0.
- `simulcast`: layer *k* is the profile's *k*-th layer, an independent encoding on a
track of its own.
- `staged`: layer 0 is the base and layer *k* the *k*-th refinement
([staged-delivery](staged-delivery.md) §1), each on a track of its own.
- `svc`: one track, layer 0, carries every layer of the profile's scalability `mode`,
named as the W3C "Scalable Video Coding (SVC) Extension for WebRTC" names them
(`L1T3`, `S3T3`). A relay that forwards it to a subscriber whose `max_layer` is *m*
forwards the spatial layers 0 to *m* alone.
A subscriber whose grant's `max_layer` is *m* subscribes to the tracks named `0` to *m*
(§10.2). A `layer_request` command (live-events §4.9) asks a publisher for a layer; it
grants nothing.
## 7. Precedence and redundancy
### 7.1 Slots, takeover and failover
A **slot** is a virtual track the descriptor declares: a name, a media kind, a
`redundancy` of 1 or 2, and a `failover_ms`. Subscribers subscribe to the slot's tracks
(§6.1, owner `_<name>`), and which principal publishes behind them can change without
any subscriber subscribing again: a relay switches the source and the subscription stands.
A slot has a **live** publisher, and, when its `redundancy` is 2, a **standby**.
- **Takeover.** A principal whose grant in effect has a higher `precedence` than the live
publisher's replaces it: the clock records a `takeover`, the new publisher goes live, and
the replaced one leaves the slot. A takeover by a precedence not higher is refused
`takeover_not_higher`.
- **Redundancy.** With `redundancy` 2, two publishers are bound to the slot: the first to
publish into it is live, the second its standby. A third is refused `slot_full`, as is
a second where `redundancy` is 1.
- **Failover.** When the live publisher's media stops, the clock records a `failover` to
the standby. The rule: the standby goes live only once `failover_ms` milliseconds of
room clock have passed since the live publisher's last object reached the relay, which
the event states as `last_seen`; earlier is refused `failover_early`. A failover to a
principal that is not the standby is refused `failover_not_standby`. The failed
publisher leaves the slot, and can publish into it again as the standby.
A standby whose live publisher unpublishes, or leaves the room, goes live with no event of
its own: its going live follows from the `unpublish` or `leave`.
### 7.2 Lifecycle detail
The lifecycle events of live-events §4.13 are authored by a room key. Their payload is the
`subject`, the principal the event is about, then `detail`, which this section defines
for each kind. A *slot* field is a short field holding a slot name or, where the
table allows, nothing (the subject's own track). `media` is a media kind of §6.1.
| kind | `detail` |
|---|---|
| `join` | `grant` (32): the digest of the grant the subject takes part under |
| `leave` | `reason` (u8): 1 left, 2 removed, 3 its grant ended, 4 connection lost |
| `publish` | `slot` (empty for an own track), `media` (u8), `profile` (u8, the index of the profile in the descriptor), `layers` (u8, at least 1) |
| `unpublish` | `slot` (empty for an own track), `media` (u8) |
| `grant_exchange` | `old` (32), `new` (32): the grants' digests |
| `takeover` | `slot` (not empty), `media` (u8), `profile` (u8), `layers` (u8, at least 1), `previous` (32): the live publisher replaced |
| `failover` | `slot` (not empty), `media` (u8), `failed` (32): the live publisher that stopped, `last_seen` (u64): the room clock at its last object |
A `detail` that does not read, with bytes after its last field, or with a value the table
does not allow, is `lifecycle_malformed`. Each event is then checked against the room
state (§9.3) at its `t`, in the order listed, and applied:
- **`join`**: the grant is one the verifier holds (`grant_unknown`); its `principal` is
the subject (`grant_wrong_principal`); the subject is not present (`already_present`);
the grant is not revoked (§5, `grant_revoked`); a grant whose `once` is not zero names a
`once` no earlier `join` named (`grant_reused`); the grant is good at `t`
(`outside_grant_window`); and the room holds fewer than `max_present` principals
(`participant_limit`). The subject is present, under that grant.
- **`leave`**: the subject is present (`not_present`). It is not present any more, its
own tracks are unpublished, and it leaves every slot; a standby whose live publisher
leaves goes live.
- **`publish`**: the subject is present (`not_present`); a `slot` named is a slot of the
descriptor of that media kind (`slot_unknown`); the grant in effect may publish the
media kind (`publish_not_permitted`), and its `profiles` hold `profile`, a profile of
that media kind with at least `layers` layers (`profile_not_granted`); the subject does
not already publish that own track or stand in that slot (`already_published`); the
slot has room (§7.1, `slot_full`); and then at most `max_publishers` principals publish
(§7.3, `publisher_limit`). An own track is published with `layers` tracks; a slot's
publisher is live if the slot has none, and its standby otherwise.
- **`unpublish`**: the subject is present (`not_present`); a `slot` named is a slot of the
descriptor of that media kind (`slot_unknown`); the subject publishes that own track or
stands in that slot (`not_published`). It is unpublished; a standby whose live
publisher unpublishes goes live.
- **`grant_exchange`**: the subject is present (`not_present`); `old` is its grant in
effect (`exchange_not_current`); `new` is a grant the verifier holds (`grant_unknown`)
whose `prev` is `old` (`exchange_unlinked`); `t` is `new`'s `effective`
(`exchange_off_tick`); `new` is not revoked (`grant_revoked`) and is good at `t`
(`outside_grant_window`); and for everything the subject publishes, its own tracks by
media kind and then its places in slots in the descriptor's order, `new` may publish
the media kind (`publish_not_permitted`) and gives the profile (`profile_not_granted`).
`new` is then the grant in effect.
- **`takeover`**: the subject is present (`not_present`); the slot is the descriptor's,
of that media kind (`slot_unknown`); `previous` is its live publisher
(`not_published`); the grant in effect may publish the media kind and gives the profile
with at least `layers` layers (`publish_not_permitted`, `profile_not_granted`); the
subject is not `previous`, and its grant's `precedence` is higher than the grant in
effect of `previous` (`takeover_not_higher`); and then at most `max_publishers`
principals publish (`publisher_limit`). The subject is live, with `layers` layers;
`previous` leaves the slot, and the subject is no longer its standby.
- **`failover`**: the subject is present (`not_present`); the slot is the descriptor's,
of that media kind (`slot_unknown`); `failed` is its live publisher (`not_published`);
the subject is its standby (`failover_not_standby`); `last_seen` is at most `t`, and
`t` − `last_seen` is at least `failover_ms` × 1000 (`failover_early`). The standby is
live, and `failed` leaves the slot.
### 7.3 Publishers
A principal **publishes** while it has an own track published or stands in a slot, live
or standby. At most `max_publishers` principals publish at once; a `publish` or a
`takeover` after which more would is refused `publisher_limit`.
## 8. Publish profiles
### 8.1 The profile record
A profile fixes, as data, what a publisher sends on one media kind: its codecs, its
layers and their limits, and its audio processing.
| field | size | meaning |
|---|---|---|
| `name` | short | the profile's name |
| `media` | 1 | its media kind (§6.1) |
| `codecs` | 1 + … | a u8 count (1 to 4), then each `codecs` value (RFC 6381) as a short field holding a token of at most 64 bytes, in the order a publisher prefers them |
| `layering` | 1 | 0 `single`, 1 `simulcast`, 2 `svc`, 3 `staged` (§6.3) |
| `mode` | short | for `svc`, the scalability mode (§6.3): 1 to 16 of `A`–`Z`, `0`–`9` and `_`; otherwise empty |
| `layers` | 1 + 14 × *n* | a u8 count (1 to 8; exactly 1 for `single`), then each layer: `width`, `height`, `fps` (u16 each), `min_kbps`, `max_kbps` (u32 each) |
| `sample_rate` | 4 | u32: audio's sample rate in Hz; 0 for video |
| `channels` | 1 | audio's channel count, 1 to 8; 0 for video |
| `processing` | 1 | audio's processing: bit 0 echo cancellation, bit 1 noise suppression, bit 2 automatic gain control, bit 3 discontinuous transmission (DTX); 0 for video |
Each layer's `max_kbps` is above 0 and `min_kbps` at most `max_kbps`: `min_kbps` is a
floor a publisher **SHOULD NOT** send below while the network allows, and `max_kbps` a
ceiling it **MUST NOT** exceed. An audio layer has `width`, `height` and `fps` 0, and an
audio profile a `sample_rate` above 0, 1 to 8 `channels`, and `processing` setting no bit
above bit 3; a video or screen layer has all three above 0. A publisher publishing under a
profile uses one of its codecs, its layering and its layers, and turns on exactly the
processing it names.
### 8.2 Registered profiles
| name | media | codecs | layering | layers (width × height @ fps, kbps) | audio |
|---|---|---|---|---|---|
| `speech` | audio | `opus` | single | 16–64 | 48 000 Hz, 1 channel; echo cancellation, noise suppression, gain control and DTX on |
| `music` | audio | `opus` | single | 128–510 | 48 000 Hz, 2 channels (stereo, fullband); no echo cancellation, noise suppression, gain control or DTX |
| `screen` | screen | `av01.0.08M.08`, `avc1.640028` | svc, `L1T3` | 1920 × 1080 @ 15, 300–2500 | — |
| `camera-720` | video | `av01.0.05M.08`, `avc1.64001F` | simulcast | 640 × 360 @ 30, 150–500; 1280 × 720 @ 30, 500–1500 | — |
| `camera-1080` | video | `av01.0.08M.08`, `avc1.640028` | simulcast | 640 × 360 @ 30, 150–500; 1280 × 720 @ 30, 500–1500; 1920 × 1080 @ 30, 1500–4000 | — |
`opus` is RFC 6716 audio, `av01` AV1 and `avc1` ITU-T H.264. `music`'s 128 kbps floor
and its processing turned off keep what an instrument plays: echo cancellation, noise
suppression and gain control are built for speech and remove parts of music, and DTX
stops sending in what it takes for silence. `screen` drops frames rather than resolution
(`L1T3`, temporal layers alone), since text on a screen does not survive scaling. A room
lists the profiles it allows in its descriptor, under these names with these values or
under names of its own; `wai_live_room_vectors` writes each registered profile's record
into the corpus, so an implementation checks its table against them.
## 9. Room verification
### 9.1 Inputs and steps
A room verifier is given a descriptor, a list of grants, the head of the authority's key
log (or none), and the room's **chains**: a list of lists of `WEV1` records, each list one
principal's records in its stream order (live-events §6). It runs these steps and stops
at the first refusal, which it reports with the reason and where:
1. **The descriptor** (§3.4, §3.2): its refusal, at `descriptor`.
2. **The key log** (§5): its refusal, at `key_log`.
3. **The grants** (§4.3): the refusal, at the grant's index.
4. **The records**, in the merged order (§9.2), each by §9.2: the refusal, at the
record's chain and its index in that chain.
5. **The outcome** (§9.4).
### 9.2 The merged order and each record
The **merged order** takes the records of every chain into one sequence, keeping each
chain's own order: repeatedly, of the chains with records left, it takes the next record
of the one whose next record has the least key. A record's key is its `t`, then 0 for a
room key's record and 1 for any other, then its chain's index; a record that live-events
§3.3 does not read has the key (0, 0, its chain's index). Lifecycle events therefore take
effect before the events of other principals at the same `t`.
One live-events verifier (live-events §6) verifies every record, for the room id. Before
it is offered a record, the verifier is given the policy the record is held to. For each
record, in the merged order:
1. live-events §6.1 steps 1 to 4: the record reads, its kind is defined, its `room` is
the room id, its signature verifies. Otherwise that section's reason.
2. A record whose digest is that of a record accepted before is a duplicate: it is
offered under the policy it was accepted under, which live-events §6.2 step 1 ignores,
and it changes nothing.
3. A room key's record is offered under the room authority's policy (live-events §5),
and refused with live-events' reason. A lifecycle event then has its `detail` read and
is applied (§7.2).
4. Any other principal's record: the principal is present (`not_present`), its grant in
effect is good at the record's `t` (`outside_grant_window`), and the record is offered
under that grant's `policy` and refused with live-events' reason: a kind the grant does
not permit is `kind_not_permitted`, a payload over its bound `payload_too_large`, and an
event past its rate window `rate_exceeded`. A participant's lifecycle event is
therefore `kind_not_permitted`, as no grant permits one (§4.3 step 5).
5. A `control` event of such a principal needs a power of its grant in effect (§3.3):
`record_start` and `record_stop` the record power, any other command the direct power.
Otherwise `power_not_granted`.
Each principal's effective policy at `t` is so the policy of its grant in effect at `t`,
and the room keys' is the authority's. Each record is reported with the grant it was
verified under, or none for a room key's.
### 9.3 Room state
The room state is replayed from the lifecycle events alone, in the merged order:
- **present**: each present principal and its grant in effect;
- **published**: each own track, by principal and media kind, with its profile and its
number of layers;
- **slots**: each slot's live publisher and standby, and the live publisher's precedence:
the slot's current **precedence holder**;
- **active grants**: each change of a principal's grant in effect (a `join` or a
`grant_exchange`), with its `t`, in the order they took effect.
The tracks a subscriber of the room can subscribe to are named by §6.1: each own track's
layers, and each slot's live publisher's.
### 9.4 Outcomes
- **refuse**: the reason and where (§9.1). Step 5's `tally_mismatch` (live-events §7) is
at the record of its `poll_close`.
- **gap**: no record refused, and the live-events verifier does not hold every seq of
every chain (live-events §6.2): the gaps, under the code `delivery_gap`. The room state
is the replay of what it holds, and a tally it computes is unchecked.
- **accept**: no record refused, no gap, and every stated tally equal to the replay.
## 10. Bindings
A grant is WAI's own signed record. A relay, a CDN or an origin that authorises with an
existing token format is given a token that maps the grant's claims onto that format's;
WAI defines no second token format. A claim the grant has and the format does not stays
in the grant, which the relay holds beside the token (§10.1).
### 10.1 Common Access Token (CTA-5007-B)
A Common Access Token is a CWT (RFC 8392). Its claims are named here by name and by the
key the IANA CWT Claims registry gives them; the value syntax of each CAT claim is
CTA-5007-B's.
| grant claim | CWT / CAT claim | mapping |
|---|---|---|
| the authority | `iss` (1) | the authority's `signer_id` (jwp-receipts §7.1): origin-bound, with its key-log entry's id, where `origin` is set; its `did:key` otherwise |
| `principal` | `sub` (2) | the principal's `did:key` |
| `room` | `aud` (3) | the room id, 64 lowercase hexadecimal digits |
| `nbf`, `exp` | `nbf` (5), `exp` (4) | the same NumericDates |
| `once` | `cti` (7), `catreplay` (308) | a `once` not zero is the token's `cti`, with `catreplay` set to prohibit replay; zero, `catreplay` permits it |
| `origins` | `catu` (312), `cath` (315) | each origin as a URI match on scheme and host; a deployment that matches on a request header carries it in `cath` |
| the token's renewal | `catr` (323) | a renewed token's `exp` is never later than the grant's; an exchange (§4.6) issues a new token |
| `principal`, as a key binding | `catdpop` (321), `cnf` (8) | proof of possession by the principal's key; `cattpk` (319) binds a TLS key, and applies only where the client's TLS key is the principal's |
| client network, geography | `catnip` (311), `catgeoiso3166` (316) | no grant claim: a deployment **MAY** add them; a room's restriction by place is a sink-evaluated predicate ([unidirectional-delivery](unidirectional-delivery.md) §2) |
| caps, `max_layer`, the slots | `moqt` | §10.2 |
| `reval` (descriptor) | `moqt-reval` | §10.2 |
| `role`, `precedence`, `policy`, `profiles`, `attrs`, `joules_ceiling`, `funds_ceiling`, `viewer` | none | WAI's alone; the relay holds the grant, and a deployment that links the two inside the token carries the grant's digest in a private-use claim (RFC 8392 §9.1) |
`catv` (310) is the version CTA-5007-B gives; `catpor` (309), `catm` (313), `catalpn`
(314), `catgeocoord` (317), `catgeoalt` (318), `catif` (322) and `catifdata` (320) have
no grant claim. This revision implements no CAT encoding: the crate has no CBOR or COSE,
and the claims above are mapped by name and key.
### 10.2 MOQT: the `moqt` claim (`draft-ietf-moq-c4m-01`)
A token for MOQT carries the `moqt` claim, an array of scopes, each `[actions, namespace
matches, track match]` (`draft-ietf-moq-c4m-01` §2.1). Every action is blocked unless a
scope enables it (§2 there), so the scopes a grant maps onto are all it can do. With the
room id `R` and the principal `P` as 64 lowercase hexadecimal digits, the scopes are, in
this order:
1. `[[0]]`: `CLIENT_SETUP`.
2. For each media kind the grant may publish, in ascending order of its code, with *m* its
name (§6.1):
`[[2, 6], [R, P, m, nil]]` (`PUBLISH_NAMESPACE` and `PUBLISH` of its own tracks), then,
for each slot of the descriptor of media kind *m*, in the descriptor's order,
`[[6], [R, "_" + slot, m, nil]]`.
3. When the grant's policy permits any kind: `[[2, 6], [R, P, "events", nil], "0"]`.
4. When the grant may subscribe: `[[3], [R]]` (`SUBSCRIBE_NAMESPACE`), then for each layer
*l* from 0 to `max_layer`, `[[4, 7, 8], [R], "l"]` (`SUBSCRIBE`, `FETCH`,
`TRACK_STATUS` of any track of the room named *l*).
Each namespace or track match is a byte string, an exact match; `nil` ends a namespace
match at that length. The token's `moqt-reval` is the descriptor's `reval`: a relay
re-validates the token, and with it the grant's revocation (§5), at least that often, and
stops what the grant authorised when it fails (§2.2 there). A `reval` of 0 asks for no
re-validation, and then only expiry ends the token. A grant exchange (§4.6) issues a new
token at the tick. The corpus states each accepted vector's scopes per grant, written as
JSON: an exact match as a string and `nil` as `null`.
### 10.3 URI Signing (RFC 9246)
Over HTTP, a room's delivery (an LL-HLS or DASH rendition of its tracks) is authorised with
a URI Signing JWT:
| grant claim | URI Signing claim | mapping |
|---|---|---|
| the authority | `iss` (§2.1.1 there) | as §10.1 |
| `principal` | `sub` (§2.1.2) | the principal's `did:key`, as a JWE as that section requires |
| `nbf`, `exp` | `nbf` (§2.1.5), `exp` (§2.1.4) | the same, with no leeway |
| `once` | `jti` (§2.1.7) | a `once` not zero, so a CDN that keeps token ids refuses a replay |
| `origins`, `room`, `max_layer` | `cdniuc` (§2.1.11) | a `regex:` container (§2.1.15.2) matching the grant's origins, the room's path, and the renditions of layers 0 to `max_layer` |
| client network | `cdniip` (§2.1.10) | no grant claim; a deployment **MAY** add it |
| renewal | `cdniets`, `cdnistt`, `cdnistd` (§2.1.12–§2.1.14) | a renewed token's `exp` is never later than the grant's |
| the rest | none | as §10.1 |
`cdniv` is 1.
### 10.4 Privacy Pass (`draft-ietf-moq-privacy-pass-auth-03`)
A viewer that need not be identified is admitted by a Privacy Pass token instead of a
grant. The token's challenge carries the scope in its `origin_info`
(`MoQAuthorizationInfo`, §3.2.4 there), and its presentations are unlinkable to one
another (§2.2 there).
| grant claim | Privacy Pass field | mapping |
|---|---|---|
| `room`, `subscribe`, `max_layer` | `origin_info` scopes | the subscribing scopes of §10.2 step 4, each a `MoQAuthScope` whose `namespace_match` is `MATCH_PREFIX` on `[R]` and whose `track_name_match` is `MATCH_EXACT` on the layer |
| `nbf`, `exp` | `redemption_context` (§3.2.1 there) | a hash of the time window the token is good for |
| `once` | the token's nonce | every token is single-use (§3.3.2 there) |
| `principal`, `viewer` | none | the token names no one; revocation is of the issuer's key, not of a viewer |
| `policy`, publish caps, ceilings, `attrs` | none | a Privacy Pass viewer sends no event and publishes nothing |
A viewer that is to send events holds a grant for a key of its own; a key per room keeps
its events unlinkable across rooms (live-events §11).
## 11. Reasons
The codes this extension adds, each with where it is raised:
| reason | raised at |
|---|---|
| `descriptor_invalid` | §3.4 |
| `key_log_missing` | §5 step 1 |
| `key_log_not_held` | §5 step 2 |
| `authority_not_listed` | §5 step 3 |
| `authority_revoked` | §5 step 4 |
| `grant_invalid` | §4.3 step 5 |
| `grant_too_long` | §4.3 step 6 |
| `caps_exceed_role` | §4.3 step 7 |
| `grant_exceeds_room` | §4.3 step 8 |
| `exchange_unlinked` | §4.3 step 3, §7.2 `grant_exchange` |
| `exchange_mismatch` | §4.3 step 3 |
| `lifecycle_malformed` | §7.2 |
| `grant_unknown` | §7.2 `join`, `grant_exchange` |
| `grant_wrong_principal` | §7.2 `join` |
| `grant_revoked` | §5, §7.2 `join`, `grant_exchange` |
| `grant_reused` | §7.2 `join` |
| `already_present` | §7.2 `join` |
| `not_present` | §7.2, §9.2 step 4 |
| `participant_limit` | §3.5, §7.2 `join` |
| `outside_grant_window` | §4.3, §7.2, §9.2 step 4 |
| `power_not_granted` | §9.2 step 5 |
| `publish_not_permitted` | §7.2 `publish`, `grant_exchange`, `takeover` |
| `profile_not_granted` | §7.2 `publish`, `grant_exchange`, `takeover` |
| `already_published` | §7.2 `publish` |
| `not_published` | §7.2 `unpublish`, `takeover`, `failover` |
| `slot_unknown` | §7.2 |
| `slot_full` | §7.1, §7.2 `publish` |
| `publisher_limit` | §7.3 |
| `exchange_not_current` | §7.2 `grant_exchange` |
| `exchange_off_tick` | §4.6, §7.2 `grant_exchange` |
| `takeover_not_higher` | §7.1, §7.2 `takeover` |
| `failover_not_standby` | §7.1, §7.2 `failover` |
| `failover_early` | §7.1, §7.2 `failover` |
Of live-events §9's codes, a room verifier also raises `truncated`, `malformed` and
`unknown_version` when a descriptor or a grant does not read (§3.4, §4.3); `bad_key` and
`bad_signature` for their signatures (§3.2, §4.2); `wrong_room` for a grant of another
room (§4.3 step 4); every code live-events §6 raises for a record (§9.2); `tally_mismatch`
(§9.4); and `delivery_gap` for a gap, which is not a refusal.
## 12. Conformance
[`live-conformance/rooms/`](../live-conformance/rooms/) holds the corpus, in one file,
`vectors.json`, written from fixed seeds by `wai_live_room_vectors gen`. It has the
records of each registered profile (§8.2), and **vectors**: a descriptor, grants, a
key-log head or none, and chains, with the outcome §9.4 gives them.
- **Accepted**: a lesson-style room (a director, a performer, an audience, and the
director's remote record control of the performer, under a key log whose head revokes
a grant and a viewer the room never admits); a broadcast room (one publisher, many
viewers, a moderator); a takeover; a redundant pair with failover; and a grant
exchange that promotes a viewer to a publisher. Each states the room id, every grant's
digest, the merged order with the grant each record was verified under, the room state
(§9.3) with the names of the tracks a subscriber can reach (§6.1), the tally
(live-events §7), and each grant's `moqt` scopes and re-validation interval (§10.2).
- **Gap**: a chain with a record not delivered.
- **Refused**: each reason of §11 at least once, each vector a control of one defect, and
the codes of live-events §9 that a descriptor, a grant or a record of a room raises.
A conforming verifier gives every vector the outcome, the reason, the place, the gaps and
the state, order, tally and scopes the corpus states. `wai_live_room_vectors verify` and
the test `tests/live_room.rs` check that the file regenerates byte for byte, that `wai-rs`
gives every vector its outcome, and that §11 lists exactly the reference's codes.
`live-conformance/live_rooms_verify.py` is the second implementation, written from this
text: it shares no code with `wai-rs`, verifies signatures with WAI's strict rule written
from RFC 8032 (`claim-conformance/_ed25519.py`), verifies each event with the live-events
corpus's own second implementation (`live_events_verify.py`), and gives every vector the
same outcome.
## 13. Security and privacy considerations
- Every claim a principal acts under is signed by the room authority, and every event a
principal sends by the principal; a relay can drop either but alter neither, and a room
verifier replays the room from them alone.
- `t` is its author's claim (live-events §11). The clock's lifecycle events fix when a
principal was present and under which grant; a participant's `t` outside its grant's
window or outside its presence is refused. A clock that lies about time is the room's
own key lying, which its signature makes evidence of.
- A grant is a bearer capability only together with the principal's key: every event
under it is signed by that key, so a copied grant lets no one else send. A token mapped
from it (§10) is a bearer token, which is why the mapping binds it to the principal's
key where the format allows (`catdpop`, `cnf`) and why its window is fourteen days at
most.
- Revocation is through the key log, so it is as available as the log (jwp-receipts §7.8):
a relay with no fresh status for the authority's origin fails closed.
- A viewer's tag is a hash of an id the authority assigns; the key log publishes the tag,
not the id, and an authority **SHOULD** assign ids that are not otherwise meaningful.
- A grant names its principal, so a room's grants link that principal's participation; a
principal that needs participation unlinkable across rooms uses a key per room, and a
viewer that needs no identity is admitted by Privacy Pass (§10.4).
- The region pin (§3.7) binds where a recording is kept; checking a recording's receipt
against it is the recording extension's.

View file

@ -0,0 +1,24 @@
# live-conformance — the `wai.live` family
Conformance corpora of the live and interactive extensions, one directory per extension,
and the independent verifier of each, beside it.
| directory | extension | verifier |
|---|---|---|
| [`events/`](events/) | [live-events](../extensions/live-events.md) (`wai.live.events`) | `live_events_verify.py` |
| [`rooms/`](rooms/) | [live-rooms](../extensions/live-rooms.md) (`wai.live.room`) | `live_rooms_verify.py` |
| [`takes/`](takes/) | [capture-binding](../extensions/capture-binding.md) (`wai.capture.take`) | `capture_take_verify.py` |
**Provenance.** Generated here: `wai_live_events_vectors gen` writes `events/`,
`wai_live_room_vectors gen` writes `rooms/` and `wai_capture_take_vectors gen` writes
`takes/`, each from records authored in code, from fixed seeds (`events/README.md`,
`rooms/README.md`, `takes/README.md`). No recorded data.
Each verifier is written from its extension's text, shares no code with `wai-rs`, and is
run by `.github/workflows/wai-conformance.yml` beside the reference's own check:
```
python3 live_events_verify.py events
python3 live_rooms_verify.py rooms
python3 capture_take_verify.py takes
```

View file

@ -0,0 +1,683 @@
#!/usr/bin/env python3
"""Independent verifier of the capture-binding corpus (extensions/capture-binding.md).
Written from the extension's text. It shares no code with wai-rs, which generates the
corpus, and never runs it. It checks Ed25519 signatures with
../claim-conformance/_ed25519.py, WAI's strict rule (SPEC §7 *Signatures*) written from
RFC 8032 with the standard library. The room is verified by the live-rooms corpus's own
second implementation, live_rooms_verify.py (live-rooms §9), which verifies every event
with live_events_verify.py (live-events §6); both are likewise written from their texts
alone. Everything else is here:
- the take tree (§4): leaves of the take's domain, the Merkle tree hash of RFC 6962 §2.1
with staged-measurement Appendix C's node, and consistency checked as RFC 9162 §2.1.4.2
checks it;
- the take_commit payload and its chain (§5), the upload receipts and their state machine
(§6), the take manifest (§7) and the consent record (§8), each read and checked here;
- take verification (§9): the take in the room, the store, the receipts, the consents,
the verdicts of single-take, aligned, complete and publishable, and whether the
manifest stands; the master position of every marker (§10); the stream id (§11.1).
For every vector it requires the outcome, reason and place, and for one with verdicts the
take id, stream id, commitments, size, root, verdicts, standing and markers the corpus
states; of the corpus, that every reason of §12 and delivery_gap appear in it. Then it
mutates copies of the corpus, one defect each, and requires every copy to fail.
python3 capture_take_verify.py <live-conformance/takes>
Exit status 0 only if every vector verifies and every mutated copy fails. Python 3.8 or
later; standard library only.
"""
import copy
import hashlib
import json
import os
import sys
HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, HERE)
sys.path.insert(0, os.path.join(HERE, "..", "claim-conformance"))
import live_events_verify as LEV # noqa: E402
import live_rooms_verify as LRV # noqa: E402
import _ed25519 as ED # noqa: E402
Refuse = LEV.Refuse
TAKE_DOMAIN = b"wai:capture-take\x01"
LEAF_DOMAIN = b"wai:take-leaf\x01"
NODE_DOMAIN = b"wai:out-node\x01"
MANIFEST_DOMAIN = b"wai:capture-manifest\x01"
RECEIPT_DOMAIN = b"wai:take-upload\x01"
CONSENT_DOMAIN = b"wai:capture-consent\x01"
STORE_AUTHORITY = "capture/store"
ZERO = bytes(32)
OPEN, RUNNING, CLOSE = 1, 2, 3
R_OPEN, R_PROGRESS, R_RESUME, R_SEALED = 1, 2, 3, 4
PURPOSES = [(1, "live"), (2, "record"), (4, "distribute"), (8, "train")]
RECORD = 2
SINGLE_TAKE, ALIGNED, COMPLETE = 1, 2, 4
TAKE_COMMIT, MARKER, CONTROL = 0x0C, 0x08, 0x09
# §12, in its order.
REASONS = [
"recording_not_permitted", "take_unknown", "commit_malformed", "device_not_bound",
"manifest_invalid", "manifest_mismatch", "store_not_listed", "upload_receipt_invalid",
"upload_receipt_mismatch", "store_outside_region", "consent_invalid",
"commit_chain_broken", "root_not_extending", "take_not_closed", "manifest_not_committed",
"clock_unstable", "capture_discontinuous", "chunk_too_long", "commit_early", "commit_late",
"chunk_extra", "chunk_reordered", "chunk_altered", "misaligned", "chunk_missing",
"upload_not_sealed", "consent_missing", "guardian_missing", "consent_revoked",
"consent_expired", "purpose_not_consented",
]
def sha(*parts):
h = hashlib.sha256()
for p in parts:
h.update(p)
return h.digest()
class Reader:
"""Reads little-endian fields in order; running out is `out`."""
def __init__(self, b, p, out):
self.b, self.p, self.out = b, p, out
def take(self, n):
if self.p + n > len(self.b):
raise Refuse(self.out)
s = self.b[self.p:self.p + n]
self.p += n
return s
def u8(self):
return self.take(1)[0]
def uint(self, n):
return int.from_bytes(self.take(n), "little")
def i64(self):
return int.from_bytes(self.take(8), "little", signed=True)
def done(self):
return self.p == len(self.b)
def read_head(b, magic):
"""live-events §3.3's first steps, with `magic` for WEV1."""
if len(b) < 4:
raise Refuse("truncated")
if b[:4] != magic:
raise Refuse("malformed")
if len(b) < 5:
raise Refuse("truncated")
if b[4] != 1:
raise Refuse("unknown_version")
def check_sig(key, domain, body, sig):
bad = ED.verify(key, domain + body, sig)
if bad:
raise Refuse(bad)
# ---------------------------------------------------------------- §4 the take tree
def leaf(desc):
return sha(LEAF_DOMAIN, desc)
def node(left, right):
return sha(NODE_DOMAIN, left, right)
def split(n):
"""The largest power of two below n, n >= 2."""
k = 1
while k * 2 < n:
k *= 2
return k
def mth(ls):
"""RFC 6962 §2.1, n >= 1."""
if len(ls) == 1:
return ls[0]
k = split(len(ls))
return node(mth(ls[:k]), mth(ls[k:]))
def root_of(chunks):
return ZERO if not chunks else mth([leaf(c["desc"]) for c in chunks])
def consistent(first, second, first_root, second_root, path):
"""RFC 9162 §2.1.4.2, with node() for HASH(0x01 || ...); 0 < first < second."""
if not path:
return False
if first & (first - 1) == 0:
path = [first_root] + list(path)
fn, sn = first - 1, second - 1
while fn & 1:
fn >>= 1
sn >>= 1
fr = sr = path[0]
for c in path[1:]:
if sn == 0:
return False
if fn & 1 or fn == sn:
fr = node(c, fr)
sr = node(c, sr)
if not fn & 1:
while not fn & 1 and fn != 0:
fn >>= 1
sn >>= 1
else:
sr = node(sr, c)
fn >>= 1
sn >>= 1
return fr == first_root and sr == second_root and sn == 0
def extends(first, old, size, new, proof):
"""§4.3, with the two cases RFC 9162 leaves to the caller."""
if first == 0:
return not proof
if first == size:
return not proof and old == new
return first < size and consistent(first, size, old, new, proof)
# ---------------------------------------------------------------- §5, §6, §7, §8 records
def read_commit(b):
"""§5.2."""
r = Reader(b, 0, "commit_malformed")
c = {"take": r.take(32), "n": r.uint(4), "phase": r.u8()}
if c["phase"] not in (OPEN, RUNNING, CLOSE):
raise Refuse("commit_malformed")
c["prev"], c["first"], c["size"] = r.take(32), r.uint(8), r.uint(8)
if c["size"] < c["first"]:
raise Refuse("commit_malformed")
c["root"] = r.take(32)
if c["size"] == 0 and c["root"] != ZERO:
raise Refuse("commit_malformed")
c["offset"] = r.i64()
k = r.u8()
if k > 64:
raise Refuse("commit_malformed")
c["proof"] = [r.take(32) for _ in range(k)]
if not r.done():
raise Refuse("commit_malformed")
return c
def is_opening(c):
return (c["n"] == 0 and c["phase"] == OPEN and c["prev"] == ZERO and c["first"] == 0 and c["size"] == 0
and not c["proof"])
def read_chunk(r):
desc = r.take(64)
return {"desc": desc, "index": int.from_bytes(desc[0:8], "little"), "start": int.from_bytes(desc[8:16], "little"),
"end": int.from_bytes(desc[16:24], "little"), "len": int.from_bytes(desc[24:32], "little"), "hash": desc[32:]}
def is_chunk(c, b):
return len(b) == c["len"] and sha(b) == c["hash"]
def read_receipt(b):
"""§6.2."""
read_head(b, b"WUR1")
r = Reader(b, 5, "truncated")
x = {"store": r.take(32), "take": r.take(32), "seq": r.uint(8), "prev": r.take(32), "at": r.uint(8),
"state": r.u8(), "held": r.uint(8), "root": r.take(32), "region": r.take(2), "sig": r.take(64)}
if not r.done():
raise Refuse("malformed")
x["body"] = b[:-64]
x["digest"] = sha(x["body"])
return x
def read_manifest(b, room):
"""§7.2: the manifest, or Refuse."""
read_head(b, b"WTM1")
r = Reader(b, 5, "truncated")
m = {k: r.take(32) for k in ("room", "device", "principal", "start", "take", "store")}
m["claims"], m["purposes"] = r.u8(), r.u8()
m["chunks"] = [read_chunk(r) for _ in range(r.uint(4))]
m["root"] = r.take(32)
m["commits"] = [r.take(32) for _ in range(r.uint(4))]
m["depicted"] = [(r.take(32), r.take(32)) for _ in range(r.u8())]
m["sig"] = r.take(64)
if not r.done():
raise Refuse("malformed")
keys = [p for p, _ in m["depicted"]]
ok = (m["claims"] & ~0b111 == 0 and m["purposes"] & ~0b1111 == 0 and len(m["chunks"]) >= 1
and all(c["index"] == i and c["len"] >= 1 for i, c in enumerate(m["chunks"]))
and m["root"] == root_of(m["chunks"])
and len(m["commits"]) >= 1 and len(set(m["commits"])) == len(m["commits"])
and 1 <= len(keys) <= 32 and all(a < z for a, z in zip(keys, keys[1:])) and m["principal"] in keys
and m["take"] == sha(TAKE_DOMAIN, m["room"], m["device"], m["start"]))
if not ok:
raise Refuse("manifest_invalid")
if m["room"] != room:
raise Refuse("wrong_room")
check_sig(m["device"], MANIFEST_DOMAIN, b[:-64], m["sig"])
return m
def read_consent(b):
"""§8.1, §8.2."""
read_head(b, b"WCN1")
r = Reader(b, 5, "truncated")
c = {"subject": r.take(32), "minor": r.u8(), "guardian": r.take(32), "purposes": r.u8(), "room": r.take(32),
"nbf": r.uint(8), "exp": r.uint(8), "nonce": r.take(16), "sig": r.take(64)}
if not r.done():
raise Refuse("malformed")
ok = (c["minor"] in (0, 1) and (c["minor"] == 1 or c["guardian"] == ZERO) and c["guardian"] != c["subject"]
and c["purposes"] != 0 and c["purposes"] & ~0b1111 == 0 and c["nbf"] < c["exp"])
if not ok:
raise Refuse("consent_invalid")
signer = c["subject"] if c["guardian"] == ZERO else c["guardian"]
check_sig(signer, CONSENT_DOMAIN, b[:-64], c["sig"])
c["digest"] = sha(b[:-64])
return c
def stream_id(take):
"""§11.1: an RFC 9562 version-8 UUID of the take id's first 16 bytes."""
u = bytearray(take[:16])
u[6] = (u[6] & 0x0F) | 0x80
u[8] = (u[8] & 0x3F) | 0x80
h = u.hex()
return "%s-%s-%s-%s-%s" % (h[:8], h[8:12], h[12:16], h[16:20], h[20:])
# ---------------------------------------------------------------- §9 verification
class Stop(Exception):
def __init__(self, at, reason):
Exception.__init__(self, reason)
self.at, self.reason = at, reason
def attrs_of(g):
return dict(LRV.read_attrs(g["attrs"]) or [])
def binds(g, principal):
"""§3.1."""
a = attrs_of(g)
return a["subject"] == principal.hex() if "subject" in a else g["principal"] == principal
def control(ev, device, command):
"""The control payload of `ev`, when it is `command` addressed to `device`."""
if ev["kind"] != CONTROL:
return None
try:
p = LEV.read_payload(CONTROL, ev["payload"], 65535)
except Refuse:
return None
return p if p["target"] == device and p["command"] == command else None
def find(d, grants, seen, gaps, m):
"""§9.2: the start, its count-in, the stop and the held commitments."""
if d["mode"] == 0:
raise Stop(["manifest"], "recording_not_permitted")
pos = next((k for k, s in enumerate(seen) if s["ev"]["digest"] == m["start"]), None)
start = None if pos is None else control(seen[pos]["ev"], m["device"], 1)
if start is None:
raise Stop(["manifest"], "take_unknown")
stop = next((s for s in seen[pos + 1:] if control(s["ev"], m["device"], 2) is not None), None)
mine = sorted((s for s in seen if s["ev"]["principal"] == m["device"] and s["ev"]["kind"] == TAKE_COMMIT),
key=lambda s: s["ev"]["seq"])
commits = []
for s in mine:
try:
c = read_commit(s["ev"]["payload"])
except Refuse as e:
raise Stop(["record", s["chain"], s["record"]], e.reason)
if c["take"] == m["take"]:
commits.append((s, c))
for s, _ in commits:
g = grants.get(s["grant"])
if g is None or not binds(g, m["principal"]):
raise Stop(["record", s["chain"], s["record"]], "device_not_bound")
device_gap = any(g["principal"] == m["device"].hex() for g in gaps)
held = [s["ev"]["digest"] for s, _ in commits]
every = {s["ev"]["digest"] for s in seen}
listed_held = [x for x in m["commits"] if x in held]
unheld = [x for x in m["commits"] if x not in held]
if listed_held != held or any(x in every or not device_gap for x in unheld):
raise Stop(["manifest"], "manifest_mismatch")
return seen[pos], start["count_in_ms"], stop, commits
def head_of(text):
return json.loads(text) if text is not None else None
def check_upload(d, head, m, receipts):
"""§6.4, §6.3: the last receipt, or Stop."""
if d["origin"] != b"":
h = head_of(head)
listed = h is not None and any(
e["key"] == m["store"].hex() and STORE_AUTHORITY in e["may_sign"] and e["status"] != "revoked" for e in h["keys"])
if not listed:
raise Stop(["store"], "store_not_listed")
prev = None
for i, b in enumerate(receipts):
at = ["receipt", i]
try:
x = read_receipt(b)
except Refuse as e:
raise Stop(at, e.reason)
fields = (x["state"] in (1, 2, 3, 4) and all(0x41 <= c <= 0x5A for c in x["region"])
and (x["held"] != 0 or x["root"] == ZERO))
if not fields or x["store"] != m["store"] or x["take"] != m["take"]:
raise Stop(at, "upload_receipt_invalid")
bad = ED.verify(x["store"], RECEIPT_DOMAIN + x["body"], x["sig"])
if bad:
raise Stop(at, bad)
if prev is None:
chained = x["seq"] == 0 and x["prev"] == ZERO and x["state"] == R_OPEN and x["held"] == 0
else:
step = {R_PROGRESS: x["held"] > prev["held"], R_RESUME: x["held"] == prev["held"],
R_SEALED: x["held"] == prev["held"]}.get(x["state"], False)
chained = (x["seq"] == i and x["prev"] == prev["digest"] and x["at"] >= prev["at"]
and x["region"] == prev["region"] and prev["state"] != R_SEALED and step)
if not chained:
raise Stop(at, "upload_receipt_invalid")
if x["held"] > len(m["chunks"]) or x["root"] != root_of(m["chunks"][:x["held"]]):
raise Stop(at, "upload_receipt_mismatch")
if d["regions"] and x["region"] not in d["regions"]:
raise Stop(at, "store_outside_region")
prev = x
return prev
def single_take(f, m, clock, parts):
"""§9.4 single-take: None when it holds, else its reason."""
start, _, stop, commits = f
interval, jitter, o = clock
held = {s["ev"]["digest"] for s, _ in commits}
if any(x not in held for x in m["commits"]):
return "delivery_gap"
cs = [c for _, c in commits]
if not cs or not is_opening(cs[0]):
return "commit_chain_broken"
for (ps, p), (_, c) in zip(commits, commits[1:]):
if p["phase"] == CLOSE or c["phase"] == OPEN or c["n"] != p["n"] + 1 or c["prev"] != ps["ev"]["digest"] or c["first"] != p["size"]:
return "commit_chain_broken"
for p, c in zip(cs, cs[1:]):
if not extends(c["first"], p["root"], c["size"], c["root"], c["proof"]):
return "root_not_extending"
last = cs[-1]
if last["phase"] != CLOSE or stop is None:
return "take_not_closed"
ch = m["chunks"]
if len(ch) != last["size"] or any(c["size"] > 0 and root_of(ch[:c["size"]]) != c["root"] for c in cs):
return "manifest_not_committed"
if any(abs(c["offset"] - o) > jitter for c in cs):
return "clock_unstable"
if any(c["start"] >= c["end"] for c in ch) or any(b["start"] != a["end"] for a, b in zip(ch, ch[1:])):
return "capture_discontinuous"
if any(c["end"] - c["start"] > interval for c in ch):
return "chunk_too_long"
added = [(s["ev"]["t"], ch[c["first"]:c["size"]]) for s, c in commits]
if (commits[0][0]["ev"]["t"] < start["ev"]["t"] or commits[-1][0]["ev"]["t"] < stop["ev"]["t"]
or any(t + jitter < x["end"] + o for t, xs in added for x in xs)):
return "commit_early"
if any(t > x["end"] + o + interval + jitter for t, xs in added for x in xs):
return "commit_late"
for index, b in parts:
if index >= len(ch):
return "chunk_extra"
if not is_chunk(ch[index], b):
return "chunk_reordered" if any(c["index"] != index and is_chunk(c, b) for c in ch) else "chunk_altered"
return None
def aligned(f, m, clock):
"""§9.4 aligned."""
start, count_in_ms, _, commits = f
_, jitter, o = clock
if not commits or commits[0][0]["ev"]["digest"] != m["commits"][0]:
return "delivery_gap"
if not is_opening(commits[0][1]):
return "commit_chain_broken"
adds = next((c for _, c in commits if c["size"] > 0), None)
if adds is None or adds["size"] > len(m["chunks"]) or root_of(m["chunks"][:adds["size"]]) != adds["root"]:
return "manifest_not_committed"
if abs(m["chunks"][0]["start"] + o - (start["ev"]["t"] + count_in_ms * 1000)) > jitter:
return "misaligned"
return None
def complete(f, m, parts, last_receipt):
"""§9.4 complete."""
commits = f[3]
s, r = (commits[-1][1]["size"], commits[-1][1]["root"]) if commits else (0, ZERO)
if s == 0 or s > len(m["chunks"]) or root_of(m["chunks"][:s]) != r:
return "manifest_not_committed"
for c in m["chunks"][:s]:
if not any(i == c["index"] and is_chunk(c, b) for i, b in parts):
return "chunk_missing"
if last_receipt is None or last_receipt["state"] != R_SEALED or last_receipt["held"] != s:
return "upload_not_sealed"
return None
def publishable(p, m, consents, room, revoked, principal_minor, start_s, at):
"""§9.4 publishable for purpose bit p."""
for subject, digest in m["depicted"]:
c = next((c for c in consents if digest != ZERO and c["digest"] == digest and c["subject"] == subject
and c["room"] in (ZERO, room)), None)
if c is None:
return "consent_missing"
if (c["minor"] == 1 or (subject == m["principal"] and principal_minor)) and c["guardian"] == ZERO:
return "guardian_missing"
if c["digest"] in revoked:
return "consent_revoked"
if not (c["nbf"] <= start_s < c["exp"]) or not (c["nbf"] <= at < c["exp"]):
return "consent_expired"
if not c["purposes"] & RECORD or not c["purposes"] & p:
return "purpose_not_consented"
return None
def position(chunks, o, t):
"""§10: (chunk, into the chunk, into the master), or None outside the master."""
dv = t - o
for c in chunks:
if c["start"] <= dv < c["end"]:
return c["index"], dv - c["start"], dv - chunks[0]["start"]
return None
def verify_take(v):
"""§9: a dict of what the corpus states for the vector."""
descriptor = bytes.fromhex(v["descriptor"])
grants = [bytes.fromhex(g) for g in v["grants"]]
head = v["key_log_head"]
chains = [[bytes.fromhex(r) for r in c] for c in v["chains"]]
room_out = LRV.verify_room(descriptor, grants, head, chains)
if room_out["outcome"] == "refuse":
return {"outcome": "refuse", "reason": room_out["reason"], "at": ["room"] + room_out["at"]}
d = LRV.read_descriptor(descriptor)
held_grants, _ = LRV.check_grants(d, grants)
by_digest = {g["digest"]: g for g in held_grants}
seen, digests = [], set()
for c, i, g in room_out["order"]:
ev = LEV.read_record(chains[c][i])
if ev["digest"] not in digests:
digests.add(ev["digest"])
seen.append({"chain": c, "record": i, "ev": ev, "grant": bytes.fromhex(g) if g else None})
try:
try:
m = read_manifest(bytes.fromhex(v["manifest"]), d["room"])
except Refuse as e:
raise Stop(["manifest"], e.reason)
f = find(d, by_digest, seen, room_out.get("gaps", []), m)
last_receipt = check_upload(d, head, m, [bytes.fromhex(r) for r in v["receipts"]])
consents = []
for k, b in enumerate(v["consents"]):
try:
consents.append(read_consent(bytes.fromhex(b)))
except Refuse as e:
raise Stop(["consent", k], e.reason)
except Stop as e:
return {"outcome": "refuse", "reason": e.reason, "at": e.at}
_, _, _, commits = f
o = commits[0][1]["offset"] if commits else 0
clock = (d["commit_interval_ms"] * 1000, d["jitter_ms"] * 1000, o)
parts = [(p["index"], bytes.fromhex(p["bytes"])) for p in v["parts"]]
revoked = set()
if d["origin"] != b"":
revoked = {bytes.fromhex(x) for x in head_of(head)["revoked_modules"]}
first_grant = by_digest.get(commits[0][0]["grant"]) if commits else None
principal_minor = first_grant is not None and attrs_of(first_grant).get("minor") == "true"
start_s = (d["t0"] + f[0]["ev"]["t"]) // 1000000
verdicts = {
"single_take": single_take(f, m, clock, parts) or "holds",
"aligned": aligned(f, m, clock) or "holds",
"complete": complete(f, m, parts, last_receipt) or "holds",
"publishable": {n: publishable(b, m, consents, d["room"], revoked, principal_minor, start_s, v["evaluated_at"]) or "holds"
for b, n in PURPOSES},
}
claimed = [(SINGLE_TAKE, "single_take"), (ALIGNED, "aligned"), (COMPLETE, "complete")]
stands = (all(not m["claims"] & b or verdicts[k] == "holds" for b, k in claimed)
and all(not m["purposes"] & b or verdicts["publishable"][n] == "holds" for b, n in PURPOSES))
markers = []
if verdicts["aligned"] == "holds":
for s in seen:
if s["ev"]["kind"] == MARKER:
at = position(m["chunks"], o, s["ev"]["t"])
if at is not None:
markers.append({"event": s["ev"]["digest"].hex(), "t": s["ev"]["t"], "chunk": at[0],
"into_chunk": at[1], "into_master": at[2]})
last = commits[-1][1] if commits else None
return {"outcome": "verdicts", "take": m["take"].hex(), "stream_id": stream_id(m["take"]),
"commits": [s["ev"]["digest"].hex() for s, _ in commits],
"size": last["size"] if last else 0, "root": (last["root"] if last else ZERO).hex(),
"verdicts": verdicts, "stands": stands, "markers": markers}
# ---------------------------------------------------------------- the corpus
def problems_of(v):
"""What differs between the vector's statement and this verifier's outcome."""
try:
got = verify_take(v)
except Exception as e: # a crash on a mutated copy is a failure to read it
return ["the vector does not verify: %s: %s" % (type(e).__name__, e)], None
out = [k for k in got if v.get(k) != got[k]]
out += [k for k in ("outcome", "reason", "at") if k in v and k not in got]
return out, got
def check(doc, quiet=False):
"""Every vector against its statement: the failures."""
fails = []
for v in doc["vectors"]:
problems, got = problems_of(v)
if not quiet:
what = "?" if got is None else got["outcome"] + (" " + got["reason"] if got["outcome"] == "refuse" else "")
print(("PASS" if not problems else "FAIL") + " vector %s: %s" % (v["name"], what))
for k in problems:
fails.append("%s: %s is %s, the corpus states %s" % (
v["name"], k, json.dumps(got.get(k))[:300] if got else k, json.dumps(v.get(k))[:300]))
return fails
def flip(h, at):
"""Hexadecimal h with the byte at `at` (from the end when negative) changed."""
b = bytearray(bytes.fromhex(h))
b[at] ^= 0x01
return b.hex()
def mutations(doc):
"""Copies of the corpus, each with one defect a conforming verifier must catch."""
names = {v["name"]: k for k, v in enumerate(doc["vectors"])}
lesson = names["lesson"]
def mutate(label, f):
d = copy.deepcopy(doc)
f(d["vectors"])
return label, d
def device_chain(vs, k):
dev = doc["principals"]["device"]
return next(c for c in vs[k]["chains"] if LEV.read_record(bytes.fromhex(c[0]))["principal"].hex() == dev)
out = [
mutate("a byte of the lesson's chunk 2 part", lambda vs: vs[lesson]["parts"][2].update(bytes=flip(vs[lesson]["parts"][2]["bytes"], 5))),
mutate("the lesson's parts 0 and 1 swapped", lambda vs: vs[lesson]["parts"][0].update(bytes=vs[lesson]["parts"][1]["bytes"])),
mutate("the lesson's last part dropped", lambda vs: vs[lesson]["parts"].pop()),
mutate("a byte of the lesson's manifest signature", lambda vs: vs[lesson].update(manifest=flip(vs[lesson]["manifest"], -10))),
# Chunk 0's descriptor begins at byte 203 of a manifest (§7.1); byte 8 of it is `start`'s.
mutate("a byte of the lesson's chunk 0 descriptor", lambda vs: vs[lesson].update(manifest=flip(vs[lesson]["manifest"], 203 + 8))),
mutate("the lesson's sealed receipt removed", lambda vs: vs[lesson]["receipts"].pop()),
mutate("a byte of the lesson's consent", lambda vs: vs[lesson]["consents"].__setitem__(0, flip(vs[lesson]["consents"][0], 70))),
mutate("the lesson's second commitment not delivered", lambda vs: device_chain(vs, lesson).pop(1)),
mutate("the lesson evaluated a year on", lambda vs: vs[lesson].update(evaluated_at=vs[lesson]["evaluated_at"] + 365 * 86400)),
mutate("the lesson's stated root", lambda vs: vs[lesson].update(root="00" * 32)),
mutate("the lesson's stated stream id", lambda vs: vs[lesson].update(stream_id="0" + vs[lesson]["stream_id"][1:])),
mutate("the lesson's stated marker position", lambda vs: vs[lesson]["markers"][0].update(into_master=vs[lesson]["markers"][0]["into_master"] + 1)),
mutate("the missing chunk stated complete", lambda vs: vs[names["missing_chunk"]]["verdicts"].update(complete="holds")),
mutate("the late commitment stated single", lambda vs: vs[names["late_commit"]]["verdicts"].update(single_take="holds")),
mutate("the misaligned take stated aligned", lambda vs: vs[names["misaligned"]]["verdicts"].update(aligned="holds")),
mutate("the revoked consent stated publishable", lambda vs: vs[names["consent_revoked"]]["verdicts"]["publishable"].update(record="holds")),
mutate("the impostor's refusal at the manifest", lambda vs: vs[names["device_not_bound"]].update(at=["manifest"])),
mutate("the store outside its region stated listed", lambda vs: vs[names["store_outside_region"]].update(reason="store_not_listed")),
]
return out
def main(corpus_dir):
fails = []
st = ED._self_test()
print(("PASS" if not st else "FAIL") + " Ed25519 strict rule self-test " + ",".join(st))
if st:
fails.append("ed25519 self-test")
with open(os.path.join(corpus_dir, "vectors.json"), encoding="utf-8") as f:
doc = json.load(f)
fails += check(doc)
seen = set()
for v in doc["vectors"]:
if v.get("reason"):
seen.add(v["reason"])
if "verdicts" in v:
vd = v["verdicts"]
seen |= {vd["single_take"], vd["aligned"], vd["complete"]} | set(vd["publishable"].values())
missing = [r for r in REASONS + ["delivery_gap"] if r not in seen]
if missing:
fails.append("no vector gives %s" % ", ".join(missing))
takes = ["lesson", "resumed_upload", "minor_guardian"]
absent = [t for t in takes if not any(v["name"] == t and v.get("stands") for v in doc["vectors"])]
if absent:
fails.append("good takes that do not stand: %s" % ", ".join(absent))
print("%s every reason of §12 appears, and every good take of §13 stands" % ("PASS" if not (missing or absent) else "FAIL"))
for label, mutated in mutations(doc):
changed = [v for v, o in zip(mutated["vectors"], doc["vectors"]) if v != o]
caught = check({"vectors": changed}, quiet=True) if changed else []
print(("PASS" if caught else "FAIL") + " mutated copy fails: %s%s" % (label, " (%s)" % caught[0][:120] if caught else ""))
if not caught:
fails.append("a mutated copy verifies: %s" % label)
for f in fails:
print("FAIL " + f)
print(("PASS" if not fails else "FAIL") + " capture-binding corpus: %d vectors" % len(doc["vectors"]))
return 1 if fails else 0
if __name__ == "__main__":
if len(sys.argv) != 2:
print("usage: capture_take_verify.py <live-conformance/takes>", file=sys.stderr)
sys.exit(2)
sys.exit(main(sys.argv[1]))

View file

@ -0,0 +1,98 @@
# live-conformance/events — live events
The corpus of [`extensions/live-events.md`](../../extensions/live-events.md) §10: `WEV1`
event records in stream order, the policies their principals are held to, and the outcome
a verifier gives them; `WEP1` policy records; and the records' bindings onto MoQ, LL-HLS
and CMAF/DASH.
**Live-event equivalence.** For each vector, a conforming verifier of the vector's room,
holding each principal to the policy the vector names for it (`policies`, else
`default_policy`) and offered `events` one at a time in the order given, reaches the
outcome the vector states (§6.4):
- `refuse`: the `reason` and the `index` of the first record refused (§6.1–§6.3, or §7's
`tally_mismatch` at the index of the `poll_close` whose stated counts the replay does not
reach);
- `gap`: no record refused, and the `gaps` (principal, first and last seq not held) in the
order found, under the code `delivery_gap` (§6.2);
- `accept`: no record refused, no gap, and every stated tally equal to the replay.
A vector that does not refuse also states each record's SHA-256 digest (§3.2). An accepted
vector states the tally (§7): each poll's state and counts, and the BLAKE3 state hash of
the `wai.feed.state` records they replay to; and each record's MSF event timeline record,
`EXT-X-DATERANGE` tag and version 1 `emsg` box (§8), which a verifier must render byte for
byte and read back to the record. A verifier that reaches another outcome, reason, index,
gap, digest, tally or rendering does not conform.
**Provenance.** Generated here: `wai_live_events_vectors gen` writes every record from
events authored in code, from fixed seeds. Each principal's key is SHA-256 of a fixed label
and the principal's index, each room id likewise, every time is a constant, and Ed25519
signing is deterministic (RFC 8032), so the file is the same bytes on every machine. The
generator asserts that each vector reaches the outcome it was written to have before it
writes it. The SCTE 35-1 sections are built to that standard's syntax, with their CRC; the
SCTE 35-2 message is a stand-in byte string, which the extension carries opaque. No
recorded data.
## Layout
One file, `vectors.json`:
- `t0_unix_us`, `pt0_ms`: the room's `t0` as microseconds since 1970 (for the MSF `t`
index and `START-DATE`), and the presentation time of `t0` in milliseconds (for
`emsg`).
- `principals`, `rooms`: the keys and room ids, by name (informative).
- `policies`: the policy records the vectors name, in hexadecimal: `default` (§5's
defaults), `authority` (the defaults with the lifecycle kinds), `chat_only` (chat,
reaction and vote) and `slow` (the defaults with `max_events` 3).
- `vectors`: each with `name`, `about`, `room`, `default_policy`, `policies` (principal
key → policy name), `events` (records in hexadecimal, in stream order), `outcome`, and
as above `reason`, `index`, `gaps`, `digests`, `tally` and `bindings` (per record:
`msf`, `daterange`, and `emsg` in hexadecimal).
- `policy_cases`: policy records (§5), each read (`accept`) or refused with its reason.
- `binding_cases`: an `input` for one `binding` (`daterange`, `emsg` in hexadecimal, or
`msf`), read back to its `record` or refused with its reason (§8).
Vectors:
- one accepted vector per kind of §4 (`kind_*`): metadata that is empty, anchored, and at the
default bound; cues of every type, with and without an SCTE-35 message of each form; chat across
scripts and at the text bound; standalone reactions exactly one window apart and targeted
ones; a poll with a changed vote, a vote after the close and a vote for no poll, closed
with its tally, and a poll left open; markers with and without an attachment; every
control command; captions of each origin; moderation decisions; take commitments; and
the lifecycle kinds under the authority's policy;
- `room_interleaved`: several principals' chains interleaved in delivery order across
three one-minute groups, with anchored events and a poll;
- `join_set_repeats` and `joiner_mid_stream`: one host's track as a continuous subscriber
receives it, with each group's first object repeating its join set (§8.2), and as a
subscriber that joins at group 1 receives it, which reports what it does not hold as
gaps;
- `chain_gap`: a chain with a seq not delivered;
- one negative control per refusal, each a single defect refused with its reason: a
flipped signature bit; three forgeries a lenient Ed25519 verifier accepts (an identity
key, its non-canonical spelling, an `R` of small order); a broken chain and a broken
genesis; a reorder; a replayed seq; `t` going back; an oversize payload; a kind not
permitted, and a lifecycle kind from a participant; the event rate and the reaction rate
exceeded; an overlong and a surrogate UTF-8 encoding; text over the bound; two
truncations; an unknown version; a bad magic, trailing bytes and a bad anchor flag;
an unknown kind and a reserved lifecycle kind; the wrong room; a malformed cue,
language tag and poll; and a stated tally the replay does not reach.
## Running
```
cd ../../wai-rs
cargo run --no-default-features --features live_events --bin wai_live_events_vectors -- verify ../live-conformance/events
cargo run --no-default-features --features live_events --bin wai_live_events_vectors -- gen ../live-conformance/events # regenerate
cargo test --no-default-features --features live_events --test live_events
cd ../live-conformance
python3 live_events_verify.py events
```
`wai_live_events_vectors verify` and `tests/live_events.rs` require the file to regenerate
byte for byte and `wai-rs` to give every case its outcome; the test also requires the
corpus to cover every kind and every reason, and the extension's §9 to list exactly the
reasons the reference reports. `live_events_verify.py` is the second implementation,
written from the extension's text and sharing no code with `wai-rs`: it gives every case
the same outcome, renders every binding itself, and requires the same coverage. Pass = an
exit status of 0 from each.

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,754 @@
#!/usr/bin/env python3
"""Independent verifier of the live-events corpus (extensions/live-events.md).
Written from the extension's text. It shares no code with wai-rs, which generates the
corpus, and never runs it. It checks Ed25519 signatures with
../claim-conformance/_ed25519.py, WAI's strict rule (SPEC §7 *Signatures*) written from
RFC 8032 with the standard library, and computes BLAKE3 with
../claim-conformance/_blake3.py, written from its specification. Everything else is here:
- the record (§3): its reader and refusals, its digest (SHA-256 without `sig`) and the
bytes its signature covers;
- every kind's payload (§4) and the text rules (§4.1);
- the policy record (§5) and every policy case;
- verification (§6): one record, each principal's chain in stream order with duplicates,
gaps and their refusals, both rates, and the outcome of a sequence;
- the poll tallies (§7): which votes count and which close closes, the feed0 records and
their state hash, and the check of a stated tally;
- the bindings (§8): the MSF record, the EXT-X-DATERANGE tag and the version 1 emsg box,
each rendered byte for byte and read back, and every binding case.
For every vector it requires the outcome, reason, index, gaps, digests, tally and
renderings the corpus states; and of the corpus, that every kind has an accepted event
and every reason a case.
python3 live_events_verify.py <live-conformance/events>
Exit status 0 only if every case verifies. Python 3.8 or later; standard library only.
"""
import base64
import datetime
import hashlib
import json
import os
import re
import struct
import sys
HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, os.path.join(HERE, "..", "claim-conformance"))
from _blake3 import blake3 # noqa: E402
import _ed25519 as ED # noqa: E402
MAGIC = b"WEV1"
POLICY_MAGIC = b"WEP1"
SIGN_DOMAIN = b"wai:live-event\x01"
EVENT_TYPE = "urn:wai:event:1"
HLS_CLASS = "science.transaction.wai.event"
HLS_TAG = "#EXT-X-DATERANGE:"
MAX_SAFE = (1 << 53) - 1
# §4: the kinds and their names.
KINDS = {
0x01: "meta", 0x02: "cue", 0x03: "chat", 0x04: "reaction", 0x05: "poll_open",
0x06: "vote", 0x07: "poll_close", 0x08: "marker", 0x09: "control", 0x0A: "caption",
0x0B: "decision", 0x0C: "take_commit", 0x10: "join", 0x11: "leave", 0x12: "publish",
0x13: "unpublish", 0x14: "grant_exchange", 0x15: "takeover", 0x16: "failover",
}
LIFECYCLE = range(0x10, 0x17)
# §9, in its order.
REASONS = [
"truncated", "malformed", "unknown_version", "unknown_kind", "wrong_room", "bad_key",
"bad_signature", "kind_not_permitted", "payload_too_large", "malformed_payload",
"bad_utf8", "text_too_long", "seq_replay", "seq_not_increasing", "chain_broken",
"time_reversed", "delivery_gap", "rate_exceeded", "tally_mismatch", "policy_malformed",
"binding_malformed", "binding_mismatch",
]
class Refuse(Exception):
def __init__(self, reason):
super().__init__(reason)
self.reason = reason
class Reader:
"""Reads fields in order; running out raises the reason the caller names."""
def __init__(self, b, short):
self.b, self.p, self.short = b, 0, short
def take(self, n):
if self.p + n > len(self.b):
raise Refuse(self.short)
s = self.b[self.p:self.p + n]
self.p += n
return s
def u8(self):
return self.take(1)[0]
def u16(self):
return struct.unpack("<H", self.take(2))[0]
def u32(self):
return struct.unpack("<I", self.take(4))[0]
def u64(self):
return struct.unpack("<Q", self.take(8))[0]
def rest(self):
s = self.b[self.p:]
self.p = len(self.b)
return s
def done(self):
return self.p == len(self.b)
# ---------------------------------------------------------------- §3 the record
def read_record(b):
"""§3.3."""
if len(b) < 4:
raise Refuse("truncated")
if b[:4] != MAGIC:
raise Refuse("malformed")
if len(b) < 5:
raise Refuse("truncated")
if b[4] != 1:
raise Refuse("unknown_version")
r = Reader(b, "truncated")
r.take(5)
ev = {"room": r.take(32), "principal": r.take(32), "seq": r.u64(), "prev": r.take(32), "t": r.u64()}
flag = r.u8()
if flag == 0:
ev["anchor"] = None
elif flag == 1:
ev["anchor"] = (r.take(32), r.u64(), r.u64())
else:
raise Refuse("malformed")
ev["kind"] = r.u8()
n = r.u16()
ev["payload"] = r.take(n)
ev["body_len"] = r.p
ev["sig"] = r.take(64)
if not r.done():
raise Refuse("malformed")
ev["body"] = b[:ev["body_len"]]
ev["digest"] = hashlib.sha256(ev["body"]).digest()
return ev
# ---------------------------------------------------------------- §4 payloads
LANG = re.compile(r"[A-Za-z]{2,8}(-[A-Za-z0-9]{1,8})*")
def text(r, max_cp):
"""§4.1."""
n = r.u16()
raw = r.take(n)
try:
s = raw.decode("utf-8")
except UnicodeDecodeError:
raise Refuse("bad_utf8")
if len(s) > max_cp:
raise Refuse("text_too_long")
return s
def splice_info_section_ok(b):
"""§4.3, form 1."""
return len(b) >= 3 and b[0] == 0xFC and 3 + (((b[1] & 0x0F) << 8) | b[2]) == len(b)
def read_payload(kind, b, max_cp):
"""§4: the payload as a dict, or Refuse."""
m = "malformed_payload"
r = Reader(b, m)
p = {"kind": kind}
if kind in (0x01, 0x0C):
p["bytes"] = r.rest()
elif kind == 0x02:
p["type"] = r.u8()
if p["type"] not in (1, 2, 3, 4):
raise Refuse(m)
p["cue_id"], p["duration_ms"] = r.u32(), r.u32()
form = r.u8()
p["form"] = form
if form in (1, 2):
p["scte"] = r.take(r.u16())
if form == 1 and not splice_info_section_ok(p["scte"]):
raise Refuse(m)
if form == 2 and len(p["scte"]) == 0:
raise Refuse(m)
elif form != 0:
raise Refuse(m)
elif kind == 0x03:
p["text"] = text(r, max_cp)
elif kind == 0x04:
p["type"] = r.u8()
if p["type"] not in (1, 2, 3, 4, 5):
raise Refuse(m)
p["value"] = r.u16()
if p["value"] == 0:
raise Refuse(m)
flags = r.u8()
if flags & ~0x03:
raise Refuse(m)
p["target"] = r.take(32) if flags & 1 else None
p["remove"] = bool(flags & 2)
elif kind == 0x05:
p["question"] = text(r, max_cp)
n = r.u8()
if n < 2:
raise Refuse(m)
p["options"] = [text(r, max_cp) for _ in range(n)]
elif kind == 0x06:
p["poll"], p["option"] = r.take(32), r.u8()
elif kind == 0x07:
p["poll"] = r.take(32)
n = r.u8()
if n < 2:
raise Refuse(m)
p["counts"] = [r.u32() for _ in range(n)]
elif kind == 0x08:
p["taxonomy"] = r.u32()
p["polarity"] = r.u8()
if p["polarity"] > 2:
raise Refuse(m)
p["text"] = text(r, max_cp)
has = r.u8()
if has not in (0, 1):
raise Refuse(m)
p["attachment"] = r.take(32) if has else None
elif kind == 0x09:
p["target"] = r.take(32)
c = r.u8()
p["command"] = c
if c == 1:
p["count_in_ms"] = r.u32()
elif c == 2:
pass
elif c == 3:
p["q16"] = r.u32()
if p["q16"] == 0:
raise Refuse(m)
elif c == 4:
p["on"] = r.u8()
if p["on"] not in (0, 1):
raise Refuse(m)
elif c == 5:
p["layer"] = r.u8()
else:
raise Refuse(m)
elif kind == 0x0A:
p["duration_ms"] = r.u32()
p["origin"] = r.u8()
if p["origin"] == 1:
p["model"] = None
elif p["origin"] in (2, 3):
p["model"] = r.take(32)
else:
raise Refuse(m)
lb = r.take(r.u8())
try:
lang = lb.decode("ascii")
except UnicodeDecodeError:
raise Refuse(m)
if not (2 <= len(lang) <= 35 and LANG.fullmatch(lang)):
raise Refuse(m)
p["lang"] = lang
p["text"] = text(r, max_cp)
elif kind == 0x0B:
p["verdict"] = r.u8()
if p["verdict"] not in (1, 2, 3):
raise Refuse(m)
p["target"], p["reason"] = r.take(32), r.u16()
elif kind in LIFECYCLE:
p["subject"], p["detail"] = r.take(32), r.rest()
else:
raise Refuse("unknown_kind")
if not r.done():
raise Refuse(m)
return p
# ---------------------------------------------------------------- §5 the policy
def read_policy(b):
if len(b) < 4:
raise Refuse("truncated")
if b[:4] != POLICY_MAGIC:
raise Refuse("policy_malformed")
if len(b) < 5:
raise Refuse("truncated")
if b[4] != 1:
raise Refuse("unknown_version")
if len(b) < 65:
raise Refuse("truncated")
if len(b) > 65:
raise Refuse("policy_malformed")
kinds = b[5:37]
max_payload, max_cp, max_events, window, max_reactions, rwindow = struct.unpack("<HHIQIQ", b[37:65])
for k in range(256):
if kinds[k // 8] >> (k % 8) & 1 and k not in KINDS:
raise Refuse("policy_malformed")
if 0 in (max_payload, max_cp, max_events, window, max_reactions, rwindow):
raise Refuse("policy_malformed")
return {"kinds": kinds, "max_payload": max_payload, "max_text_cp": max_cp, "max_events": max_events,
"window_us": window, "max_reactions": max_reactions, "reaction_window_us": rwindow}
def permits(policy, k):
return policy["kinds"][k // 8] >> (k % 8) & 1 == 1
# ---------------------------------------------------------------- §6 verification
class Verifier:
def __init__(self, room, default_policy, own):
self.room, self.default, self.own = room, default_policy, own
self.chains = {} # principal -> {"held": {seq: digest}, "head": (seq, digest, t) | None, "times": [], "reactions": []}
self.accepted = [] # (index, ev, payload)
self.gaps = []
self.offered = 0
def offer(self, b):
index = self.offered
self.offered += 1
ev = read_record(b) # §6.1 step 1
if ev["kind"] not in KINDS: # step 2
raise Refuse("unknown_kind")
if ev["room"] != self.room: # step 3
raise Refuse("wrong_room")
bad = ED.verify(ev["principal"], SIGN_DOMAIN + ev["body"], ev["sig"]) # step 4
if bad is not None:
raise Refuse("bad_key" if bad == "bad_key" else "bad_signature")
policy = self.own.get(ev["principal"], self.default)
if not permits(policy, ev["kind"]): # step 5
raise Refuse("kind_not_permitted")
if len(ev["payload"]) > policy["max_payload"]: # step 6
raise Refuse("payload_too_large")
p = read_payload(ev["kind"], ev["payload"], policy["max_text_cp"]) # step 7
c = self.chains.setdefault(ev["principal"], {"held": {}, "head": None, "times": [], "reactions": []})
seq, d, t = ev["seq"], ev["digest"], ev["t"]
if c["held"].get(seq) == d: # §6.2 step 1
return "duplicate"
gap = None
if c["head"] is not None: # step 2
hseq, hd, ht = c["head"]
if seq <= hseq:
raise Refuse("seq_replay" if seq in c["held"] else "seq_not_increasing")
if seq == hseq + 1:
if ev["prev"] != hd:
raise Refuse("chain_broken")
else:
gap = (hseq + 1, seq - 1)
if t < ht:
raise Refuse("time_reversed")
elif seq == 0: # step 3
if ev["prev"] != bytes(32):
raise Refuse("chain_broken")
else:
gap = (0, seq - 1)
within = lambda times, w: 1 + sum(1 for f in times if t - f < w) # noqa: E731
if within(c["times"], policy["window_us"]) > policy["max_events"]: # §6.3
raise Refuse("rate_exceeded")
standalone = ev["kind"] == 0x04 and p["target"] is None
if standalone and within(c["reactions"], policy["reaction_window_us"]) > policy["max_reactions"]:
raise Refuse("rate_exceeded")
c["held"][seq] = d
c["head"] = (seq, d, t)
c["times"].append(t)
if standalone:
c["reactions"].append(t)
if gap:
self.gaps.append((ev["principal"], gap[0], gap[1]))
self.accepted.append((index, ev, p))
return "accepted"
def run(verifier, records):
"""§6.4: ("refuse", reason, index) | ("gap", gaps) | ("accept",)."""
for i, b in enumerate(records):
try:
verifier.offer(b)
except Refuse as e:
return ("refuse", e.reason, i)
if verifier.gaps:
return ("gap", list(verifier.gaps))
bad = stated_tally_mismatch(verifier.accepted)
if bad is not None:
return ("refuse", "tally_mismatch", bad)
return ("accept",)
# ---------------------------------------------------------------- §7 polls
def poll_state(accepted):
polls = {}
for _, ev, p in accepted:
if p["kind"] == 0x05:
polls[ev["digest"]] = {"author": ev["principal"], "seq": ev["seq"], "n": len(p["options"]), "t": ev["t"]}
closes = {}
for index, ev, p in accepted:
if p["kind"] == 0x07 and p["poll"] in polls and polls[p["poll"]]["author"] == ev["principal"]:
cur = closes.get(p["poll"])
if cur is None or ev["seq"] < cur["seq"]:
closes[p["poll"]] = {"seq": ev["seq"], "t": ev["t"], "index": index, "counts": p["counts"]}
votes = {}
for _, ev, p in sorted((a for a in accepted if a[2]["kind"] == 0x06), key=lambda a: (a[1]["principal"], a[1]["seq"])):
poll = polls.get(p["poll"])
if poll is None or p["option"] >= poll["n"] or ev["t"] < poll["t"]:
continue
close = closes.get(p["poll"])
if close is not None and ev["t"] >= close["t"]:
continue
votes[(p["poll"], ev["principal"])] = p["option"]
return polls, votes, closes
def counts_of(poll_digest, poll, votes):
counts = [0] * poll["n"]
for (d, _), option in votes.items():
if d == poll_digest:
counts[option] += 1
return counts
def node_id(author, seq):
return blake3(b"wai:world-node\x01" + author + struct.pack("<Q", seq))[:16]
def tally(accepted):
"""The feed0 state the polls replay to, and each poll's cells."""
polls, votes, closes = poll_state(accepted)
nodes = {}
out = []
for d in sorted(polls):
poll = polls[d]
state = 0 if d in closes else 1
counts = counts_of(d, poll, votes)
cells = [state] + counts
body = struct.pack("<H", len(cells)) + b"".join(b"\x01" + struct.pack("<q", c) for c in cells)
nodes[node_id(poll["author"], poll["seq"])] = body
out.append({"poll": d.hex(), "open": state == 1, "counts": counts})
canon = struct.pack("<I", len(nodes)) + b"".join(k + struct.pack("<I", len(v)) + v for k, v in sorted(nodes.items()))
return {"state_hash": blake3(b"wai:world-state\x01" + canon).hex(), "polls": out}
def stated_tally_mismatch(accepted):
polls, votes, closes = poll_state(accepted)
bad = [c["index"] for d, c in closes.items() if c["counts"] != counts_of(d, polls[d], votes)]
return min(bad) if bad else None
# ---------------------------------------------------------------- §8 bindings
def b64url(b):
return base64.urlsafe_b64encode(b).decode().rstrip("=")
def b64url_read(s):
"""§8.1: canonical base64url without padding, or None."""
if not re.fullmatch(r"[A-Za-z0-9_-]*", s) or len(s) % 4 == 1:
return None
try:
b = base64.urlsafe_b64decode(s + "=" * (-len(s) % 4))
except ValueError:
return None
return b if b64url(b) == s else None
def bound_record(b):
"""The record a binding carries: one §3.3 reads with a kind §4 defines, with a cue's or
caption's payload read for the fields a binding derives."""
try:
ev = read_record(b)
if ev["kind"] not in KINDS:
raise Refuse("unknown_kind")
ev["p"] = read_payload(ev["kind"], ev["payload"], 0xFFFF) if ev["kind"] in (0x02, 0x0A) else None
except Refuse:
raise Refuse("binding_malformed")
return ev
def wallclock_ms(ev, t0):
return (t0 + ev["t"]) // 1000
def iso(ms):
try:
d = datetime.datetime(1970, 1, 1) + datetime.timedelta(milliseconds=ms)
except OverflowError:
raise Refuse("binding_malformed")
return d.strftime("%Y-%m-%dT%H:%M:%S") + ".%03dZ" % (ms % 1000)
def seconds(ms):
return "%d.%03d" % (ms // 1000, ms % 1000)
def msf_render(b, t0):
ev = bound_record(b)
a = ev["anchor"]
if a is not None and a[1] <= MAX_SAFE and a[2] <= MAX_SAFE:
idx = '"l":[%d,%d]' % (a[1], a[2])
else:
ms = wallclock_ms(ev, t0)
if ms > MAX_SAFE:
raise Refuse("binding_malformed")
idx = '"t":%d' % ms
return '{%s,"data":{"wev1":"%s"}}' % (idx, b64url(b))
def is_int(v):
return isinstance(v, int) and not isinstance(v, bool)
def msf_read(s, t0):
try:
o = json.loads(s)
except ValueError:
raise Refuse("binding_malformed")
if not isinstance(o, dict) or not isinstance(o.get("data"), dict) or not isinstance(o["data"].get("wev1"), str):
raise Refuse("binding_malformed")
b = b64url_read(o["data"]["wev1"])
if b is None:
raise Refuse("binding_malformed")
ev = bound_record(b)
if sum(k in o for k in ("t", "l", "m")) != 1:
raise Refuse("binding_malformed")
want = json.loads(msf_render(b, t0))
if "l" in want:
got = o.get("l")
ok = isinstance(got, list) and len(got) == 2 and all(is_int(x) for x in got) and got == want["l"]
else:
ok = is_int(o.get("t")) and o["t"] == want["t"]
if not ok:
raise Refuse("binding_mismatch")
del ev
return b
def daterange_attrs(b, t0):
"""§8.3's table: (name, value as written), in order."""
ev = bound_record(b)
a = [("ID", '"%s"' % ev["digest"].hex()), ("CLASS", '"%s"' % HLS_CLASS),
("START-DATE", '"%s"' % iso(wallclock_ms(ev, t0)))]
p = ev["p"]
if p is not None and p["kind"] == 0x02:
if p["duration_ms"] > 0:
a.append(("PLANNED-DURATION" if p["type"] in (1, 4) else "DURATION", seconds(p["duration_ms"])))
if p["form"] == 1:
name = {1: "SCTE35-OUT", 4: "SCTE35-OUT", 2: "SCTE35-IN"}.get(p["type"], "SCTE35-CMD")
a.append((name, "0x" + p["scte"].hex().upper()))
elif p is not None and p["duration_ms"] > 0:
a.append(("DURATION", seconds(p["duration_ms"])))
a.append(("X-WAI-EVENT", '"%s"' % b64url(b)))
return a
DATERANGE_NAMES = ("ID", "CLASS", "START-DATE", "DURATION", "PLANNED-DURATION", "SCTE35-CMD", "SCTE35-OUT", "SCTE35-IN", "X-WAI-EVENT")
def daterange_render(b, t0):
return HLS_TAG + ",".join("%s=%s" % nv for nv in daterange_attrs(b, t0))
def attribute_list(s):
"""RFC 8216 §4.2, as §8.3 reads it; None when it does not read."""
out, i = {}, 0
while i < len(s):
m = re.match(r"[A-Z0-9-]+", s[i:])
if not m or i + m.end() >= len(s) or s[i + m.end()] != "=":
return None
name = m.group(0)
i += m.end() + 1
start = i
if s[i] == '"':
j = s.find('"', i + 1)
if j < 0:
return None
i = j + 1
else:
while i < len(s) and s[i] != ",":
i += 1
if i == start or name in out:
return None
out[name] = s[start:i]
if i < len(s):
if s[i] != "," or i + 1 == len(s):
return None
i += 1
return out
def daterange_read(line, t0):
if not line.startswith(HLS_TAG):
raise Refuse("binding_malformed")
got = attribute_list(line[len(HLS_TAG):])
if got is None or "X-WAI-EVENT" not in got:
raise Refuse("binding_malformed")
v = got["X-WAI-EVENT"]
if len(v) < 2 or v[0] != '"' or v[-1] != '"':
raise Refuse("binding_malformed")
b = b64url_read(v[1:-1])
if b is None:
raise Refuse("binding_malformed")
want = dict(daterange_attrs(b, t0))
for name in DATERANGE_NAMES:
if got.get(name) != want.get(name):
raise Refuse("binding_mismatch")
return b
def emsg_render(b, pt0):
ev = bound_record(b)
p = ev["p"]
if p is not None:
duration = p["duration_ms"] if p["duration_ms"] else 0xFFFFFFFF
else:
duration = 0
pt = pt0 + ev["t"] // 1000
if pt >= 1 << 64:
raise Refuse("binding_malformed")
body = struct.pack(">BBBBIQI", 1, 0, 0, 0, 1000, pt, duration) + ev["digest"][:4]
body += EVENT_TYPE.encode() + b"\x00" + KINDS[ev["kind"]].encode() + b"\x00" + b
return struct.pack(">I", 8 + len(body)) + b"emsg" + body
def emsg_read(box, pt0):
if len(box) < 32 or struct.unpack(">I", box[:4])[0] != len(box) or box[4:8] != b"emsg" or box[8] != 1:
raise Refuse("binding_malformed")
rest = box[32:]
z1 = rest.find(b"\x00")
z2 = rest.find(b"\x00", z1 + 1) if z1 >= 0 else -1
if z2 < 0:
raise Refuse("binding_malformed")
b = rest[z2 + 1:]
bound_record(b)
if emsg_render(b, pt0) != box:
raise Refuse("binding_mismatch")
return b
def read_binding(kind, value, t0, pt0):
if kind == "daterange":
return daterange_read(value, t0)
if kind == "msf":
return msf_read(value, t0)
if kind == "emsg":
try:
box = bytes.fromhex(value)
except ValueError:
raise Refuse("binding_malformed")
return emsg_read(box, pt0)
raise Refuse("binding_malformed")
# ---------------------------------------------------------------- the corpus
def main(corpus_dir):
fails = []
st = ED._self_test()
print(("PASS" if not st else "FAIL") + " Ed25519 strict rule self-test " + ",".join(st))
if st:
fails.append("ed25519 self-test")
with open(os.path.join(corpus_dir, "vectors.json"), encoding="utf-8") as f:
doc = json.load(f)
t0, pt0 = doc["t0_unix_us"], doc["pt0_ms"]
policies = {}
for name, h in doc["policies"].items():
try:
policies[name] = read_policy(bytes.fromhex(h))
except Refuse as e:
fails.append("policy %s: %s" % (name, e.reason))
rooms = {n: bytes.fromhex(h) for n, h in doc["rooms"].items()}
kinds_accepted, reasons_seen = set(), set()
n_vec = 0
for v in doc["vectors"]:
n_vec += 1
name = v["name"]
own = {bytes.fromhex(k): policies[p] for k, p in v["policies"].items()}
ver = Verifier(rooms[v["room"]], policies[v["default_policy"]], own)
records = [bytes.fromhex(e) for e in v["events"]]
out = run(ver, records)
problems = []
if out[0] != v["outcome"]:
problems.append("outcome %s, the corpus states %s" % (out, v["outcome"]))
elif out[0] == "refuse":
if (out[1], out[2]) != (v["reason"], v["index"]):
problems.append("refused %s at %d, the corpus states %s at %s" % (out[1], out[2], v["reason"], v["index"]))
elif out[0] == "gap":
gaps = [{"principal": p.hex(), "first": a, "last": z} for p, a, z in out[1]]
if gaps != v["gaps"] or v.get("reason") != "delivery_gap":
problems.append("gaps %s differ" % gaps)
if out[0] in ("accept", "gap"):
if [hashlib.sha256(read_record(r)["body"]).hexdigest() for r in records] != v["digests"]:
problems.append("digests differ")
if out[0] == "accept":
if tally(ver.accepted) != v["tally"]:
problems.append("tally %s differs" % tally(ver.accepted))
if len(v["bindings"]) != len(records):
problems.append("not one binding entry per event")
for i, (r, bnd) in enumerate(zip(records, v["bindings"])):
rendered = {"msf": msf_render(r, t0), "daterange": daterange_render(r, t0), "emsg": emsg_render(r, pt0).hex()}
if rendered != bnd:
problems.append("event %d: a rendering differs: %s" % (i, [k for k in rendered if rendered[k] != bnd.get(k)]))
for k in ("msf", "daterange", "emsg"):
try:
if read_binding(k, bnd[k], t0, pt0) != r:
problems.append("event %d: %s reads back to other bytes" % (i, k))
except Refuse as e:
problems.append("event %d: %s refused %s" % (i, k, e.reason))
for _, ev, _ in ver.accepted:
kinds_accepted.add(ev["kind"])
if v.get("reason"):
reasons_seen.add(v["reason"])
print(("PASS" if not problems else "FAIL") + " vector %s: %s%s" % (name, out[0], "" if out[0] == "accept" else " " + str(out[1] if out[0] == "refuse" else "delivery_gap")))
fails += ["%s: %s" % (name, p) for p in problems]
for c in doc["policy_cases"]:
try:
read_policy(bytes.fromhex(c["policy"]))
got = ("accept", None)
except Refuse as e:
got = ("refuse", e.reason)
want = (c["outcome"], c.get("reason"))
if got != want:
fails.append("policy case %s: %s, the corpus states %s" % (c["name"], got, want))
if c.get("reason"):
reasons_seen.add(c["reason"])
print("%s policy cases" % ("PASS" if not any(f.startswith("policy case") for f in fails) else "FAIL"))
for c in doc["binding_cases"]:
try:
got = ("accept", read_binding(c["binding"], c["input"], t0, pt0).hex())
except Refuse as e:
got = ("refuse", e.reason)
want = (c["outcome"], c["record"] if c["outcome"] == "accept" else c.get("reason"))
if got != want:
fails.append("binding case %s: %s, the corpus states %s" % (c["name"], got, want))
if c.get("reason"):
reasons_seen.add(c["reason"])
print("%s binding cases" % ("PASS" if not any(f.startswith("binding case") for f in fails) else "FAIL"))
missing_kinds = sorted(KINDS[k] for k in set(KINDS) - kinds_accepted)
missing_reasons = [r for r in REASONS if r not in reasons_seen]
if missing_kinds:
fails.append("no accepted event of kind %s" % ", ".join(missing_kinds))
if missing_reasons:
fails.append("no case refused with %s" % ", ".join(missing_reasons))
print("%s every kind accepted and every reason exercised" % ("PASS" if not (missing_kinds or missing_reasons) else "FAIL"))
for f in fails:
print("FAIL " + f)
print(("PASS" if not fails else "FAIL") + " live-events corpus: %d vectors, every policy and binding case" % n_vec)
return 1 if fails else 0
if __name__ == "__main__":
if len(sys.argv) != 2:
print("usage: live_events_verify.py <live-conformance/events>", file=sys.stderr)
sys.exit(2)
sys.exit(main(sys.argv[1]))

View file

@ -0,0 +1,986 @@
#!/usr/bin/env python3
"""Independent verifier of the live-rooms corpus (extensions/live-rooms.md).
Written from the extension's text. It shares no code with wai-rs, which generates the
corpus, and never runs it. It checks Ed25519 signatures with
../claim-conformance/_ed25519.py, WAI's strict rule (SPEC §7 *Signatures*) written from
RFC 8032 with the standard library, and computes BLAKE3 (for a key log's commitments)
with ../claim-conformance/_blake3.py. Every event is verified by the live-events corpus's
own second implementation, live_events_verify.py (live-events §6), which is likewise
written from its text alone. Everything else is here:
- the room descriptor (§3): its reader, its field rules, its digest and signature, and the
registered publish profiles (§8), each written from §8.2's table and compared with the
record the corpus states;
- the room grant (§4): its reader and the eight checks of §4.3 in their order, its
attributes (§4.4), and its window;
- revocation (§5): the key-log head held for the descriptor's origin (jwp-receipts §7.2
rules 1 to 3: its members, its canonical form and its root's signature), the
authority's entry, and the revoked grants and viewers;
- track naming (§6) and the lifecycle detail (§7.2), with slots, takeover and failover;
- room verification (§9): the merged order, each record's steps, the room state, the
outcome, and the `moqt` scopes of each grant (§10.2).
For every vector it requires the outcome, reason and place, and for one that does not
refuse the room id, the gaps or the grant digests, merged order, state, tally and scopes
the corpus states; and of the corpus, that every reason of §11 refuses a vector.
python3 live_rooms_verify.py <live-conformance/rooms>
Exit status 0 only if every vector verifies. Python 3.8 or later; standard library only.
"""
import hashlib
import json
import os
import re
import struct
import sys
HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, HERE)
sys.path.insert(0, os.path.join(HERE, "..", "claim-conformance"))
import live_events_verify as LEV # noqa: E402
from _blake3 import blake3 # noqa: E402
import _ed25519 as ED # noqa: E402
Refuse = LEV.Refuse
DESCRIPTOR_MAGIC = b"WRD1"
GRANT_MAGIC = b"WRG1"
DESCRIPTOR_DOMAIN = b"wai:live-room\x01"
GRANT_DOMAIN = b"wai:live-room-grant\x01"
VIEWER_DOMAIN = b"wai:live-viewer\x01"
AUTHORITY = "live/room"
MAX_GRANT_S = 14 * 86400
NO_CEILING = 2 ** 64 - 1
MEDIA = {1: "audio", 2: "video", 3: "screen"}
MODERATE, DIRECT, COMPOSE, RECORD = 1, 2, 4, 8
CONTROL, DECISION = 0x09, 0x0B
JOIN, LEAVE, PUBLISH, UNPUBLISH, EXCHANGE, TAKEOVER, FAILOVER = range(0x10, 0x17)
# §11, in its order.
REASONS = [
"descriptor_invalid", "key_log_missing", "key_log_not_held", "authority_not_listed",
"authority_revoked", "grant_invalid", "grant_too_long", "caps_exceed_role",
"grant_exceeds_room", "exchange_unlinked", "exchange_mismatch", "lifecycle_malformed",
"grant_unknown", "grant_wrong_principal", "grant_revoked", "grant_reused",
"already_present", "not_present", "participant_limit", "outside_grant_window",
"power_not_granted", "publish_not_permitted", "profile_not_granted", "already_published",
"not_published", "slot_unknown", "slot_full", "publisher_limit", "exchange_not_current",
"exchange_off_tick", "takeover_not_higher", "failover_not_standby", "failover_early",
]
class Reader:
"""Reads fields in order; running out is `truncated` (§3.4)."""
def __init__(self, b, p=0):
self.b, self.p = b, p
def take(self, n):
if self.p + n > len(self.b):
raise Refuse("truncated")
s = self.b[self.p:self.p + n]
self.p += n
return s
def u8(self):
return self.take(1)[0]
def u16(self):
return struct.unpack("<H", self.take(2))[0]
def u32(self):
return struct.unpack("<I", self.take(4))[0]
def u64(self):
return struct.unpack("<Q", self.take(8))[0]
def name(self):
return self.take(self.u8())
def string(self):
return self.take(self.u16())
def read_head(b, magic):
"""§3.4 step 1."""
if len(b) < 4:
raise Refuse("truncated")
if b[:4] != magic:
raise Refuse("malformed")
if len(b) < 5:
raise Refuse("truncated")
if b[4] != 1:
raise Refuse("unknown_version")
NAME = re.compile(rb"[a-z0-9_-]{1,32}")
SLOT_NAME = re.compile(rb"[a-z0-9_]{1,32}")
MODE = re.compile(rb"[A-Z0-9_]{1,16}")
def is_name(b):
return NAME.fullmatch(b) is not None
def is_slot_name(b):
return SLOT_NAME.fullmatch(b) is not None
def is_token(b, most):
return 1 <= len(b) <= most and all(0x21 <= c <= 0x7E for c in b)
def bitset(kinds):
out = bytearray(32)
for k in kinds:
out[k // 8] |= 1 << (k % 8)
return bytes(out)
def has(bm, k):
return bm[k // 8] >> (k % 8) & 1 == 1
def within(a, b):
"""Every bit of bitmap or byte `a` is set in `b`."""
if isinstance(a, int):
return a & ~b == 0
return all(x & ~y == 0 for x, y in zip(a, b))
def lifecycle_permitted(policy):
return any(LEV.permits(policy, k) for k in range(0x10, 0x20))
def powers_cover(kinds, powers):
"""§3.3: decision needs moderate; control needs direct or record."""
return (not has(kinds, DECISION) or powers & MODERATE) and (not has(kinds, CONTROL) or powers & (DIRECT | RECORD))
# ---------------------------------------------------------------- §8 profiles
def profile_bytes(name, media, codecs, layering, mode, layers, sample_rate, channels, processing):
"""§8.1's record."""
o = bytes([len(name)]) + name.encode() + bytes([media, len(codecs)])
for c in codecs:
o += bytes([len(c)]) + c.encode()
o += bytes([layering, len(mode)]) + mode.encode() + bytes([len(layers)])
for w, h, fps, lo, hi in layers:
o += struct.pack("<HHHII", w, h, fps, lo, hi)
return o + struct.pack("<IBB", sample_rate, channels, processing)
# §8.2's table: processing bit 0 echo cancellation, 1 noise suppression, 2 gain control, 3 DTX.
REGISTERED = {
"speech": profile_bytes("speech", 1, ["opus"], 0, "", [(0, 0, 0, 16, 64)], 48000, 1, 0b1111),
"music": profile_bytes("music", 1, ["opus"], 0, "", [(0, 0, 0, 128, 510)], 48000, 2, 0),
"screen": profile_bytes("screen", 3, ["av01.0.08M.08", "avc1.640028"], 2, "L1T3", [(1920, 1080, 15, 300, 2500)], 0, 0, 0),
"camera-720": profile_bytes("camera-720", 2, ["av01.0.05M.08", "avc1.64001F"], 1, "",
[(640, 360, 30, 150, 500), (1280, 720, 30, 500, 1500)], 0, 0, 0),
"camera-1080": profile_bytes("camera-1080", 2, ["av01.0.08M.08", "avc1.640028"], 1, "",
[(640, 360, 30, 150, 500), (1280, 720, 30, 500, 1500), (1920, 1080, 30, 1500, 4000)], 0, 0, 0),
}
def read_profile(r):
start = r.p
p = {"name": r.name(), "media": r.u8()}
p["codecs"] = [r.name() for _ in range(r.u8())]
p["layering"], p["mode"] = r.u8(), r.name()
p["layers"] = [struct.unpack("<HHHII", r.take(14)) for _ in range(r.u8())]
p["sample_rate"], p["channels"], p["processing"] = r.u32(), r.u8(), r.u8()
p["bytes"] = r.b[start:r.p]
return p
def profile_ok(p):
"""§8.1."""
if not is_name(p["name"]) or p["media"] not in MEDIA or not 1 <= len(p["codecs"]) <= 4:
return False
if not all(is_token(c, 64) for c in p["codecs"]) or p["layering"] > 3:
return False
if (p["layering"] == 2) != (len(p["mode"]) > 0) or (p["mode"] and not MODE.fullmatch(p["mode"])):
return False
n = len(p["layers"])
if not 1 <= n <= 8 or (p["layering"] == 0 and n != 1):
return False
audio = p["media"] == 1
for w, h, fps, lo, hi in p["layers"]:
if hi == 0 or lo > hi:
return False
if audio and (w, h, fps) != (0, 0, 0):
return False
if not audio and not (w and h and fps):
return False
if audio:
return p["sample_rate"] > 0 and 1 <= p["channels"] <= 8 and p["processing"] & ~0x0F == 0
return p["sample_rate"] == 0 and p["channels"] == 0 and p["processing"] == 0
# ---------------------------------------------------------------- §3 the descriptor
def read_descriptor(b):
"""§3.4, then §3.2: the descriptor, or Refuse."""
read_head(b, DESCRIPTOR_MAGIC)
r = Reader(b, 5)
d = {"authority": r.take(32), "clock": r.take(32), "t0": r.u64(), "tick": r.u64(), "origin": r.string(),
"max_present": r.u32(), "max_publishers": r.u16(), "reval": r.u32(), "ceiling": r.take(65)}
d["roles"] = []
for _ in range(r.u8()):
d["roles"].append({"name": r.name(), "publish": r.u8(), "subscribe": r.u8(), "max_layer": r.u8(),
"powers": r.u8(), "events": r.take(32)})
d["slots"] = []
for _ in range(r.u8()):
d["slots"].append({"name": r.name(), "media": r.u8(), "redundancy": r.u8(), "failover_ms": r.u32()})
d["profiles"] = [read_profile(r) for _ in range(r.u8())]
d["review"] = (r.take(32), r.u32(), r.u8())
d["mode"], d["retention"], d["commit_interval_ms"], d["jitter_ms"] = r.u8(), r.u16(), r.u32(), r.u32()
d["regions"] = [r.take(2) for _ in range(r.u8())]
body = b[:r.p]
d["sig"] = r.take(64)
if r.p != len(b):
raise Refuse("malformed")
if not descriptor_ok(d):
raise Refuse("descriptor_invalid")
bad = ED.verify(d["authority"], DESCRIPTOR_DOMAIN + body, d["sig"])
if bad:
raise Refuse(bad)
d["room"] = hashlib.sha256(body).digest()
try:
d["ceiling_policy"] = LEV.read_policy(d["ceiling"])
except Refuse:
raise Refuse("descriptor_invalid")
return d
def descriptor_ok(d):
"""§3.4's field rules."""
if d["tick"] == 0 or not (d["origin"] == b"" or is_token(d["origin"], 255)):
return False
if d["max_present"] == 0 or d["max_publishers"] == 0:
return False
try:
ceiling = LEV.read_policy(d["ceiling"])
except Refuse:
return False
if lifecycle_permitted(ceiling) or not 1 <= len(d["roles"]) <= 16:
return False
names = [x["name"] for x in d["roles"]]
if len(set(names)) != len(names):
return False
for x in d["roles"]:
if not is_name(x["name"]) or x["subscribe"] > 1 or x["publish"] & ~0b111 or x["powers"] & ~0b1111:
return False
if x["subscribe"] == 0 and x["max_layer"] != 0:
return False
if not within(x["events"], ceiling["kinds"]) or not powers_cover(x["events"], x["powers"]):
return False
if len(d["slots"]) > 16 or len({s["name"] for s in d["slots"]}) != len(d["slots"]):
return False
for s in d["slots"]:
if not is_slot_name(s["name"]) or s["media"] not in MEDIA:
return False
if (s["redundancy"], s["failover_ms"] > 0) not in ((1, False), (2, True)):
return False
if len(d["profiles"]) > 16 or len({p["name"] for p in d["profiles"]}) != len(d["profiles"]):
return False
for p in d["profiles"]:
if not profile_ok(p):
return False
reg = REGISTERED.get(p["name"].decode())
if reg is not None and reg != p["bytes"]:
return False
kinds, deadline, fallback = d["review"]
if any(kinds):
if not within(kinds, ceiling["kinds"]) or deadline == 0 or fallback not in (1, 2):
return False
elif deadline != 0 or fallback != 0:
return False
if d["mode"] > 2 or len(d["regions"]) > 16:
return False
if d["mode"] == 0:
if d["commit_interval_ms"] != 0 or d["jitter_ms"] != 0:
return False
elif d["commit_interval_ms"] == 0 or d["jitter_ms"] > d["commit_interval_ms"]:
return False
if not all(re.fullmatch(rb"[A-Z]{2}", x) for x in d["regions"]):
return False
return all(a < b for a, b in zip(d["regions"], d["regions"][1:]))
def is_room_key(d, k):
return k == d["authority"] or k == d["clock"]
# ---------------------------------------------------------------- §4 grants
def read_attrs(b):
"""§4.4: the entries, or None when they break a rule."""
if len(b) > 1024:
return None
r = Reader(b)
out, last = [], None
try:
while r.p < len(b):
k, v = r.name(), r.string()
if not is_slot_name(k) or (last is not None and last >= k):
return None
out.append((k.decode(), v.decode("utf-8")))
last = k
except (Refuse, UnicodeDecodeError):
return None
return out
def read_grant(b):
"""§4.3 step 1: the grant's fields as read."""
read_head(b, GRANT_MAGIC)
r = Reader(b, 5)
g = {"room": r.take(32), "principal": r.take(32), "prev": r.take(32), "effective": r.u64(), "nbf": r.u64(),
"exp": r.u64(), "role": r.u8(), "publish": r.u8(), "subscribe": r.u8(), "max_layer": r.u8(),
"powers": r.u8(), "precedence": r.u8(), "once": r.take(16), "viewer": r.take(16), "policy_bytes": r.take(65),
"joules": r.u64(), "funds": r.u64(), "currency": r.take(3)}
g["profiles"] = list(r.take(r.u8()))
g["origins"] = [r.name() for _ in range(r.u8())]
g["attrs"] = r.string()
g["body"] = b[:r.p]
g["sig"] = r.take(64)
if r.p != len(b):
raise Refuse("malformed")
g["digest"] = hashlib.sha256(g["body"]).digest()
return g
def grant_fields_ok(g, d, held):
"""§4.3 step 5."""
if g["exp"] <= g["nbf"]:
return False
if g["prev"] == bytes(32):
if g["effective"] != 0:
return False
elif g["effective"] % d["tick"] != 0:
return False
if g["role"] >= len(d["roles"]) or g["subscribe"] > 1:
return False
if g["publish"] & ~0b111 or g["powers"] & ~0b1111 or (g["subscribe"] == 0 and g["max_layer"] != 0):
return False
try:
policy = LEV.read_policy(g["policy_bytes"])
except Refuse:
return False
if lifecycle_permitted(policy) or not powers_cover(policy["kinds"], g["powers"]):
return False
funded = g["funds"] != NO_CEILING
if funded != (g["currency"] != bytes(3)) or (funded and not re.fullmatch(rb"[A-Z]{3}", g["currency"])):
return False
media = set()
for i in g["profiles"]:
if i >= len(d["profiles"]):
return False
m = d["profiles"][i]["media"]
if m in media or not publishes(g, m):
return False
media.add(m)
if len(g["origins"]) > 8 or not all(is_token(o, 255) for o in g["origins"]):
return False
if read_attrs(g["attrs"]) is None:
return False
return all(h["digest"] != g["digest"] for h in held)
def publishes(g, m):
return m in MEDIA and g["publish"] >> (m - 1) & 1 == 1
def check_grants(d, grants):
"""§4.3 over every grant, in order: the grants, or (index, reason)."""
held = []
for i, b in enumerate(grants):
try:
g = read_grant(b)
except Refuse as e:
return None, (i, e.reason)
bad = ED.verify(d["authority"], GRANT_DOMAIN + g["body"], g["sig"])
if bad:
return None, (i, bad)
if g["prev"] != bytes(32):
old = next((h for h in held if h["digest"] == g["prev"]), None)
if old is None:
return None, (i, "exchange_unlinked")
if old["room"] != g["room"] or old["principal"] != g["principal"]:
return None, (i, "exchange_mismatch")
if g["room"] != d["room"]:
return None, (i, "wrong_room")
if not grant_fields_ok(g, d, held):
return None, (i, "grant_invalid")
g["policy"] = LEV.read_policy(g["policy_bytes"])
if g["exp"] - g["nbf"] > MAX_GRANT_S:
return None, (i, "grant_too_long")
role = d["roles"][g["role"]]
if (not within(g["publish"], role["publish"]) or g["subscribe"] > role["subscribe"]
or g["max_layer"] > role["max_layer"] or not within(g["powers"], role["powers"])
or not within(g["policy"]["kinds"], role["events"])):
return None, (i, "caps_exceed_role")
p, c = g["policy"], d["ceiling_policy"]
if (p["max_payload"] > c["max_payload"] or p["max_text_cp"] > c["max_text_cp"]
or p["max_events"] > c["max_events"] or p["window_us"] < c["window_us"]
or p["max_reactions"] > c["max_reactions"] or p["reaction_window_us"] < c["reaction_window_us"]):
return None, (i, "grant_exceeds_room")
held.append(g)
return held, None
def good_at(g, d, t):
"""§4.3: nbf ≤ ⌊(t0 + t) / 10^6⌋ < exp."""
return g["nbf"] <= (d["t0"] + t) // 1000000 < g["exp"]
def viewer_tag(g):
return None if g["viewer"] == bytes(16) else hashlib.sha256(VIEWER_DOMAIN + g["viewer"]).digest()
# ---------------------------------------------------------------- §5 the key log
REVISION_MEMBERS = {"kind", "origin", "seq", "prev", "issued_at", "root", "next_root", "keys", "revoked_modules",
"grant_max_s", "status_max_age_s", "sig"}
ENTRY_MEMBERS = {"id", "key", "may_sign", "status", "until", "compromised_since"}
HEX32 = re.compile(r"[0-9a-f]{64}")
LOG_TIME = re.compile(r"[0-9]{4}-(0[1-9]|1[0-2])-(0[1-9]|[12][0-9]|3[01])T([01][0-9]|2[0-3]):[0-5][0-9]:[0-5][0-9]Z")
KEY_ID = re.compile(r"[A-Za-z0-9._-]{1,64}")
AUTH_SEG = re.compile(r"[A-Za-z0-9._-]+")
def no_duplicates(pairs):
keys = [k for k, _ in pairs]
if len(set(keys)) != len(keys):
raise ValueError("a member named twice")
return dict(pairs)
def is_int(v):
return isinstance(v, int) and not isinstance(v, bool) and 0 <= v <= 2 ** 53 - 1
def commitment(k):
return blake3(b"wai:key-set-next\x01" + k)
def jcs(v):
"""RFC 8785 over what a key-log revision holds: strings of ASCII, integers, null, arrays."""
return json.dumps(v, sort_keys=True, separators=(",", ":"), ensure_ascii=False)
def entry_ok(e):
if not isinstance(e, dict) or set(e) != ENTRY_MEMBERS:
return False
if not isinstance(e["id"], str) or not KEY_ID.fullmatch(e["id"]):
return False
if not isinstance(e["key"], str) or not HEX32.fullmatch(e["key"]) or not ED.strict_key(bytes.fromhex(e["key"])):
return False
ms = e["may_sign"]
if not isinstance(ms, list) or not ms or len(set(map(str, ms))) != len(ms):
return False
for a in ms:
if not isinstance(a, str) or not 1 <= len(a) <= 128 or not all(AUTH_SEG.fullmatch(s) for s in a.split("/")):
return False
for t in ("until", "compromised_since"):
if e[t] is not None and not (isinstance(e[t], str) and LOG_TIME.fullmatch(e[t])):
return False
shape = {"active": e["until"] is None and e["compromised_since"] is None,
"retired": e["until"] is not None and e["compromised_since"] is None,
"revoked": e["compromised_since"] is not None}
return shape.get(e["status"], False)
def revision_ok(v):
"""jwp-receipts §7.2 rule 1, as far as this verifier reads it."""
if not isinstance(v, dict) or set(v) != REVISION_MEMBERS or v["kind"] != "wai-key-set":
return False
if not isinstance(v["origin"], str) or not is_int(v["seq"]):
return False
if (v["seq"] == 0) != (v["prev"] is None) or (v["prev"] is not None and not (isinstance(v["prev"], str) and HEX32.fullmatch(v["prev"]))):
return False
if not isinstance(v["issued_at"], str) or not LOG_TIME.fullmatch(v["issued_at"]):
return False
for k in ("root", "next_root"):
if not isinstance(v[k], str) or not HEX32.fullmatch(v[k]):
return False
root, nxt = bytes.fromhex(v["root"]), bytes.fromhex(v["next_root"])
if nxt == commitment(root):
return False
if not isinstance(v["keys"], list) or not all(entry_ok(e) for e in v["keys"]):
return False
ids, keys = [e["id"] for e in v["keys"]], [e["key"] for e in v["keys"]]
if len(set(ids)) != len(ids) or len(set(keys)) != len(keys):
return False
if any(bytes.fromhex(k) == root or commitment(bytes.fromhex(k)) == nxt for k in keys):
return False
rm = v["revoked_modules"]
if not isinstance(rm, list) or not all(isinstance(x, str) and HEX32.fullmatch(x) for x in rm) or len(set(rm)) != len(rm):
return False
if not (is_int(v["grant_max_s"]) and 1 <= v["grant_max_s"] <= 86400):
return False
if not (is_int(v["status_max_age_s"]) and 1 <= v["status_max_age_s"] <= 3600):
return False
return isinstance(v["sig"], str) and re.fullmatch(r"[0-9a-f]{128}", v["sig"]) is not None
def check_key_log(d, head):
"""§5: the revoked digests, or Refuse."""
if d["origin"] == b"":
return set()
if head is None:
raise Refuse("key_log_missing")
try:
v = json.loads(head, object_pairs_hook=no_duplicates)
except ValueError:
raise Refuse("key_log_not_held")
if not revision_ok(v) or v["origin"] != d["origin"].decode():
raise Refuse("key_log_not_held")
unsigned = {k: x for k, x in v.items() if k != "sig"}
if ED.verify(bytes.fromhex(v["root"]), b"wai:key-set\x01" + jcs(unsigned).encode(), bytes.fromhex(v["sig"])):
raise Refuse("key_log_not_held")
entry = next((e for e in v["keys"] if e["key"] == d["authority"].hex() and AUTHORITY in e["may_sign"]), None)
if entry is None:
raise Refuse("authority_not_listed")
if entry["status"] == "revoked":
raise Refuse("authority_revoked")
return {bytes.fromhex(x) for x in v["revoked_modules"]}
def revoked(g, revoked_set):
return g["digest"] in revoked_set or viewer_tag(g) in revoked_set
# ---------------------------------------------------------------- §7 lifecycle detail
def read_detail(kind, b):
"""§7.2: the detail as a dict, or Refuse("lifecycle_malformed")."""
r = Reader(b)
try:
def slot(required):
s = r.name()
if (not s and required) or (s and not is_slot_name(s)):
raise Refuse("lifecycle_malformed")
return s.decode()
def media():
m = r.u8()
if m not in MEDIA:
raise Refuse("lifecycle_malformed")
return m
def layers():
n = r.u8()
if n == 0:
raise Refuse("lifecycle_malformed")
return n
if kind == JOIN:
x = {"grant": r.take(32)}
elif kind == LEAVE:
x = {"reason": r.u8()}
if x["reason"] not in (1, 2, 3, 4):
raise Refuse("lifecycle_malformed")
elif kind == PUBLISH:
x = {"slot": slot(False), "media": media(), "profile": r.u8()}
x["layers"] = layers()
elif kind == UNPUBLISH:
x = {"slot": slot(False), "media": media()}
elif kind == EXCHANGE:
x = {"old": r.take(32), "new": r.take(32)}
elif kind == TAKEOVER:
x = {"slot": slot(True), "media": media(), "profile": r.u8()}
x["layers"] = layers()
x["previous"] = r.take(32)
elif kind == FAILOVER:
x = {"slot": slot(True), "media": media(), "failed": r.take(32), "last_seen": r.u64()}
else:
raise Refuse("lifecycle_malformed")
except Refuse:
raise Refuse("lifecycle_malformed")
if r.p != len(b):
raise Refuse("lifecycle_malformed")
return x
class Room:
"""The room state of §9.3 and the checks of §7.2 that move it."""
def __init__(self, d, grants, revoked_set):
self.d, self.grants, self.revoked = d, grants, revoked_set
self.present = {} # principal -> grant digest
self.own = {} # (principal, media) -> (profile, layers)
self.slots = [{"live": None, "standby": None} for _ in d["slots"]] # each (principal, profile, layers)
self.timeline = []
self.used_once = set()
def grant(self, digest):
return next((g for g in self.grants if g["digest"] == digest), None)
def current(self, p):
return self.grant(self.present[p]) if p in self.present else None
def slot_index(self, name, m):
return next((i for i, s in enumerate(self.d["slots"]) if s["name"].decode() == name and s["media"] == m), None)
def publishers(self):
out = {p for p, _ in self.own}
for s in self.slots:
out |= {h[0] for h in (s["live"], s["standby"]) if h}
return out
def may_publish(self, g, m, profile, layers):
if not publishes(g, m):
raise Refuse("publish_not_permitted")
ps = self.d["profiles"]
if not (profile < len(ps) and profile in g["profiles"] and ps[profile]["media"] == m and layers <= len(ps[profile]["layers"])):
raise Refuse("profile_not_granted")
def limit(self):
if len(self.publishers()) > self.d["max_publishers"]:
raise Refuse("publisher_limit")
def drop(self, p):
for s in self.slots:
if s["standby"] and s["standby"][0] == p:
s["standby"] = None
if s["live"] and s["live"][0] == p:
s["live"], s["standby"] = s["standby"], None
def publishing(self, p):
out = [(m, v[0]) for (q, m), v in sorted(self.own.items()) if q == p]
for i, s in enumerate(self.slots):
for h in (s["live"], s["standby"]):
if h and h[0] == p:
out.append((self.d["slots"][i]["media"], h[1]))
return out
def need_present(self, p):
g = self.current(p)
if g is None:
raise Refuse("not_present")
return g
def slot_of(self, name, m):
i = self.slot_index(name, m)
if i is None:
raise Refuse("slot_unknown")
return i
def apply(self, kind, subject, x, t):
if kind == JOIN:
g = self.grant(x["grant"])
if g is None:
raise Refuse("grant_unknown")
if g["principal"] != subject:
raise Refuse("grant_wrong_principal")
if subject in self.present:
raise Refuse("already_present")
if revoked(g, self.revoked):
raise Refuse("grant_revoked")
if g["once"] != bytes(16) and g["once"] in self.used_once:
raise Refuse("grant_reused")
if not good_at(g, self.d, t):
raise Refuse("outside_grant_window")
if len(self.present) + 1 > self.d["max_present"]:
raise Refuse("participant_limit")
if g["once"] != bytes(16):
self.used_once.add(g["once"])
self.present[subject] = g["digest"]
self.timeline.append((subject, g["digest"], t))
elif kind == LEAVE:
self.need_present(subject)
del self.present[subject]
self.own = {k: v for k, v in self.own.items() if k[0] != subject}
self.drop(subject)
elif kind == PUBLISH:
g = self.need_present(subject)
i = None if x["slot"] == "" else self.slot_of(x["slot"], x["media"])
self.may_publish(g, x["media"], x["profile"], x["layers"])
held = (subject, x["profile"], x["layers"])
if i is None:
if (subject, x["media"]) in self.own:
raise Refuse("already_published")
self.own[(subject, x["media"])] = (x["profile"], x["layers"])
else:
s = self.slots[i]
if any(h and h[0] == subject for h in (s["live"], s["standby"])):
raise Refuse("already_published")
if s["live"] and (self.d["slots"][i]["redundancy"] < 2 or s["standby"]):
raise Refuse("slot_full")
if s["live"] is None:
s["live"] = held
else:
s["standby"] = held
self.limit()
elif kind == UNPUBLISH:
self.need_present(subject)
if x["slot"] == "":
if (subject, x["media"]) not in self.own:
raise Refuse("not_published")
del self.own[(subject, x["media"])]
else:
s = self.slots[self.slot_of(x["slot"], x["media"])]
if not any(h and h[0] == subject for h in (s["live"], s["standby"])):
raise Refuse("not_published")
if s["standby"] and s["standby"][0] == subject:
s["standby"] = None
else:
s["live"], s["standby"] = s["standby"], None
elif kind == EXCHANGE:
cur = self.need_present(subject)
if cur["digest"] != x["old"]:
raise Refuse("exchange_not_current")
g = self.grant(x["new"])
if g is None:
raise Refuse("grant_unknown")
if g["prev"] != x["old"]:
raise Refuse("exchange_unlinked")
if t != g["effective"]:
raise Refuse("exchange_off_tick")
if revoked(g, self.revoked):
raise Refuse("grant_revoked")
if not good_at(g, self.d, t):
raise Refuse("outside_grant_window")
for m, profile in self.publishing(subject):
if not publishes(g, m):
raise Refuse("publish_not_permitted")
if profile not in g["profiles"]:
raise Refuse("profile_not_granted")
self.present[subject] = g["digest"]
self.timeline.append((subject, g["digest"], t))
elif kind == TAKEOVER:
g = self.need_present(subject)
i = self.slot_of(x["slot"], x["media"])
s = self.slots[i]
if not s["live"] or s["live"][0] != x["previous"]:
raise Refuse("not_published")
self.may_publish(g, x["media"], x["profile"], x["layers"])
theirs = self.current(x["previous"])
if subject == x["previous"] or g["precedence"] <= (theirs["precedence"] if theirs else 0):
raise Refuse("takeover_not_higher")
if s["standby"] and s["standby"][0] == subject:
s["standby"] = None
s["live"] = (subject, x["profile"], x["layers"])
self.limit()
elif kind == FAILOVER:
self.need_present(subject)
i = self.slot_of(x["slot"], x["media"])
s = self.slots[i]
if not s["live"] or s["live"][0] != x["failed"]:
raise Refuse("not_published")
if not s["standby"] or s["standby"][0] != subject:
raise Refuse("failover_not_standby")
if x["last_seen"] > t or t - x["last_seen"] < self.d["slots"][i]["failover_ms"] * 1000:
raise Refuse("failover_early")
s["live"], s["standby"] = s["standby"], None
# ---------------------------------------------------------------- §6 names, §10.2 scopes
def render(fields, name):
"""MOQT §8.8: a-z, A-Z, 0-9 and _ as themselves; every other byte as . and two hex digits."""
def one(f):
return "".join(chr(c) if (chr(c).isascii() and chr(c).isalnum()) or c == 0x5F else ".%02x" % c for c in f.encode())
return "-".join(one(f) for f in fields) + "--" + one(name)
def scopes(d, g):
"""§10.2, in its order."""
room, me = d["room"].hex(), g["principal"].hex()
out = [[[0]]]
for m in (1, 2, 3):
if publishes(g, m):
out.append([[2, 6], [room, me, MEDIA[m], None]])
for s in d["slots"]:
if s["media"] == m:
out.append([[6], [room, "_" + s["name"].decode(), MEDIA[m], None]])
if any(g["policy"]["kinds"]):
out.append([[2, 6], [room, me, "events", None], "0"])
if g["subscribe"]:
out.append([[3], [room]])
for layer in range(g["max_layer"] + 1):
out.append([[4, 7, 8], [room], str(layer)])
return out
# ---------------------------------------------------------------- §9 room verification
def authority_policy():
"""live-events §5: the default bounds, every kind permitted."""
b = b"WEP1\x01" + bitset(LEV.KINDS) + struct.pack("<HHIQIQ", 1024, 500, 50, 1000000, 1, 1000000)
return LEV.read_policy(b)
def merged_order(d, chains):
"""§9.2."""
def key(c, i):
try:
ev = LEV.read_record(chains[c][i])
except Refuse:
return (0, 0, c)
return (ev["t"], 0 if is_room_key(d, ev["principal"]) else 1, c)
nxt, out = [0] * len(chains), []
while True:
live = [c for c in range(len(chains)) if nxt[c] < len(chains[c])]
if not live:
return out
c = min(live, key=lambda c: key(c, nxt[c]))
out.append((c, nxt[c]))
nxt[c] += 1
def verify_room(descriptor, grants, head, chains):
"""§9: a dict of what the corpus states for the vector."""
try:
d = read_descriptor(descriptor)
except Refuse as e:
return {"outcome": "refuse", "reason": e.reason, "at": ["descriptor"]}
try:
revoked_set = check_key_log(d, head)
except Refuse as e:
return {"outcome": "refuse", "reason": e.reason, "at": ["key_log"]}
held, bad = check_grants(d, grants)
if bad:
return {"outcome": "refuse", "reason": bad[1], "at": ["grant", bad[0]]}
room = Room(d, held, revoked_set)
auth = authority_policy()
ver = LEV.Verifier(d["room"], auth, {})
seen = {}
order, at_index = [], []
for c, i in merged_order(d, chains):
b = chains[c][i]
at_index.append((c, i))
try:
under = step(d, room, ver, seen, auth, b)
except Refuse as e:
return {"outcome": "refuse", "reason": e.reason, "at": ["record", c, i]}
order.append([c, i, under.hex() if under else None])
if ver.gaps:
return {"outcome": "gap", "reason": "delivery_gap", "room": d["room"].hex(),
"gaps": [{"principal": p.hex(), "first": a, "last": z} for p, a, z in ver.gaps], "order": order}
mismatch = LEV.stated_tally_mismatch(ver.accepted)
if mismatch is not None:
c, i = at_index[mismatch]
return {"outcome": "refuse", "reason": "tally_mismatch", "at": ["record", c, i]}
return {"outcome": "accept", "room": d["room"].hex(), "grant_digests": [g["digest"].hex() for g in held],
"order": order, "state": state_of(d, room), "tally": LEV.tally(ver.accepted),
"moqt": [{"grant": g["digest"].hex(), "reval": d["reval"], "scopes": scopes(d, g)} for g in held]}
def step(d, room, ver, seen, auth, b):
"""§9.2 over one record: the digest of the grant it was verified under, or None."""
ev = LEV.read_record(b)
if ev["kind"] not in LEV.KINDS:
raise Refuse("unknown_kind")
if ev["room"] != d["room"]:
raise Refuse("wrong_room")
bad = ED.verify(ev["principal"], LEV.SIGN_DOMAIN + ev["body"], ev["sig"])
if bad:
raise Refuse(bad)
p = ev["principal"]
if ev["digest"] in seen:
policy, under = seen[ev["digest"]]
ver.own[p] = policy
ver.offer(b)
return under
if is_room_key(d, p):
ver.own[p] = auth
ver.offer(b)
seen[ev["digest"]] = (auth, None)
payload = ver.accepted[-1][2]
if 0x10 <= ev["kind"] <= 0x16:
room.apply(ev["kind"], payload["subject"], read_detail(ev["kind"], payload["detail"]), ev["t"])
return None
g = room.current(p)
if g is None:
raise Refuse("not_present")
if not good_at(g, d, ev["t"]):
raise Refuse("outside_grant_window")
ver.own[p] = g["policy"]
ver.offer(b)
seen[ev["digest"]] = (g["policy"], g["digest"])
payload = ver.accepted[-1][2]
if payload["kind"] == CONTROL:
need = RECORD if payload["command"] in (1, 2) else DIRECT
if g["powers"] & need == 0:
raise Refuse("power_not_granted")
return g["digest"]
def state_of(d, room):
"""§9.3, as the corpus writes it."""
def precedence(p):
g = room.current(p)
return g["precedence"] if g else None
tracks = []
for (p, m), (_, layers) in room.own.items():
tracks += [render([d["room"].hex(), p.hex(), MEDIA[m]], str(n)) for n in range(layers)]
for s, st in zip(d["slots"], room.slots):
if st["live"]:
tracks += [render([d["room"].hex(), "_" + s["name"].decode(), MEDIA[s["media"]]], str(n)) for n in range(st["live"][2])]
return {
"present": [{"principal": p.hex(), "grant": g.hex()} for p, g in sorted(room.present.items())],
"published": [{"principal": p.hex(), "media": MEDIA[m], "profile": d["profiles"][v[0]]["name"].decode(), "layers": v[1]}
for (p, m), v in sorted(room.own.items())],
"slots": [{"slot": s["name"].decode(), "media": MEDIA[s["media"]],
"live": st["live"][0].hex() if st["live"] else None,
"standby": st["standby"][0].hex() if st["standby"] else None,
"precedence": precedence(st["live"][0]) if st["live"] else None}
for s, st in zip(d["slots"], room.slots)],
"tracks": sorted(tracks),
"timeline": [{"principal": p.hex(), "grant": g.hex(), "t": t} for p, g, t in room.timeline],
}
# ---------------------------------------------------------------- the corpus
def main(corpus_dir):
fails = []
st = ED._self_test()
print(("PASS" if not st else "FAIL") + " Ed25519 strict rule self-test " + ",".join(st))
if st:
fails.append("ed25519 self-test")
with open(os.path.join(corpus_dir, "vectors.json"), encoding="utf-8") as f:
doc = json.load(f)
stated = {p["name"]: p["record"] for p in doc["registered_profiles"]}
mine = {n: b.hex() for n, b in REGISTERED.items()}
if stated != mine:
fails.append("the registered profiles differ from §8.2's table: %s" % sorted(n for n in set(stated) | set(mine) if stated.get(n) != mine.get(n)))
print("%s registered profiles (§8.2)" % ("PASS" if stated == mine else "FAIL"))
reasons_seen, outcomes = set(), {}
for v in doc["vectors"]:
name = v["name"]
got = verify_room(bytes.fromhex(v["descriptor"]), [bytes.fromhex(g) for g in v["grants"]], v["key_log_head"],
[[bytes.fromhex(r) for r in c] for c in v["chains"]])
problems = [k for k in got if v.get(k) != got[k]]
problems += [k for k in ("outcome", "reason", "at") if k in v and k not in got]
if v.get("reason"):
reasons_seen.add(v["reason"])
outcomes[name] = got["outcome"]
what = got["outcome"] + ("" if got["outcome"] == "accept" else " " + got["reason"]) + (" at %s" % got["at"] if "at" in got else "")
print(("PASS" if not problems else "FAIL") + " vector %s: %s" % (name, what))
for k in problems:
fails.append("%s: %s is %s, the corpus states %s" % (name, k, json.dumps(got.get(k))[:300], json.dumps(v.get(k))[:300]))
missing = [r for r in REASONS if r not in reasons_seen]
if missing:
fails.append("no vector refused with %s" % ", ".join(missing))
rooms = ["lesson", "broadcast", "takeover", "redundant_failover", "promote_viewer"]
absent = [r for r in rooms if outcomes.get(r) != "accept"]
if absent:
fails.append("rooms not accepted: %s" % ", ".join(absent))
print("%s every reason of §11 refuses a vector, and every room of §12 is accepted" % ("PASS" if not (missing or absent) else "FAIL"))
for f in fails:
print("FAIL " + f)
print(("PASS" if not fails else "FAIL") + " live-rooms corpus: %d vectors" % len(doc["vectors"]))
return 1 if fails else 0
if __name__ == "__main__":
if len(sys.argv) != 2:
print("usage: live_rooms_verify.py <live-conformance/rooms>", file=sys.stderr)
sys.exit(2)
sys.exit(main(sys.argv[1]))

View file

@ -0,0 +1,94 @@
# live-conformance/rooms — live rooms
The corpus of [`extensions/live-rooms.md`](../../extensions/live-rooms.md) §12: rooms, each
a signed `WRD1` descriptor, its `WRG1` grants, the head of its authority's key log, and its
principals' `WEV1` chains, with the outcome a room verifier gives them; and the records of
the registered publish profiles.
**Room equivalence.** For each vector, a conforming room verifier given its `descriptor`,
its `grants` in order, its `key_log_head` (or none) and its `chains` (each one principal's
records in stream order) reaches the outcome the vector states (§9.4):
- `refuse`: the `reason` and where, `at`: `["descriptor"]`, `["key_log"]`,
`["grant", i]`, or `["record", c, i]` for record `i` of chain `c`;
- `gap`: no record refused, and the `gaps` (principal, first and last seq not held), under
the code `delivery_gap`, with the `room` id and the merged `order`;
- `accept`: the `room` id; every grant's digest (`grant_digests`); the merged `order`
(§9.2), each entry `[chain, record, grant]` with the digest of the grant the record was
verified under, or `null` for a room key's record; the room `state` (§9.3); the `tally`
of the room's polls (live-events §7); and, per grant, the `moqt` scopes and `reval`
interval it maps onto (§10.2).
A verifier that reaches another outcome, reason, place, gap, digest, order, state, tally
or scope does not conform.
**Provenance.** Generated here: `wai_live_room_vectors gen` writes every descriptor, grant,
key-log head and record from rooms authored in code, from fixed seeds. Each key is SHA-256
of a fixed label and the principal's index, each nonce and viewer id likewise, every time
is a constant (the rooms' `t0` is 2026-10-16T00:00:00Z), and Ed25519 signing is
deterministic (RFC 8032), so the file is the same bytes on every machine. The generator
asserts that each vector reaches the outcome it was written to have before it writes it.
The key-log heads are sealed by the reference's key log (jwp-receipts §7.2) under a fixed
root. The origins are `.example` names (RFC 2606). No recorded data.
## Layout
One file, `vectors.json`:
- `principals`: the keys, by name (informative).
- `registered_profiles`: each profile §8.2 registers, by `name`, with its `record` (§8.1)
in hexadecimal; a verifier compares its own table with them.
- `vectors`: each with `name`, `about`, `descriptor` and `grants` (hexadecimal),
`key_log_head` (a key-log revision's JSON text, or `null`), `chains` (lists of records
in hexadecimal), `outcome`, and as above `reason`, `at`, `room`, `gaps`,
`grant_digests`, `order`, `state`, `tally` and `moqt`.
`state` has `present` (each principal and its grant in effect, by principal), `published`
(each own track: principal, media kind, profile name, number of layers, by principal and
media code), `slots` (each slot in the descriptor's order: its live publisher, standby,
and the live publisher's precedence), `tracks` (the rendered Full Track Names of every
track a subscriber can reach, §6.1, sorted), and `timeline` (each change of a principal's
grant in effect, with its `t`).
Vectors:
- accepted rooms: `lesson` (a director's remote record control of a performer, an audience
under single-use grants, review and recording policies with a region pin, a key-log
head revoking a grant and a viewer the room never admits), `broadcast` (one publisher,
viewers voting, a moderator's decision, no key log, the authority its own clock),
`takeover`, `redundant_failover` and `promote_viewer`;
- `room_gap`: a chain with a record not delivered;
- one negative control per refusal, each a single defect: the descriptor truncated, with
trailing bytes, of another version, with a flipped signature bit, with a tick of 0,
with a registered profile changed, with a role beyond the ceiling, and with a recording
policy whose jitter exceeds its commit interval; the key log
missing, of another origin, re-signed wrongly, not listing the authority, and revoking
it; a grant truncated, signed by another key, of another room, permitting a lifecycle
kind, with oversized attributes, too long, beyond its role, beyond the room's ceiling,
chained to nothing, and chained to another principal's; and in the records, a bad
signature, the wrong room, a kind not in the grant, a participant's lifecycle event,
events outside a grant's window, a principal not present, a revoked grant and a
revoked viewer, a reused single-use grant, an unknown grant, another principal's grant,
a second join, the participant limit, a malformed detail, every publish refusal, every
exchange refusal, a takeover without higher precedence, both failover refusals, a
command without its power, a grant's rate and payload bounds, and a stated tally the
replay does not reach.
## Running
```
cd ../../wai-rs
cargo run --no-default-features --features live_room --bin wai_live_room_vectors -- verify ../live-conformance/rooms
cargo run --no-default-features --features live_room --bin wai_live_room_vectors -- gen ../live-conformance/rooms # regenerate
cargo test --no-default-features --features live_room --test live_room
cd ../live-conformance
python3 live_rooms_verify.py rooms
```
`wai_live_room_vectors verify` and `tests/live_room.rs` require the file to regenerate
byte for byte and `wai-rs` to give every vector its outcome; the test also requires every
reason of §11 to refuse a vector and §11 to list exactly the reasons the reference reports.
`live_rooms_verify.py` is the second implementation, written from the extension's text and
sharing no code with `wai-rs`: it verifies every event with `live_events_verify.py`, the
live-events corpus's own second implementation, gives every vector the same outcome, and
requires the same coverage. Pass = an exit status of 0 from each.

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,91 @@
# live-conformance/takes — capture binding
The corpus of [`extensions/capture-binding.md`](../../extensions/capture-binding.md) §13:
takes, each a room (a signed `WRD1` descriptor, its `WRG1` grants, the head of its
authority's key log, and its principals' `WEV1` chains, the device's `take_commit` events
among them), a device-signed `WTM1` take manifest, the uploaded parts, the store's `WUR1`
upload receipts, `WCN1` consent records, and an evaluation instant, with the outcome a take
verifier gives them.
**Take equivalence.** For each vector, a conforming take verifier given its room, its
`manifest`, its `parts` (each an `index` and its `bytes`, in the order received), its
`receipts` and `consents` in order, and its `evaluated_at` (seconds since 1970) reaches the
outcome the vector states (§9.5):
- `refuse`: the `reason` and where, `at`: `["room", …]` with the room verifier's own place
(live-rooms §9.1), `["manifest"]`, `["record", c, i]` for record `i` of chain `c`,
`["store"]`, `["receipt", i]` or `["consent", i]`;
- `verdicts`: the `take` id and its `stream_id` (§11.1); the digests of the held
commitments, `commits`, in order; the last held commitment's `size` and `root`; the
`verdicts` of `single_take`, `aligned`, `complete` and `publishable` for each of `live`,
`record`, `distribute` and `train`, each `holds` or the reason it fails; whether the
manifest `stands`; and the `markers` of the room within the master (§10), each with its
`event` digest, its `t`, its `chunk`, and its position `into_chunk` and `into_master` in
microseconds, stated where `aligned` holds.
A verifier that reaches another outcome, reason, place, verdict, digest or position does not
conform.
**Provenance.** Generated here: `wai_capture_take_vectors gen` writes every descriptor,
grant, key-log head, record, chunk, manifest, receipt and consent from takes authored in
code, from fixed seeds. Each key is SHA-256 of a fixed label and the principal's index, each
chunk's bytes SHA-256 output under a fixed label and the chunk's index (they stand for an
encoded master and encode nothing), every time is a constant (the rooms' `t0` is
2026-10-16T00:00:00Z), and Ed25519 signing is deterministic (RFC 8032), so the file is the
same bytes on every machine. The generator asserts that each vector reaches the outcome it
was written to have before it writes it. The key-log heads are sealed by the reference's key
log (jwp-receipts §7.2) under a fixed root. The origins are `.example` names (RFC 2606). No
recorded data.
## Layout
One file, `vectors.json`:
- `principals`: the keys, by name (informative).
- `vectors`: each with `name`, `about`, `descriptor` and `grants` (hexadecimal),
`key_log_head` (a key-log revision's JSON text, or `null`), `chains` (lists of records in
hexadecimal), `manifest`, `parts`, `receipts` and `consents` (hexadecimal),
`evaluated_at`, `outcome`, and as above `reason`, `at`, `take`, `stream_id`, `commits`,
`size`, `root`, `verdicts`, `stands` and `markers`.
Every take is in one lesson room: a director with the record power, the performer's device
under a grant whose `subject` attribute names the performer, an audience; a recording policy
of mode 1, a two-second commit interval and 500 ms of jitter, pinned to DE and FR; a key log
listing the room authority and the store. Vectors:
- good takes: `lesson` (the director's `record_start` with a four-second count-in, ten
one-second chunks committed every two seconds, two markers, `record_stop`, the closing
commitment, an upload sealed in DE, the performer's consent to live, record and
distribute), `resumed_upload` (the upload interrupted after four chunks and resumed, chunk
3 sent twice) and `minor_guardian` (a performer the grant marks a minor, under the
guardian's consent); each stands, and `publishable` for `train` fails;
- the negatives of §13, each a single defect: `commit_gap`, `spliced_chunk`,
`reordered_chunk`, `extra_chunk`, `missing_chunk` (`complete` fails and `single-take`
holds), `late_commit`, `root_not_extending`, `misaligned`, `consent_missing`,
`consent_expired`, `consent_revoked`, `minor_without_guardian` and `device_not_bound`;
- one vector for each other reason of §12, a manifest of another room, a manifest and a
consent with a bad signature, and a room that refuses the device's first commitment.
## Running
```
cd ../../wai-rs
cargo run --no-default-features --features capture_take --bin wai_capture_take_vectors -- verify ../live-conformance/takes
cargo run --no-default-features --features capture_take --bin wai_capture_take_vectors -- gen ../live-conformance/takes # regenerate
cargo test --no-default-features --features capture_take --test capture_take
cd ../live-conformance
python3 capture_take_verify.py takes
```
`wai_capture_take_vectors verify` and `tests/capture_take.rs` require the file to regenerate
byte for byte and `wai-rs` to give every vector its outcome; the test also requires every
reason of §12 and `delivery_gap` to appear, the three good takes to stand, and §12 to list
exactly the reasons the reference reports. `capture_take_verify.py` is the second
implementation, written from the extension's text and sharing no code with `wai-rs`: it
verifies the room with `live_rooms_verify.py` (which verifies every event with
`live_events_verify.py`), gives every vector the same outcome, requires the same coverage,
and then mutates copies of the corpus, one defect each (a part's byte, two parts swapped, a
part dropped, a signature's byte, a descriptor's byte, the seal removed, a consent's byte, a
commitment not delivered, a later evaluation instant, and stated roots, ids, positions,
verdicts, places and reasons changed), each of which must fail. Pass = an exit status of 0
from each.

File diff suppressed because it is too large Load diff

View file

@ -214,6 +214,20 @@
"edition": null,
"registered": "2026-06-13"
},
{
"capability": "wai.capture.take",
"kind": "record",
"status": "registered",
"media": null,
"role": null,
"tier": null,
"licence": null,
"replicate": null,
"sets": [],
"spec_section": "§5 Interactive, temporal & computed content",
"edition": null,
"registered": "2026-10-17"
},
{
"capability": "wai.derive.crop",
"kind": "derivation",
@ -544,6 +558,34 @@
"edition": null,
"registered": "2026-06-12"
},
{
"capability": "wai.live.events",
"kind": "record",
"status": "registered",
"media": null,
"role": null,
"tier": null,
"licence": null,
"replicate": null,
"sets": [],
"spec_section": "§5 Interactive, temporal & computed content",
"edition": null,
"registered": "2026-10-16"
},
{
"capability": "wai.live.room",
"kind": "record",
"status": "registered",
"media": null,
"role": null,
"tier": null,
"licence": null,
"replicate": null,
"sets": [],
"spec_section": "§5 Interactive, temporal & computed content",
"edition": null,
"registered": "2026-10-16"
},
{
"capability": "wai.mesh.draco",
"kind": "payload",
@ -1643,6 +1685,27 @@
"section": "§5",
"status": "defined"
},
{
"name": "WCN1",
"kind": "magic",
"document": "extensions/capture-binding.md",
"section": "§8.1",
"status": "defined"
},
{
"name": "WEP1",
"kind": "magic",
"document": "extensions/live-events.md",
"section": "§5",
"status": "defined"
},
{
"name": "WEV1",
"kind": "magic",
"document": "extensions/live-events.md",
"section": "§3.1",
"status": "defined"
},
{
"name": "WHP1",
"kind": "magic",
@ -1650,6 +1713,20 @@
"section": "§6.2",
"status": "defined"
},
{
"name": "WRD1",
"kind": "magic",
"document": "extensions/live-rooms.md",
"section": "§3.1",
"status": "defined"
},
{
"name": "WRG1",
"kind": "magic",
"document": "extensions/live-rooms.md",
"section": "§4.1",
"status": "defined"
},
{
"name": "WSIX",
"kind": "magic",
@ -1657,6 +1734,27 @@
"section": "§6",
"status": "defined"
},
{
"name": "WTM1",
"kind": "magic",
"document": "extensions/capture-binding.md",
"section": "§7.1",
"status": "defined"
},
{
"name": "WUR1",
"kind": "magic",
"document": "extensions/capture-binding.md",
"section": "§6.2",
"status": "defined"
},
{
"name": "X-WAI-EVENT",
"kind": "playlist-attribute",
"document": "extensions/live-events.md",
"section": "§8.3",
"status": "defined"
},
{
"name": "X-WAI-FLOOR",
"kind": "playlist-attribute",
@ -1692,6 +1790,13 @@
"section": "§8",
"status": "defined"
},
{
"name": "science.transaction.wai.event",
"kind": "playlist-attribute",
"document": "extensions/live-events.md",
"section": "§8.3",
"status": "defined"
},
{
"name": "science.transaction.wai.multivariant",
"kind": "data-id",
@ -1734,6 +1839,20 @@
"section": "§8",
"status": "defined"
},
{
"name": "urn:wai:event:1",
"kind": "scheme",
"document": "extensions/live-events.md",
"section": "§8.4",
"status": "defined"
},
{
"name": "urn:wai:event:1",
"kind": "catalog",
"document": "extensions/live-events.md",
"section": "§8.1",
"status": "defined"
},
{
"name": "wai",
"kind": "catalog",

View file

@ -267,6 +267,13 @@ wai:wai.avatar.pose a wai:Capability ;
wai:specSection "§5 Interactive, temporal & computed content" ;
wai:registeredOn "2026-06-13"^^xsd:date .
wai:wai.capture.take a wai:Capability ;
rdfs:label "wai.capture.take" ;
wai:kind wai:kind-record ;
wai:status wai:status-registered ;
wai:specSection "§5 Interactive, temporal & computed content" ;
wai:registeredOn "2026-10-17"^^xsd:date .
wai:wai.derive.crop a wai:Capability ;
rdfs:label "wai.derive.crop" ;
wai:kind wai:kind-derivation ;
@ -515,6 +522,20 @@ wai:wai.image.png a wai:Capability ;
wai:specSection "§5 Mandatory floor" ;
wai:registeredOn "2026-06-12"^^xsd:date .
wai:wai.live.events a wai:Capability ;
rdfs:label "wai.live.events" ;
wai:kind wai:kind-record ;
wai:status wai:status-registered ;
wai:specSection "§5 Interactive, temporal & computed content" ;
wai:registeredOn "2026-10-16"^^xsd:date .
wai:wai.live.room a wai:Capability ;
rdfs:label "wai.live.room" ;
wai:kind wai:kind-record ;
wai:status wai:status-registered ;
wai:specSection "§5 Interactive, temporal & computed content" ;
wai:registeredOn "2026-10-16"^^xsd:date .
wai:wai.mesh.draco a wai:Capability ;
rdfs:label "wai.mesh.draco" ;
wai:kind wai:kind-payload ;

View file

@ -174,6 +174,24 @@ ed25519 = ["dep:ed25519-dalek"]
# Interactive worlds (extensions/interactive-worlds): media that consumes
# input. Pure-Rust, no ML dep chain — the sim is closed-form fixed-point.
world = ["dep:blake3", "ed25519"]
# Live events (extensions/live-events): `wai.live.events`, a signed, hash-chained,
# timeline-bound event track — the WEV1 record, its policy, its verifier, poll tallies
# replayed through the feed sim (wai.world.sim.feed0), and the MSF event timeline,
# EXT-X-DATERANGE and emsg mappings. SHA-256 and Ed25519 only; no new dependency.
live_events = ["world"]
# Live rooms (extensions/live-rooms): `wai.live.room` — the signed room descriptor (WRD1)
# and room grant (WRG1), revocation through the authority's key log (jwp-receipts §7),
# track naming, precedence and redundancy over slots, publish profiles, and the room
# verifier that replays every principal's events under the grant in effect at each t.
# Ed25519, SHA-256 and the key log only; no new dependency.
live_room = ["live_events", "key_discovery"]
# Capture binding (extensions/capture-binding): `wai.capture.take` — a device's master
# recording bound to a live room by rolling take_commit events (the take tree is
# staged-measurement Appendix C's, with RFC 9162 consistency proofs), the store's upload
# receipts (WUR1), the take manifest (WTM1), consent records (WCN1), and the take verifier
# with its single-take, aligned, complete and publishable verdicts. Ed25519, SHA-256 and
# the key log only; no new dependency.
capture_take = ["live_room"]
# Object-based spatial audio (extensions/spatial-audio): the deterministic
# Fx mixdown engine. Reuses the world `Fx` floor + blake3; no codec/ML deps
# (sources arrive as decoded mono PCM — decoding the wai.audio.* assets is
@ -601,6 +619,21 @@ name = "wai_world_vectors"
path = "src/bin/wai_world_vectors.rs"
required-features = ["world"]
[[bin]]
name = "wai_live_events_vectors"
path = "src/bin/wai_live_events_vectors.rs"
required-features = ["live_events"]
[[bin]]
name = "wai_live_room_vectors"
path = "src/bin/wai_live_room_vectors.rs"
required-features = ["live_room"]
[[bin]]
name = "wai_capture_take_vectors"
path = "src/bin/wai_capture_take_vectors.rs"
required-features = ["capture_take"]
[[bin]]
name = "wai_world_meter"
path = "src/bin/wai_world_meter.rs"
@ -1211,6 +1244,21 @@ name = "world_model"
path = "tests/world_model.rs"
required-features = ["world_model"]
[[test]]
name = "live_events"
path = "tests/live_events.rs"
required-features = ["live_events"]
[[test]]
name = "live_room"
path = "tests/live_room.rs"
required-features = ["live_room"]
[[test]]
name = "capture_take"
path = "tests/capture_take.rs"
required-features = ["capture_take"]
[[test]]
name = "world_replay"
path = "tests/world_replay.rs"

View file

@ -0,0 +1,53 @@
//! Conformance corpus for capture binding (`live-conformance/takes/`, extensions/capture-binding.md §13).
//!
//! wai_capture_take_vectors gen <corpus-dir> write vectors.json, from fixed seeds
//! wai_capture_take_vectors verify <corpus-dir> vectors.json regenerates byte for byte, and
//! every vector gets the outcome it states
//!
//! The generator and this verifier are one implementation. The corpus's second
//! implementation is `live-conformance/capture_take_verify.py`, written from the extension's
//! text and sharing no code with this binary.
use std::path::Path;
use wai::capture_take::corpus;
fn main() {
let a: Vec<String> = std::env::args().collect();
match (a.get(1).map(String::as_str), a.get(2)) {
(Some("gen"), Some(d)) => {
let p = Path::new(d).join("vectors.json");
if let Err(e) = std::fs::create_dir_all(d).and_then(|_| std::fs::write(&p, corpus::generate())) {
eprintln!("write {}: {e}", p.display());
std::process::exit(1);
}
println!("wrote {}", p.display());
}
(Some("verify"), Some(d)) => std::process::exit(verify(Path::new(d))),
_ => {
eprintln!("usage: wai_capture_take_vectors gen|verify <corpus-dir>");
std::process::exit(2);
}
}
}
fn verify(dir: &Path) -> i32 {
let p = dir.join("vectors.json");
let doc = match std::fs::read_to_string(&p) {
Ok(s) => s,
Err(e) => {
eprintln!("read {}: {e}", p.display());
return 1;
}
};
let mut fail = 0;
let same = doc == corpus::generate();
println!("{} vectors.json regenerates byte for byte from its seeds", if same { "PASS" } else { "FAIL" });
fail += (!same) as i32;
let fails = corpus::verify(&doc);
for f in &fails {
println!("FAIL {f}");
}
println!("{} every vector gets the outcome the corpus states", if fails.is_empty() { "PASS" } else { "FAIL" });
fail += (!fails.is_empty()) as i32;
(fail != 0) as i32
}

View file

@ -0,0 +1,54 @@
//! Conformance corpus for live events (`live-conformance/events/`, extensions/live-events.md §10).
//!
//! wai_live_events_vectors gen <corpus-dir> write vectors.json, from fixed seeds
//! wai_live_events_vectors verify <corpus-dir> vectors.json regenerates byte for byte, and
//! every vector, policy case and binding case
//! gets the outcome it states
//!
//! The generator and this verifier are one implementation. The corpus's second
//! implementation is `live-conformance/live_events_verify.py`, written from the extension's
//! text and sharing no code with this binary.
use std::path::Path;
use wai::live_events::corpus;
fn main() {
let a: Vec<String> = std::env::args().collect();
match (a.get(1).map(String::as_str), a.get(2)) {
(Some("gen"), Some(d)) => {
let p = Path::new(d).join("vectors.json");
if let Err(e) = std::fs::create_dir_all(d).and_then(|_| std::fs::write(&p, corpus::generate())) {
eprintln!("write {}: {e}", p.display());
std::process::exit(1);
}
println!("wrote {}", p.display());
}
(Some("verify"), Some(d)) => std::process::exit(verify(Path::new(d))),
_ => {
eprintln!("usage: wai_live_events_vectors gen|verify <corpus-dir>");
std::process::exit(2);
}
}
}
fn verify(dir: &Path) -> i32 {
let p = dir.join("vectors.json");
let doc = match std::fs::read_to_string(&p) {
Ok(s) => s,
Err(e) => {
eprintln!("read {}: {e}", p.display());
return 1;
}
};
let mut fail = 0;
let same = doc == corpus::generate();
println!("{} vectors.json regenerates byte for byte from its seeds", if same { "PASS" } else { "FAIL" });
fail += (!same) as i32;
let fails = corpus::verify(&doc);
for f in &fails {
println!("FAIL {f}");
}
println!("{} every case gets the outcome the corpus states", if fails.is_empty() { "PASS" } else { "FAIL" });
fail += (!fails.is_empty()) as i32;
(fail != 0) as i32
}

View file

@ -0,0 +1,53 @@
//! Conformance corpus for live rooms (`live-conformance/rooms/`, extensions/live-rooms.md §12).
//!
//! wai_live_room_vectors gen <corpus-dir> write vectors.json, from fixed seeds
//! wai_live_room_vectors verify <corpus-dir> vectors.json regenerates byte for byte, and
//! every vector gets the outcome it states
//!
//! The generator and this verifier are one implementation. The corpus's second
//! implementation is `live-conformance/live_rooms_verify.py`, written from the extension's
//! text and sharing no code with this binary.
use std::path::Path;
use wai::live_room::corpus;
fn main() {
let a: Vec<String> = std::env::args().collect();
match (a.get(1).map(String::as_str), a.get(2)) {
(Some("gen"), Some(d)) => {
let p = Path::new(d).join("vectors.json");
if let Err(e) = std::fs::create_dir_all(d).and_then(|_| std::fs::write(&p, corpus::generate())) {
eprintln!("write {}: {e}", p.display());
std::process::exit(1);
}
println!("wrote {}", p.display());
}
(Some("verify"), Some(d)) => std::process::exit(verify(Path::new(d))),
_ => {
eprintln!("usage: wai_live_room_vectors gen|verify <corpus-dir>");
std::process::exit(2);
}
}
}
fn verify(dir: &Path) -> i32 {
let p = dir.join("vectors.json");
let doc = match std::fs::read_to_string(&p) {
Ok(s) => s,
Err(e) => {
eprintln!("read {}: {e}", p.display());
return 1;
}
};
let mut fail = 0;
let same = doc == corpus::generate();
println!("{} vectors.json regenerates byte for byte from its seeds", if same { "PASS" } else { "FAIL" });
fail += (!same) as i32;
let fails = corpus::verify(&doc);
for f in &fails {
println!("FAIL {f}");
}
println!("{} every vector gets the outcome the corpus states", if fails.is_empty() { "PASS" } else { "FAIL" });
fail += (!fails.is_empty()) as i32;
(fail != 0) as i32
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,964 @@
//! The conformance corpus of extensions/capture-binding.md §13: `live-conformance/takes/vectors.json`.
//!
//! [`generate`] writes it from fixed seeds: every key is SHA-256 of a fixed label and an
//! index, every time is a constant, every chunk's bytes are SHA-256 output of a fixed label,
//! and Ed25519 signing is deterministic (RFC 8032), so the file is the same bytes on every
//! machine. Each vector states the outcome it was written to have, and the generator
//! asserts that this module reaches it before writing it, so a defect here cannot write
//! itself into the corpus as expected.
//!
//! [`verify`] checks a corpus file against this module. `live-conformance/capture_take_verify.py`
//! checks the same file from the extension's text, sharing no code with this module.
use super::*;
use crate::key_log::{commitment, KeyEntry};
use crate::live_events::{Marker, Policy};
use crate::live_room::{media, power, registered_profile, Caps, Descriptor, Detail, Grant, Limits, Recording, Review, Role, NO_CEILING};
use crate::output_tree::consistency_proof;
use serde_json::{json, Map, Value};
/// The rooms' `t0`: 2026-10-16T00:00:00Z, in microseconds and in seconds.
const T0_US: u64 = 1_792_108_800_000_000;
const T0_S: u64 = T0_US / 1_000_000;
const S: u64 = 1_000_000;
const MS: u64 = 1_000;
const ORIGIN: &str = "https://lessons.example";
/// The evaluation instant of every vector: a day after `t0`.
const EVALUATED_AT: u64 = T0_S + 86_400;
const AUTHORITY: u8 = 0;
const RELAY: u8 = 1;
const DIRECTOR: u8 = 2;
const DEVICE: u8 = 3;
const PERFORMER: u8 = 4;
const VIEWER: u8 = 5;
const STORE: u8 = 6;
const GUARDIAN: u8 = 7;
const MINOR: u8 = 8;
const IMPOSTOR: u8 = 9;
const LOG_ROOT: u8 = 10;
const LOG_NEXT: u8 = 11;
const PRINCIPALS: [(&str, u8); 12] = [
("authority", AUTHORITY),
("relay", RELAY),
("director", DIRECTOR),
("device", DEVICE),
("performer", PERFORMER),
("viewer", VIEWER),
("store", STORE),
("guardian", GUARDIAN),
("minor", MINOR),
("impostor", IMPOSTOR),
("log_root", LOG_ROOT),
("log_next", LOG_NEXT),
];
fn key(i: u8) -> SigningKey {
SigningKey::from_bytes(&sha256(&[b"wai:capture-take/corpus/key\x00", &[i]]))
}
fn pk(i: u8) -> [u8; 32] {
key(i).verifying_key().to_bytes()
}
fn unhex(s: &str) -> Option<Vec<u8>> {
if !s.len().is_multiple_of(2) {
return None;
}
(0..s.len()).step_by(2).map(|i| u8::from_str_radix(s.get(i..i + 2)?, 16).ok()).collect()
}
fn bits(ks: &[u8]) -> [u8; 32] {
let mut b = [0u8; 32];
for &k in ks {
b[(k / 8) as usize] |= 1 << (k % 8);
}
b
}
/// The bytes of chunk `i`: 48 to 111 bytes of SHA-256 output under a fixed label.
fn chunk_bytes(label: &[u8], i: usize) -> Vec<u8> {
let len = 48 + (i * 37) % 64;
let mut out = Vec::with_capacity(len + 32);
let mut j = 0u8;
while out.len() < len {
out.extend_from_slice(&sha256(&[b"wai:capture-take/corpus/chunk\x00", label, &(i as u64).to_le_bytes(), &[j]]));
j += 1;
}
out.truncate(len);
out
}
// ---- the room ----
fn role(name: &str, publish: u8, max_layer: u8, powers: u8, events: &[u8]) -> Role {
Role { name: name.into(), caps: Caps { publish, subscribe: true, max_layer, powers }, events: bits(events) }
}
fn director() -> Role {
use events::kind::*;
role("director", 1, 2, power::MODERATE | power::DIRECT | power::RECORD, &[META, CHAT, REACTION, MARKER, CONTROL, CAPTION, DECISION])
}
fn performer() -> Role {
use events::kind::*;
role("performer", 1 | 2, 2, 0, &[CHAT, REACTION, MARKER, TAKE_COMMIT])
}
fn audience() -> Role {
use events::kind::*;
role("audience", 0, 1, 0, &[CHAT, REACTION])
}
fn kinds_of(b: &[u8; 32]) -> Vec<u8> {
(0..=255u8).filter(|k| b[(k / 8) as usize] & (1 << (k % 8)) != 0).collect()
}
/// How the store's receipts run.
#[derive(Clone, Copy, PartialEq, Eq)]
enum Upload {
/// open, progress to every chunk, sealed.
Whole,
/// open, progress to 4, resume at 4, progress to every chunk, sealed.
Resumed,
/// open, progress to `n` and no further.
Stopped(usize),
/// open, progress to every chunk, and no seal.
Unsealed,
}
/// How the parts arrive.
#[derive(Clone, Copy, PartialEq, Eq)]
enum Parts {
Whole,
/// Chunk 3 sent twice, once before the interruption and once after.
Resumed,
Missing(usize),
Altered(usize),
Swapped(usize, usize),
Extra,
}
/// The consent a scene gives its principal.
#[derive(Clone, Copy)]
struct ConsentPlan {
subject: u8,
minor: u8,
guardian: Option<u8>,
/// The key that signs it; the signer of §8.1 unless a vector forges it.
signer: u8,
purposes: u8,
exp: u64,
given: bool,
revoked: bool,
}
/// One take, with every knob a vector turns.
#[derive(Clone)]
struct Scene {
label: &'static str,
mode: u8,
regions: Vec<[u8; 2]>,
store_listed: bool,
/// The device grant's attributes.
attrs: Vec<(String, String)>,
/// Whether the device's grant may send `take_commit`.
device_commits: bool,
count_in_ms: u32,
start_t: u64,
/// Each chunk's capture range on the room clock.
chunks: Vec<(u64, u64)>,
stop_t: u64,
/// The offset (room clock − device clock).
offset: i64,
/// A commitment `n` whose stated offset differs by this much.
offset_jump: Option<(u32, i64)>,
/// A commitment `n` sent at this `t` instead.
late: Option<(u32, u64)>,
/// A commitment `n` that adds one chunk more than it has completed.
early_add: Option<u32>,
/// A commitment `n` whose `prev` is zero.
break_prev: Option<u32>,
/// From commitment `n` on, the device commits a history whose chunk `c` is edited.
rewrite: Option<(u32, usize)>,
/// The manifest and the parts carry chunk `c` edited, which no commitment committed.
manifest_edit: Option<usize>,
no_close: bool,
/// The commitment `n` the relay does not deliver.
drop: Option<u32>,
/// A `take_commit` of the device that does not read, after the close.
malformed: bool,
principal: u8,
consent: Option<ConsentPlan>,
upload: Upload,
parts: Parts,
region: [u8; 2],
/// Receipt 1 states `seq` 2.
receipt_seq_skip: bool,
/// Receipt 1's root is not the manifest's.
receipt_root: bool,
/// The manifest names another start: the viewer's chat.
start_is_chat: bool,
/// The manifest leaves commitment `n` out.
omit: Option<u32>,
/// The manifest's claims byte.
claims: u8,
/// The manifest names another room.
other_room: bool,
/// The manifest's signature has a bit flipped.
flip_sig: bool,
}
fn lesson(label: &'static str) -> Scene {
let capture = 6 * S;
Scene {
label,
mode: 1,
regions: vec![*b"DE", *b"FR"],
store_listed: true,
attrs: vec![("featured".into(), "true".into()), ("subject".into(), hex(&pk(PERFORMER)))],
device_commits: true,
count_in_ms: 4_000,
start_t: 2 * S,
chunks: (0..10).map(|i| (capture + i * S, capture + (i + 1) * S)).collect(),
stop_t: 16 * S,
offset: -5_000_000_000,
offset_jump: None,
late: None,
early_add: None,
break_prev: None,
rewrite: None,
manifest_edit: None,
no_close: false,
drop: None,
malformed: false,
principal: PERFORMER,
consent: Some(ConsentPlan {
subject: PERFORMER,
minor: 0,
guardian: None,
signer: PERFORMER,
purposes: purpose::LIVE | purpose::RECORD | purpose::DISTRIBUTE,
exp: T0_S + 30 * 86_400,
given: true,
revoked: false,
}),
upload: Upload::Whole,
parts: Parts::Whole,
region: *b"DE",
receipt_seq_skip: false,
receipt_root: false,
start_is_chat: false,
omit: None,
claims: claim::SINGLE_TAKE | claim::ALIGNED | claim::COMPLETE,
other_room: false,
flip_sig: false,
}
}
/// A principal's chain being written.
struct Author {
who: u8,
seq: u64,
prev: [u8; 32],
records: Vec<Vec<u8>>,
}
struct Room {
room: [u8; 32],
authors: Vec<Author>,
}
impl Room {
fn emit(&mut self, who: u8, t: u64, p: &Payload) -> [u8; 32] {
self.raw(who, t, p.kind(), p.encode())
}
fn raw(&mut self, who: u8, t: u64, kind: u8, payload: Vec<u8>) -> [u8; 32] {
let room = self.room;
let a = match self.authors.iter().position(|a| a.who == who) {
Some(i) => &mut self.authors[i],
None => {
self.authors.push(Author { who, seq: 0, prev: [0; 32], records: vec![] });
self.authors.last_mut().expect("just pushed")
}
};
let ev = Event::sign(&key(who), room, a.seq, a.prev, t, None, kind, payload);
a.seq += 1;
a.prev = ev.digest();
a.records.push(ev.to_bytes());
a.prev
}
fn life(&mut self, t: u64, subject: u8, d: Detail) {
self.emit(RELAY, t, &Payload::Lifecycle { kind: d.kind(), subject: pk(subject), detail: d.encode() });
}
}
/// A scene written out: everything a take verifier is given.
struct Written {
descriptor: Vec<u8>,
grants: Vec<Vec<u8>>,
head: Option<String>,
chains: Vec<Vec<Vec<u8>>>,
manifest: Vec<u8>,
parts: Vec<(u64, Vec<u8>)>,
receipts: Vec<Vec<u8>>,
consents: Vec<Vec<u8>>,
}
fn write(s: &Scene) -> Written {
// The descriptor.
let d = Descriptor {
authority: pk(AUTHORITY),
clock: pk(RELAY),
t0_us: T0_US,
tick_us: S,
origin: ORIGIN.into(),
limits: Limits { max_present: 8, max_publishers: 3, reval_s: 300, ceiling: Policy::default() },
roles: vec![director(), performer(), audience()],
slots: vec![],
profiles: ["speech", "music", "camera-1080"].iter().map(|n| registered_profile(n).expect("registered")).collect(),
review: Review { kinds: [0; 32], deadline_ms: 0, fallback: 0 },
recording: if s.mode == 0 {
Recording { mode: 0, retention_days: 30, commit_interval_ms: 0, jitter_ms: 0, regions: s.regions.clone() }
} else {
Recording { mode: s.mode, retention_days: 30, commit_interval_ms: 2_000, jitter_ms: 500, regions: s.regions.clone() }
},
sig: [0; 64],
}
.sign(&key(AUTHORITY));
let room_id = d.room_id();
// The grants.
let grant = |who: u8, role: usize| {
let r = &d.roles[role];
Grant {
room: room_id,
principal: pk(who),
prev: [0; 32],
effective: 0,
nbf: T0_S - 3_600,
exp: T0_S + 86_400,
role: role as u8,
caps: r.caps,
precedence: 0,
once: [0; 16],
viewer: [0; 16],
policy: Policy::with_kinds(&kinds_of(&r.events)),
joules_ceiling: NO_CEILING,
funds_ceiling: NO_CEILING,
currency: [0; 3],
profiles: vec![],
origins: vec!["https://relay.example".into()],
attrs: vec![],
sig: [0; 64],
}
};
let gd = Grant { profiles: vec![0], ..grant(DIRECTOR, 0) }.sign(&key(AUTHORITY));
let mut gp = Grant { profiles: vec![1, 2], attrs: s.attrs.clone(), ..grant(DEVICE, 1) };
if !s.device_commits {
use events::kind::*;
gp.policy = Policy::with_kinds(&[CHAT, REACTION, MARKER]);
}
let gp = gp.sign(&key(AUTHORITY));
let gv = grant(VIEWER, 2).sign(&key(AUTHORITY));
let grants = vec![gd.to_bytes(), gp.to_bytes(), gv.to_bytes()];
// The consent.
let consent = s.consent.map(|c| {
Consent {
subject: pk(c.subject),
minor: c.minor,
guardian: c.guardian.map_or([0; 32], pk),
purposes: c.purposes,
room: room_id,
nbf: T0_S - 86_400,
exp: c.exp,
nonce: sha256(&[b"wai:capture-take/corpus/nonce", s.label.as_bytes()])[..16].try_into().expect("16 bytes"),
sig: [0; 64],
}
.sign(&key(c.signer))
});
// The key log.
let mut keys = vec![KeyEntry::active("room-authority", pk(AUTHORITY), &[room::KEY_LOG_AUTHORITY])];
if s.store_listed {
keys.push(KeyEntry::active("take-store", pk(STORE), &[STORE_AUTHORITY]));
}
let revoked = match (&consent, s.consent) {
(Some(c), Some(p)) if p.revoked => vec![c.digest()],
_ => vec![],
};
let revision = Revision {
origin: ORIGIN.into(),
seq: 0,
prev: None,
issued_at: "2026-10-15T00:00:00Z".into(),
root: [0; 32],
next_root: commitment(&pk(LOG_NEXT)),
keys,
revoked_modules: revoked,
grant_max_s: 86_400,
status_max_age_s: 3_600,
sig: [0; 64],
};
let head = Revision::seal(&key(LOG_ROOT), revision).expect("a well-formed revision").to_json();
// The room's events: the clock's lifecycle, the director's, the viewer's.
let mut rm = Room { room: room_id, authors: vec![] };
rm.life(0, DIRECTOR, Detail::Join { grant: gd.digest() });
rm.life(0, DEVICE, Detail::Join { grant: gp.digest() });
rm.life(500 * MS, VIEWER, Detail::Join { grant: gv.digest() });
rm.life(S, DEVICE, Detail::Publish { slot: String::new(), media: media::AUDIO, profile: 1, layers: 1 });
rm.life(S, DEVICE, Detail::Publish { slot: String::new(), media: media::VIDEO, profile: 2, layers: 3 });
rm.life(S, DIRECTOR, Detail::Publish { slot: String::new(), media: media::AUDIO, profile: 0, layers: 1 });
let start = rm.emit(DIRECTOR, s.start_t, &Payload::Control { target: pk(DEVICE), command: Command::RecordStart { count_in_ms: s.count_in_ms } });
let capture = s.chunks[0].0;
for (k, note) in [(3, "rushed the run"), (7, "lovely phrasing")] {
let polarity = if k == 3 { 2 } else { 1 };
rm.emit(DIRECTOR, capture + k * S, &Payload::Marker(Marker { taxonomy: 4, polarity, text: note.into(), attachment: None }));
}
rm.emit(DIRECTOR, s.stop_t, &Payload::Control { target: pk(DEVICE), command: Command::RecordStop });
let chat = rm.emit(VIEWER, s.start_t + 500 * MS, &Payload::Chat { text: "good luck".into() });
// The chunks, as captured, as the device commits them, and as the manifest lists them.
let device_time = |t: u64| (t as i128 - s.offset as i128) as u64;
let bytes: Vec<Vec<u8>> = (0..s.chunks.len()).map(|i| chunk_bytes(s.label.as_bytes(), i)).collect();
let edited = |c: usize| {
let mut b = bytes.clone();
b[c] = chunk_bytes(b"edited", c);
b
};
let descriptors = |bs: &[Vec<u8>]| -> Vec<Chunk> {
s.chunks.iter().zip(bs).enumerate().map(|(i, (&(a, z), b))| Chunk::of(i as u64, device_time(a), device_time(z), b)).collect()
};
let honest = descriptors(&bytes);
let rewritten = s.rewrite.map(|(_, c)| descriptors(&edited(c)));
// The device's commitments.
let take = take_id(&room_id, &pk(DEVICE), &start);
let mut times = vec![(s.start_t + 200 * MS, phase::OPEN)];
let mut t = capture + 2_300 * MS;
while t < s.stop_t {
times.push((t, phase::RUNNING));
t += 2 * S;
}
if !s.no_close {
times.push((s.stop_t + 400 * MS, phase::CLOSE));
}
let mut commits: Vec<[u8; 32]> = Vec::new();
let (mut prev, mut size) = ([0u8; 32], 0u64);
for (n, &(t, ph)) in times.iter().enumerate() {
let n = n as u32;
let t = s.late.filter(|l| l.0 == n).map_or(t, |l| l.1);
let history = match (&rewritten, s.rewrite) {
(Some(r), Some((from, _))) if n >= from => r,
_ => &honest,
};
let done = if ph == phase::OPEN {
0
} else if ph == phase::CLOSE {
s.chunks.len() as u64
} else {
s.chunks.iter().filter(|c| c.1 <= t).count() as u64 + u64::from(s.early_add == Some(n))
};
let first = size;
let new_size = done.max(size);
let l = leaves(&history[..new_size as usize]);
let c = Commit {
take,
n,
phase: ph,
prev: if s.break_prev == Some(n) { [0; 32] } else { prev },
first,
size: new_size,
root: root(&history[..new_size as usize]),
offset: s.offset + s.offset_jump.filter(|j| j.0 == n).map_or(0, |j| j.1),
proof: consistency_proof(first as usize, &l),
};
prev = rm.emit(DEVICE, t, &Payload::TakeCommit(c.encode()));
commits.push(prev);
size = new_size;
}
if s.malformed {
rm.raw(DEVICE, s.stop_t + S, events::kind::TAKE_COMMIT, vec![0; 10]);
}
if let Some(n) = s.drop {
let a = rm.authors.iter_mut().find(|a| a.who == DEVICE).expect("the device's chain");
let i = a.records.iter().position(|r| Event::parse(r).expect("a corpus record").digest() == commits[n as usize]).expect("held");
a.records.remove(i);
}
// What the device uploads: its master as committed, or as edited.
let final_bytes = match (s.rewrite, s.manifest_edit) {
(Some((_, c)), _) | (None, Some(c)) => edited(c),
_ => bytes.clone(),
};
let mut listed = descriptors(&final_bytes);
listed.truncate(size as usize);
let n = listed.len();
// The parts.
let mut parts: Vec<(u64, Vec<u8>)> = (0..n).map(|i| (i as u64, final_bytes[i].clone())).collect();
match s.parts {
Parts::Whole => {}
Parts::Resumed => parts.insert(4, (3, final_bytes[3].clone())),
Parts::Missing(i) => {
parts.remove(i);
}
Parts::Altered(i) => parts[i].1[0] ^= 0x01,
Parts::Swapped(a, b) => {
let (x, y) = (parts[a].1.clone(), parts[b].1.clone());
parts[a].1 = y;
parts[b].1 = x;
}
Parts::Extra => parts.push((n as u64, chunk_bytes(b"extra", n))),
}
// The receipts.
let steps: Vec<(u8, usize)> = match s.upload {
Upload::Whole => vec![(state::OPEN, 0), (state::PROGRESS, n), (state::SEALED, n)],
Upload::Resumed => vec![(state::OPEN, 0), (state::PROGRESS, 4), (state::RESUME, 4), (state::PROGRESS, n), (state::SEALED, n)],
Upload::Stopped(k) => vec![(state::OPEN, 0), (state::PROGRESS, k)],
Upload::Unsealed => vec![(state::OPEN, 0), (state::PROGRESS, n)],
};
let mut receipts = Vec::new();
let mut rprev = [0u8; 32];
for (i, &(st, held)) in steps.iter().enumerate() {
let r = Receipt {
store: pk(STORE),
take,
seq: if s.receipt_seq_skip && i == 1 { 2 } else { i as u64 },
prev: rprev,
at: T0_US + (20 + 3 * i as u64) * S,
state: st,
held: held as u64,
root: if s.receipt_root && i == 1 { [0x5A; 32] } else { root(&listed[..held]) },
region: s.region,
sig: [0; 64],
}
.sign(&key(STORE));
rprev = r.digest();
receipts.push(r.to_bytes());
}
// The manifest.
let m_room = if s.other_room { sha256(&[b"wai:capture-take/corpus/another room"]) } else { room_id };
let m_start = if s.start_is_chat { chat } else { start };
let manifest = Manifest {
room: m_room,
device: pk(DEVICE),
principal: pk(s.principal),
start: m_start,
take: take_id(&m_room, &pk(DEVICE), &m_start),
store: pk(STORE),
claims: s.claims,
purposes: purpose::RECORD | purpose::DISTRIBUTE,
root: root(&listed),
chunks: listed,
commits: commits.iter().enumerate().filter(|(i, _)| s.omit != Some(*i as u32)).map(|(_, c)| *c).collect(),
depicted: vec![(pk(s.principal), match &consent {
Some(c) if c.subject == pk(s.principal) => c.digest(),
_ => [0; 32],
})],
sig: [0; 64],
}
.sign(&key(DEVICE));
let mut manifest = manifest.to_bytes();
if s.flip_sig {
let l = manifest.len();
manifest[l - 33] ^= 0x04;
}
let consents = match (consent, s.consent) {
(Some(c), Some(p)) if p.given => vec![c.to_bytes()],
_ => vec![],
};
Written {
descriptor: d.to_bytes(),
grants,
head: Some(head),
chains: rm.authors.into_iter().map(|a| a.records).collect(),
manifest,
parts,
receipts,
consents,
}
}
// ---- vectors ----
/// Which claim a vector is written to fail.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum Claim {
SingleTake,
Aligned,
Complete,
/// `publishable` for `record`.
Record,
}
/// What a vector was written to do.
#[derive(Clone, Copy, Debug)]
enum Intent {
/// Every claim holds, and `publishable` for every purpose but `train`.
Good,
/// The claims hold but this one, which fails with this reason.
Fails(Claim, Reason),
/// Refused, at a place of this kind, with this reason.
Refuse(&'static str, Reason),
}
struct Vector {
name: &'static str,
about: &'static str,
w: Written,
intent: Intent,
}
fn inputs(w: &Written, at: u64) -> TakeInputs<'_> {
TakeInputs {
descriptor: &w.descriptor,
grants: &w.grants,
head: w.head.as_deref(),
chains: &w.chains,
manifest: &w.manifest,
parts: &w.parts,
receipts: &w.receipts,
consents: &w.consents,
at,
}
}
fn place_kind(p: &Place) -> &'static str {
match p {
Place::Room(_) => "room",
Place::Manifest => "manifest",
Place::Record { .. } => "record",
Place::Store => "store",
Place::Receipt(_) => "receipt",
Place::Consent(_) => "consent",
}
}
fn reached(intent: Intent, run: &TakeRun) -> bool {
let train = Verdict::Fails(Reason::PurposeNotConsented);
match (intent, &run.outcome) {
(Intent::Good, TakeOutcome::Verdicts(v)) => {
v.single_take.holds() && v.aligned.holds() && v.complete.holds() && v.publishable[..3].iter().all(|p| p.holds()) && v.publishable[3] == train && v.stands
}
(Intent::Fails(c, r), TakeOutcome::Verdicts(v)) => {
let got = match c {
Claim::SingleTake => v.single_take,
Claim::Aligned => v.aligned,
Claim::Complete => v.complete,
Claim::Record => v.publishable[1],
};
got == Verdict::Fails(r) && !v.stands
}
(Intent::Refuse(k, r), TakeOutcome::Refuse { at, reason }) => place_kind(at) == k && *reason == r,
_ => false,
}
}
fn vector(name: &'static str, about: &'static str, s: Scene, intent: Intent) -> Vector {
Vector { name, about, w: write(&s), intent }
}
fn fails(c: Claim, r: Reason) -> Intent {
Intent::Fails(c, r)
}
fn good_vectors() -> Vec<Vector> {
let mut out = vec![vector(
"lesson",
"a lesson take: the director's remote record_start with a four-second count-in, the performer's device committing every two seconds while it captures ten one-second chunks, the director's markers, record_stop and the closing commitment, an upload sealed by the store in DE within the room's region pin, and the performer's consent to live, record and distribute (not train)",
lesson("lesson"),
Intent::Good,
)];
let mut s = lesson("resumed_upload");
s.upload = Upload::Resumed;
s.parts = Parts::Resumed;
out.push(vector(
"resumed_upload",
"the lesson take with an upload interrupted after four chunks and resumed: the store's receipts run open, progress, resume, progress, sealed, and chunk 3 is sent again after the interruption",
s,
Intent::Good,
));
let mut s = lesson("minor_guardian");
s.attrs = vec![("minor".into(), "true".into()), ("subject".into(), hex(&pk(MINOR)))];
s.principal = MINOR;
s.consent = Some(ConsentPlan { subject: MINOR, minor: 1, guardian: Some(GUARDIAN), signer: GUARDIAN, ..s.consent.expect("a consent") });
out.push(vector(
"minor_guardian",
"an audition take by a performer the room authority marks a minor in the device's grant, under a consent the guardian signs",
s,
Intent::Good,
));
out
}
fn negative_vectors() -> Vec<Vector> {
let mut out = Vec::new();
let mut s = lesson("gap");
s.drop = Some(2);
out.push(vector("commit_gap", "commitment 2 not delivered by the relay: the device's chain has a gap where the manifest lists it (§9.4 single-take 1); aligned and complete hold", s, fails(Claim::SingleTake, DELIVERY_GAP)));
let mut s = lesson("spliced");
s.parts = Parts::Altered(4);
out.push(vector("spliced_chunk", "the uploaded part 4 altered after its chunk was committed (§9.4 single-take 11); complete fails too, as chunk 4 was not uploaded", s, fails(Claim::SingleTake, Reason::ChunkAltered)));
let mut s = lesson("reordered");
s.parts = Parts::Swapped(3, 5);
out.push(vector("reordered_chunk", "parts 3 and 5 uploaded each at the other's index (§9.4 single-take 11)", s, fails(Claim::SingleTake, Reason::ChunkReordered)));
let mut s = lesson("extra");
s.parts = Parts::Extra;
out.push(vector("extra_chunk", "a part at index 10, beyond every committed chunk (§9.4 single-take 11); complete holds", s, fails(Claim::SingleTake, Reason::ChunkExtra)));
let mut s = lesson("missing");
s.parts = Parts::Missing(6);
s.upload = Upload::Stopped(6);
out.push(vector("missing_chunk", "chunk 6 never uploaded, the store's receipts stopping at six held: every commitment verifies and single-take holds; complete fails (§9.4)", s, fails(Claim::Complete, Reason::ChunkMissing)));
let mut s = lesson("late");
s.late = Some((3, 13_800 * MS));
out.push(vector("late_commit", "commitment 3 sent 2.8 s after chunk 4's capture ended, beyond the interval plus the jitter (§9.4 single-take 10)", s, fails(Claim::SingleTake, Reason::CommitLate)));
let mut s = lesson("rewrite");
s.rewrite = Some((4, 2));
out.push(vector("root_not_extending", "from commitment 4 on the device commits a history whose chunk 2 is edited, and uploads it: commitment 4's root does not extend commitment 3's (§4.3, §9.4 single-take 3); aligned and complete hold of the edited upload", s, fails(Claim::SingleTake, Reason::RootNotExtending)));
let mut s = lesson("misaligned");
let capture = s.start_t + 400 * MS;
s.chunks = (0..10).map(|i| (capture + i * S, capture + (i + 1) * S)).collect();
s.stop_t = capture + 10 * S;
out.push(vector("misaligned", "the device began capturing at once, ignoring the four-second count-in: chunk 0 starts 3.6 s before the capture instant (§9.4 aligned 4); single-take holds", s, fails(Claim::Aligned, Reason::Misaligned)));
let mut s = lesson("consent_missing");
s.consent = s.consent.map(|c| ConsentPlan { given: false, ..c });
out.push(vector("consent_missing", "the manifest binds the performer's consent and no consent record is given (§9.4 publishable 1)", s, fails(Claim::Record, Reason::ConsentMissing)));
let mut s = lesson("consent_expired");
s.consent = s.consent.map(|c| ConsentPlan { exp: T0_S + 3_600, ..c });
out.push(vector("consent_expired", "a consent that ends an hour after t0, before the evaluation instant (§9.4 publishable 4)", s, fails(Claim::Record, Reason::ConsentExpired)));
let mut s = lesson("consent_revoked");
s.consent = s.consent.map(|c| ConsentPlan { revoked: true, ..c });
out.push(vector("consent_revoked", "a consent whose digest the key log's head lists in revoked_modules (§8.3, §9.4 publishable 3)", s, fails(Claim::Record, Reason::ConsentRevoked)));
let mut s = lesson("minor_alone");
s.attrs = vec![("minor".into(), "true".into()), ("subject".into(), hex(&pk(PERFORMER)))];
out.push(vector("minor_without_guardian", "a performer the device's grant marks a minor, under a consent the performer signed alone (§3.1, §9.4 publishable 2)", s, fails(Claim::Record, Reason::GuardianMissing)));
let mut s = lesson("impostor");
s.principal = IMPOSTOR;
out.push(vector("device_not_bound", "a manifest that claims the take for another principal than the one the device's grant names in its subject attribute (§3.1, §9.2 step 4)", s, Intent::Refuse("record", Reason::DeviceNotBound)));
out
}
fn reason_vectors() -> Vec<Vector> {
let mut out = Vec::new();
let mut s = lesson("not_recorded");
s.mode = 0;
out.push(vector("recording_not_permitted", "a take in a room whose recording policy has mode 0 (§9.2 step 1)", s, Intent::Refuse("manifest", Reason::RecordingNotPermitted)));
let mut s = lesson("start_is_chat");
s.start_is_chat = true;
out.push(vector("take_unknown", "a manifest whose start is the digest of a chat message, not of a record_start to the device (§9.2 step 2)", s, Intent::Refuse("manifest", Reason::TakeUnknown)));
let mut s = lesson("malformed");
s.malformed = true;
out.push(vector("commit_malformed", "a take_commit of the device whose payload is ten bytes (§5.2, §9.2 step 3)", s, Intent::Refuse("record", Reason::CommitMalformed)));
let mut s = lesson("claims_bit");
s.claims = 0b1000;
out.push(vector("manifest_invalid", "a manifest whose claims byte sets bit 3 (§7.2)", s, Intent::Refuse("manifest", Reason::ManifestInvalid)));
let mut s = lesson("omit");
s.omit = Some(2);
out.push(vector("manifest_mismatch", "a manifest that leaves out commitment 2, which the room holds (§9.2 step 5)", s, Intent::Refuse("manifest", Reason::ManifestMismatch)));
let mut s = lesson("manifest_sig");
s.flip_sig = true;
out.push(vector("manifest_bad_signature", "one bit of the manifest's signature flipped (§7.1)", s, Intent::Refuse("manifest", events::Reason::BadSignature.into())));
let mut s = lesson("other_room");
s.other_room = true;
out.push(vector("manifest_wrong_room", "a manifest of another room, its take id that room's (§7.2)", s, Intent::Refuse("manifest", events::Reason::WrongRoom.into())));
let mut s = lesson("unlisted_store");
s.store_listed = false;
out.push(vector("store_not_listed", "a store the key log's head does not list with capture/store (§6.4)", s, Intent::Refuse("store", Reason::StoreNotListed)));
let mut s = lesson("receipt_seq");
s.receipt_seq_skip = true;
out.push(vector("upload_receipt_invalid", "receipt 1 states seq 2 (§6.3)", s, Intent::Refuse("receipt", Reason::UploadReceiptInvalid)));
let mut s = lesson("receipt_root");
s.receipt_root = true;
out.push(vector("upload_receipt_mismatch", "receipt 1's root is not the root over the chunks it says it holds (§6.3)", s, Intent::Refuse("receipt", Reason::UploadReceiptMismatch)));
let mut s = lesson("region");
s.region = *b"US";
out.push(vector("store_outside_region", "a store that keeps the master in US, outside the room's region pin of DE and FR (§6.3)", s, Intent::Refuse("receipt", Reason::StoreOutsideRegion)));
let mut s = lesson("consent_purposes");
s.consent = s.consent.map(|c| ConsentPlan { purposes: 0, ..c });
out.push(vector("consent_invalid", "a consent record that consents to no purpose (§8.2)", s, Intent::Refuse("consent", Reason::ConsentInvalid)));
let mut s = lesson("consent_forged");
s.consent = s.consent.map(|c| ConsentPlan { signer: VIEWER, ..c });
out.push(vector("consent_bad_signature", "a consent of the performer signed by another key (§8.1)", s, Intent::Refuse("consent", events::Reason::BadSignature.into())));
let mut s = lesson("prev_zero");
s.break_prev = Some(3);
out.push(vector("commit_chain_broken", "commitment 3 whose prev is zero, not commitment 2's digest (§5.3, §9.4 single-take 2)", s, fails(Claim::SingleTake, Reason::CommitChainBroken)));
let mut s = lesson("no_close");
s.no_close = true;
out.push(vector("take_not_closed", "a device that stopped committing before the stop and never closed the take; its upload holds the eight chunks it committed (§9.4 single-take 4); complete holds", s, fails(Claim::SingleTake, Reason::TakeNotClosed)));
let mut s = lesson("manifest_edit");
s.manifest_edit = Some(2);
out.push(vector("manifest_not_committed", "a manifest and an upload whose chunk 2 is edited, under commitments of the chunk as captured (§9.4 single-take 5); complete fails the same way", s, fails(Claim::SingleTake, Reason::ManifestNotCommitted)));
let mut s = lesson("offset_jump");
s.offset_jump = Some((3, 600_000));
out.push(vector("clock_unstable", "commitment 3 states an offset 600 ms from the opening commitment's, beyond the jitter (§9.4 single-take 6)", s, fails(Claim::SingleTake, Reason::ClockUnstable)));
let mut s = lesson("pause");
s.chunks[5].0 += 200 * MS;
out.push(vector("capture_discontinuous", "chunk 5 begins 200 ms after chunk 4 ends: a pause in the capture (§9.4 single-take 7)", s, fails(Claim::SingleTake, Reason::CaptureDiscontinuous)));
let mut s = lesson("long_chunk");
let c = 6 * S;
let ends = [c, c + S, c + 2 * S, c + 3 * S, c + 5_500 * MS, c + 6_500 * MS, c + 7_500 * MS, c + 8_500 * MS, c + 9_500 * MS, c + 10 * S];
s.chunks = ends.windows(2).map(|w| (w[0], w[1])).collect();
out.push(vector("chunk_too_long", "chunk 3 spans 2.5 s of capture, beyond the commit interval (§9.4 single-take 8)", s, fails(Claim::SingleTake, Reason::ChunkTooLong)));
let mut s = lesson("early");
s.early_add = Some(2);
out.push(vector("commit_early", "commitment 2 commits chunk 4, whose capture ends 700 ms after it, beyond the jitter (§9.4 single-take 9)", s, fails(Claim::SingleTake, Reason::CommitEarly)));
let mut s = lesson("unsealed");
s.upload = Upload::Unsealed;
out.push(vector("upload_not_sealed", "every part uploaded and the store's last receipt a progress, never sealed (§9.4 complete 3)", s, fails(Claim::Complete, Reason::UploadNotSealed)));
let mut s = lesson("room_refused");
s.device_commits = false;
out.push(vector("room_refused", "a device whose grant does not permit take_commit: the room refuses its first commitment (live-rooms §9.2 step 4), and the take is refused there", s, Intent::Refuse("room", events::Reason::KindNotPermitted.into())));
out
}
fn vectors() -> Vec<Vector> {
let mut v = good_vectors();
v.extend(negative_vectors());
v.extend(reason_vectors());
v
}
// ---- the file ----
fn place_json(p: &Place) -> Value {
match p {
Place::Room(at) => {
let mut v = vec![json!("room")];
match at {
At::Descriptor => v.push(json!("descriptor")),
At::KeyLog => v.push(json!("key_log")),
At::Grant(i) => v.extend([json!("grant"), json!(i)]),
At::Record { chain, record } => v.extend([json!("record"), json!(chain), json!(record)]),
}
Value::Array(v)
}
Place::Manifest => json!(["manifest"]),
Place::Record { chain, record } => json!(["record", chain, record]),
Place::Store => json!(["store"]),
Place::Receipt(i) => json!(["receipt", i]),
Place::Consent(i) => json!(["consent", i]),
}
}
fn verdict_json(v: Verdict) -> Value {
match v {
Verdict::Holds => json!("holds"),
Verdict::Fails(r) => json!(r.code()),
}
}
/// What a run states, as the corpus writes it: the outcome and what goes with it.
fn outcome_json(run: &TakeRun) -> Map<String, Value> {
let mut o = Map::new();
match &run.outcome {
TakeOutcome::Refuse { at, reason } => {
o.insert("outcome".into(), json!("refuse"));
o.insert("reason".into(), json!(reason.code()));
o.insert("at".into(), place_json(at));
}
TakeOutcome::Verdicts(v) => {
let take = run.take.expect("a take that read");
o.insert("outcome".into(), json!("verdicts"));
o.insert("take".into(), json!(hex(&take)));
o.insert("stream_id".into(), json!(stream_id(&take)));
o.insert("commits".into(), json!(run.held.iter().map(|c| hex(c)).collect::<Vec<_>>()));
o.insert("size".into(), json!(run.size));
o.insert("root".into(), json!(hex(&run.root)));
let publishable: Map<String, Value> = purpose::ALL.iter().zip(v.publishable).map(|((_, n), v)| (n.to_string(), verdict_json(v))).collect();
o.insert(
"verdicts".into(),
json!({
"single_take": verdict_json(v.single_take),
"aligned": verdict_json(v.aligned),
"complete": verdict_json(v.complete),
"publishable": publishable,
}),
);
o.insert("stands".into(), json!(v.stands));
o.insert(
"markers".into(),
Value::Array(
run.markers
.iter()
.map(|m| json!({"event": hex(&m.event), "t": m.t, "chunk": m.at.chunk, "into_chunk": m.at.into_chunk, "into_master": m.at.into_master}))
.collect(),
),
);
}
}
o
}
fn vector_json(v: &Vector) -> Value {
let run = verify_take(&inputs(&v.w, EVALUATED_AT));
assert!(reached(v.intent, &run), "vector {} does not reach the outcome it was written to have ({:?}): {:?}", v.name, v.intent, run.outcome);
let w = &v.w;
let mut o = Map::new();
o.insert("name".into(), json!(v.name));
o.insert("about".into(), json!(v.about));
o.insert("descriptor".into(), json!(hex(&w.descriptor)));
o.insert("grants".into(), json!(w.grants.iter().map(|g| hex(g)).collect::<Vec<_>>()));
o.insert("key_log_head".into(), w.head.as_ref().map_or(Value::Null, |h| json!(h)));
o.insert("chains".into(), json!(w.chains.iter().map(|c| c.iter().map(|r| hex(r)).collect::<Vec<_>>()).collect::<Vec<_>>()));
o.insert("manifest".into(), json!(hex(&w.manifest)));
o.insert("parts".into(), Value::Array(w.parts.iter().map(|(i, b)| json!({"index": i, "bytes": hex(b)})).collect()));
o.insert("receipts".into(), json!(w.receipts.iter().map(|r| hex(r)).collect::<Vec<_>>()));
o.insert("consents".into(), json!(w.consents.iter().map(|c| hex(c)).collect::<Vec<_>>()));
o.insert("evaluated_at".into(), json!(EVALUATED_AT));
o.extend(outcome_json(&run));
Value::Object(o)
}
/// The corpus file, `vectors.json`.
pub fn generate() -> String {
let doc = json!({
"corpus": CAPABILITY,
"extension": "extensions/capture-binding.md",
"principals": PRINCIPALS.iter().map(|(n, i)| (n.to_string(), json!(hex(&pk(*i))))).collect::<Map<String, Value>>(),
"vectors": vectors().iter().map(vector_json).collect::<Vec<_>>(),
});
serde_json::to_string_pretty(&doc).expect("the corpus serialises") + "\n"
}
fn written_of(v: &Value) -> Option<Written> {
let hexes = |k: &str| -> Option<Vec<Vec<u8>>> { v[k].as_array()?.iter().map(|g| g.as_str().and_then(unhex)).collect() };
Some(Written {
descriptor: unhex(v["descriptor"].as_str()?)?,
grants: hexes("grants")?,
head: v["key_log_head"].as_str().map(String::from),
chains: v["chains"]
.as_array()?
.iter()
.map(|c| c.as_array().and_then(|rs| rs.iter().map(|r| r.as_str().and_then(unhex)).collect::<Option<Vec<_>>>()))
.collect::<Option<_>>()?,
manifest: unhex(v["manifest"].as_str()?)?,
parts: v["parts"].as_array()?.iter().map(|p| Some((p["index"].as_u64()?, unhex(p["bytes"].as_str()?)?))).collect::<Option<_>>()?,
receipts: hexes("receipts")?,
consents: hexes("consents")?,
})
}
/// Check a corpus file against this module; the failures, one line each.
pub fn verify(doc: &str) -> Vec<String> {
let mut fails = Vec::new();
let root: Value = match serde_json::from_str(doc) {
Ok(v) => v,
Err(e) => return vec![format!("the corpus does not parse: {e}")],
};
for v in root["vectors"].as_array().into_iter().flatten() {
let name = v["name"].as_str().unwrap_or("?");
let Some(w) = written_of(v) else {
fails.push(format!("{name}: the vector does not read"));
continue;
};
let Some(at) = v["evaluated_at"].as_u64() else {
fails.push(format!("{name}: no evaluated_at"));
continue;
};
let run = verify_take(&inputs(&w, at));
for (k, want) in outcome_json(&run) {
if v[&k] != want {
fails.push(format!("{name}: {k} is {want}, the corpus states {}", v[&k]));
}
}
}
fails
}

View file

@ -209,6 +209,9 @@ pub const CAP_VIDEO_SSAI: &str = "wai.video.ssai";
pub const CAP_VIDEO_KEYS: &str = "wai.video.keys";
pub const CAP_VIDEO_PACKAGE: &str = "wai.video.package";
pub const CAP_VIDEO_CHANNEL: &str = "wai.video.channel";
pub const CAP_LIVE_EVENTS: &str = "wai.live.events";
pub const CAP_LIVE_ROOM: &str = "wai.live.room";
pub const CAP_CAPTURE_TAKE: &str = "wai.capture.take";
/// One row per string SPEC §5 registers that is a payload, companion or record:
/// `(string, SPEC section, edition, registered)`.
@ -286,6 +289,9 @@ const ROWS: &[(&str, &str, Option<&str>, &str)] = &[
(CAP_ASSET_PROVENANCE, S_INTERACTIVE, None, "2026-06-13"),
(CAP_QUANTUM_CALIBRATION, S_INTERACTIVE, None, "2026-08-01"),
(CAP_QUANTUM_JOB, S_INTERACTIVE, None, "2026-08-01"),
(CAP_LIVE_EVENTS, S_INTERACTIVE, None, "2026-10-16"),
(CAP_LIVE_ROOM, S_INTERACTIVE, None, "2026-10-16"),
(CAP_CAPTURE_TAKE, S_INTERACTIVE, None, "2026-10-17"),
(CAP_AUDIO_IAMF, S_IMMERSIVE, None, "2026-06-17"),
(CAP_AUDIO_AC4, S_IMMERSIVE, None, "2026-06-17"),
(CAP_AUDIO_IVAS, S_IMMERSIVE, None, "2026-06-17"),
@ -332,6 +338,7 @@ const DERIVATIONS_REGISTERED: &str = "2026-09-28";
const RECORDS: &[&str] = &[
CAP_ASSET_PROVENANCE, CAP_QUANTUM_CALIBRATION, CAP_QUANTUM_JOB,
CAP_VIDEO_MANIFEST, CAP_VIDEO_SSAI, CAP_VIDEO_KEYS, CAP_VIDEO_PACKAGE, CAP_VIDEO_CHANNEL,
CAP_LIVE_EVENTS, CAP_LIVE_ROOM, CAP_CAPTURE_TAKE,
];
/// A named identifier SPEC uses that has the shape of a capability string but is
@ -388,7 +395,8 @@ pub enum WireKind {
WellKnown,
/// A playlist tag.
PlaylistTag,
/// A playlist attribute.
/// A playlist attribute, or a value a WAI binding gives one (an `EXT-X-DATERANGE`
/// `CLASS`).
PlaylistAttribute,
/// The `kind` member of a JSON document.
DocumentKind,
@ -468,6 +476,9 @@ const HTTP_DELIVERY: &str = "extensions/http-delivery.md";
const MOQ_FORMAT: &str = "extensions/moq-streaming-format.md";
const JWP_RECEIPTS: &str = "extensions/jwp-receipts.md";
const PACKAGING: &str = "extensions/packaging.md";
const LIVE_EVENTS: &str = "extensions/live-events.md";
const LIVE_ROOMS: &str = "extensions/live-rooms.md";
const CAPTURE_BINDING: &str = "extensions/capture-binding.md";
const fn wire(name: &'static str, kind: WireKind, document: &'static str, section: &'static str,
status: WireStatus) -> WireIdentifier {
@ -476,8 +487,8 @@ const fn wire(name: &'static str, kind: WireKind, document: &'static str, sectio
/// The wire identifiers of the container, of its two delivery bindings
/// (http-delivery, moq-streaming-format), of the key discovery those bindings serve
/// (jwp-receipts §7) and of packaging, in document order. The export lists them sorted
/// by name and kind.
/// (jwp-receipts §7), of packaging, of live events, of live rooms and of capture binding, in document order. The
/// export lists them sorted by name and kind.
pub const WIRE_IDENTIFIERS: &[WireIdentifier] = &[
wire("WAI1", WireKind::Magic, "SPEC.md", "§2", WireStatus::Defined),
wire("WAI2", WireKind::Magic, "SPEC.md", "§7.1", WireStatus::Defined),
@ -520,6 +531,17 @@ pub const WIRE_IDENTIFIERS: &[WireIdentifier] = &[
wire("tag:transaction.science,2026:wai/class", WireKind::Scheme, PACKAGING, "§8", WireStatus::Defined),
wire("tag:transaction.science,2026:wai/stages", WireKind::Scheme, PACKAGING, "§8", WireStatus::Defined),
wire("wai_delivery_timeout_ms", WireKind::Catalog, PACKAGING, "§8", WireStatus::Defined),
wire("WEV1", WireKind::Magic, LIVE_EVENTS, "§3.1", WireStatus::Defined),
wire("WEP1", WireKind::Magic, LIVE_EVENTS, "§5", WireStatus::Defined),
wire("urn:wai:event:1", WireKind::Catalog, LIVE_EVENTS, "§8.1", WireStatus::Defined),
wire("science.transaction.wai.event", WireKind::PlaylistAttribute, LIVE_EVENTS, "§8.3", WireStatus::Defined),
wire("X-WAI-EVENT", WireKind::PlaylistAttribute, LIVE_EVENTS, "§8.3", WireStatus::Defined),
wire("urn:wai:event:1", WireKind::Scheme, LIVE_EVENTS, "§8.4", WireStatus::Defined),
wire("WRD1", WireKind::Magic, LIVE_ROOMS, "§3.1", WireStatus::Defined),
wire("WRG1", WireKind::Magic, LIVE_ROOMS, "§4.1", WireStatus::Defined),
wire("WUR1", WireKind::Magic, CAPTURE_BINDING, "§6.2", WireStatus::Defined),
wire("WTM1", WireKind::Magic, CAPTURE_BINDING, "§7.1", WireStatus::Defined),
wire("WCN1", WireKind::Magic, CAPTURE_BINDING, "§8.1", WireStatus::Defined),
];
/// A string with the shape of a capability that the reference implementation uses

View file

@ -376,6 +376,36 @@ pub mod delivery_terms;
#[cfg(feature = "telemetry")]
pub mod telemetry;
// Live events (extensions/live-events.md): `wai.live.events`, a signed, hash-chained,
// timeline-bound event track. Cues, chat, reactions, polls, markers, remote control,
// captions, moderation decisions and room lifecycle are one record, `WEV1`, with its
// policy (`WEP1`), its verifier, poll tallies replayed as feed state, and the field
// mappings onto an MSF event timeline, EXT-X-DATERANGE and a version 1 emsg.
// Its constants are Rust-only: names like VERSION, IN, OUT and BREAK must not
// become macros in include/wai.h.
/// cbindgen:ignore
#[cfg(feature = "live_events")]
pub mod live_events;
// Live rooms (extensions/live-rooms.md): `wai.live.room` — the signed room descriptor and
// room grant, revocation through the room authority's key log, track naming, takeover and
// failover over slots, publish profiles, and the room verifier that replays every
// principal's events under the grant in effect at each room-clock instant. Rust-only, like
// live_events: its constants must not become macros in include/wai.h.
/// cbindgen:ignore
#[cfg(feature = "live_room")]
pub mod live_room;
// Capture binding (extensions/capture-binding.md): `wai.capture.take` — a device's master
// recording bound to a live room by rolling take_commit events in the room's event track,
// the store's upload receipts, the take manifest, consent records with guardians for
// minors, and the take verifier's single-take, aligned, complete and publishable verdicts.
// Rust-only, like live_events and live_room: its constants must not become macros in
// include/wai.h.
/// cbindgen:ignore
#[cfg(feature = "capture_take")]
pub mod capture_take;
// Typed receipt classes (extensions/jwp-receipts.md §2.4.3–§2.4.12, §2.6, §3.1–§3.2):
// energy, lawful-basis, log-inclusion, soft-binding, binding-recovery, measurement,
// operating-point, stage, delivery-gap and reconstruct-session statements about

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

2133
wai/wai-rs/src/live_room.rs Normal file

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -72,7 +72,10 @@ pub fn leaf(p: &Presentation) -> [u8; 32] {
sha256(&b)
}
fn node(l: &[u8; 32], r: &[u8; 32]) -> [u8; 32] {
/// The tree's interior node, `SHA-256("wai:out-node\x01" ‖ left ‖ right)`. A tree of
/// another leaf domain built with this node (capture-binding's take tree) is this
/// construction over its own leaves.
pub fn node(l: &[u8; 32], r: &[u8; 32]) -> [u8; 32] {
let mut b = Vec::with_capacity(NODE_DOMAIN.len() + 64);
b.extend_from_slice(NODE_DOMAIN);
b.extend_from_slice(l);
@ -89,6 +92,11 @@ fn split(n: usize) -> usize {
k
}
/// RFC 6962's Merkle tree hash over leaf hashes `d` (`d` not empty), with [`node`].
pub fn tree_hash(d: &[[u8; 32]]) -> [u8; 32] {
mth(d)
}
fn mth(d: &[[u8; 32]]) -> [u8; 32] {
if d.len() == 1 {
return d[0];
@ -185,6 +193,76 @@ pub fn verify_proof(leaf: [u8; 32], index: u64, path: &[[u8; 32]], c: &OutputCom
sn == 0 && r == root
}
fn subproof(m: usize, d: &[[u8; 32]], b: bool, out: &mut Vec<[u8; 32]>) {
let n = d.len();
if m == n {
if !b {
out.push(mth(d));
}
return;
}
let k = split(n);
if m <= k {
subproof(m, &d[..k], b, out);
out.push(mth(&d[k..]));
} else {
subproof(m - k, &d[k..], false, out);
out.push(mth(&d[..k]));
}
}
/// The consistency proof (RFC 9162 §2.1.4.1, RFC 6962 §2.1.2's `PROOF(m, D[n])`) that the
/// tree over the first `m` leaf hashes of `d` is a prefix of the tree over all of them:
/// empty when `m` is 0 or `d.len()`.
pub fn consistency_proof(m: usize, d: &[[u8; 32]]) -> Vec<[u8; 32]> {
let mut out = Vec::new();
if 0 < m && m < d.len() {
subproof(m, d, true, &mut out);
}
out
}
/// Whether `proof` shows the tree of `first` leaves whose root is `first_root` is a prefix
/// of the tree of `second` leaves whose root is `second_root`, checked as RFC 9162
/// §2.1.4.2 checks one, with [`node`] for its `HASH(0x01 ‖ …)`. `0 < first < second`; the
/// caller decides the cases RFC 9162 leaves to it (an empty first tree, or equal sizes).
pub fn verify_consistency(first: u64, second: u64, first_root: &[u8; 32], second_root: &[u8; 32], proof: &[[u8; 32]]) -> bool {
if first == 0 || first >= second || proof.is_empty() {
return false;
}
let mut path: Vec<[u8; 32]> = Vec::with_capacity(proof.len() + 1);
if first.is_power_of_two() {
path.push(*first_root);
}
path.extend_from_slice(proof);
let (mut fnn, mut sn) = (first - 1, second - 1);
while fnn & 1 == 1 {
fnn >>= 1;
sn >>= 1;
}
let (mut fr, mut sr) = (path[0], path[0]);
for c in &path[1..] {
if sn == 0 {
return false;
}
if fnn & 1 == 1 || fnn == sn {
fr = node(c, &fr);
sr = node(c, &sr);
if fnn & 1 == 0 {
while fnn & 1 == 0 && fnn != 0 {
fnn >>= 1;
sn >>= 1;
}
}
} else {
sr = node(&sr, c);
}
fnn >>= 1;
sn >>= 1;
}
fr == *first_root && sr == *second_root && sn == 0
}
#[cfg(test)]
mod tests {
use super::*;
@ -274,6 +352,38 @@ mod tests {
assert!(!verify_proof(leaf(&v[6]), 6, &paths[6], &as8));
}
/// Every prefix of trees of 1 to 33 leaves is proved consistent with the whole, and a
/// flipped bit, a rewritten history or the next tree's root is refused. A proof does
/// not fix the second tree's size, as an audit path does not (Appendix C): the 1-to-3
/// proof also walks to the 3-leaf root under a stated size of 4, so a commitment states
/// its size and a holder of the leaves recomputes the root under it.
#[test]
fn consistency_proofs_roundtrip() {
let leaves: Vec<[u8; 32]> = (0..34u8).map(|i| sha256(&[b'c', i])).collect();
let three = tree_hash(&leaves[..3]);
let p13 = consistency_proof(1, &leaves[..3]);
assert!(verify_consistency(1, 4, &leaves[0], &three, &p13), "a proof does not fix the size");
for n in 2..=33usize {
let d = &leaves[..n];
let second = tree_hash(d);
let next = tree_hash(&leaves[..n + 1]);
for m in 1..n {
let first = tree_hash(&d[..m]);
let p = consistency_proof(m, d);
assert!(verify_consistency(m as u64, n as u64, &first, &second, &p), "m = {m}, n = {n}");
assert!(!verify_consistency(m as u64, n as u64 + 1, &first, &next, &p), "m = {m}, n = {n}, next tree");
let mut bad = p.clone();
bad[0][7] ^= 0x10;
assert!(!verify_consistency(m as u64, n as u64, &first, &second, &bad), "m = {m}, n = {n}, bit");
let mut other = d.to_vec();
other[0][0] ^= 1;
let rewritten = tree_hash(&other);
assert!(!verify_consistency(m as u64, n as u64, &first, &rewritten, &p), "m = {m}, n = {n}, history");
}
assert!(consistency_proof(0, d).is_empty() && consistency_proof(n, d).is_empty());
}
}
#[test]
fn duplicate_presentation_is_refused() {
let mut p = ps(4);

View file

@ -0,0 +1,77 @@
//! `wai.capture.take` (extensions/capture-binding.md): the committed corpus
//! `live-conformance/takes/vectors.json` regenerates byte for byte from its fixed seeds,
//! every vector in it gets the outcome it states, every reason the extension adds appears
//! in it, and the extension's §12 lists exactly those reasons.
#![cfg(feature = "capture_take")]
use std::collections::BTreeSet;
use std::path::PathBuf;
use wai::capture_take::{corpus, Reason, DELIVERY_GAP};
fn wai_dir() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("..")
}
fn committed() -> String {
std::fs::read_to_string(wai_dir().join("live-conformance/takes/vectors.json")).expect("the committed corpus")
}
#[test]
fn the_committed_corpus_regenerates_from_its_seeds() {
assert!(committed() == corpus::generate(), "live-conformance/takes/vectors.json differs from `wai_capture_take_vectors gen`");
}
#[test]
fn every_vector_gets_its_stated_outcome() {
let fails = corpus::verify(&committed());
assert!(fails.is_empty(), "{}", fails.join("\n"));
}
/// Every reason this extension adds, and `delivery_gap`, is a vector's refusal or one of
/// its verdicts; the three good takes §13 names stand, and `single-take` holds of the
/// missing chunk while `complete` fails.
#[test]
fn the_corpus_covers_every_reason_and_take() {
let doc: serde_json::Value = serde_json::from_str(&committed()).unwrap();
let vectors = doc["vectors"].as_array().unwrap();
let mut seen: BTreeSet<String> = BTreeSet::new();
for v in vectors {
if let Some(r) = v["reason"].as_str() {
seen.insert(r.into());
}
let verdicts = &v["verdicts"];
for k in ["single_take", "aligned", "complete"] {
if let Some(r) = verdicts[k].as_str() {
seen.insert(r.into());
}
}
if let Some(p) = verdicts["publishable"].as_object() {
seen.extend(p.values().filter_map(|r| r.as_str()).map(String::from));
}
}
for r in Reason::ALL.iter().copied().chain([DELIVERY_GAP]) {
assert!(seen.contains(r.code()), "nothing in the corpus gives {r}");
}
for name in ["lesson", "resumed_upload", "minor_guardian"] {
let v = vectors.iter().find(|v| v["name"] == name).unwrap_or_else(|| panic!("no vector {name}"));
assert_eq!(v["stands"], true, "{name}");
}
let missing = vectors.iter().find(|v| v["name"] == "missing_chunk").expect("the missing chunk");
assert_eq!(missing["verdicts"]["single_take"], "holds");
assert_eq!(missing["verdicts"]["complete"], "chunk_missing");
}
/// §12 of the extension lists exactly the codes `Reason::ALL` has, each once, in order.
#[test]
fn the_extension_lists_the_reasons() {
let text = std::fs::read_to_string(wai_dir().join("extensions/capture-binding.md")).expect("the extension");
let at = text.find("\n## 12. Reasons").expect("a §12");
let end = text[at + 1..].find("\n## ").map_or(text.len(), |e| at + 1 + e);
let listed: Vec<String> = text[at..end]
.lines()
.filter(|l| l.starts_with("| `"))
.filter_map(|l| l.split('`').nth(1).map(String::from))
.collect();
let want: Vec<String> = Reason::ALL.iter().map(|r| r.code().to_string()).collect();
assert_eq!(listed, want, "§12's table and Reason::ALL differ");
}

View file

@ -223,6 +223,7 @@ fn every_decoded_output_states_a_sha256() {
("pin-conformance", "parameter-set pins: every digest there is a SHA-256 of a parameter set"),
("ops-conformance", "integer operators: every operator, stage and graph vector, the lower_* decodes of the sets under sets/ among them, states its output integers in full or its refusal code, never a digest of them; certificates/ states each lowered set's SHA-256 (a parameter-set identity) and holds the lowered graphs as bytes, compared byte for byte; the output digests a capability decoded through its lowering reproduces (integer-operators §14.4) are stated by that capability's own corpus"),
("signer-conformance", "key logs, grants and statuses: signed documents, no decoded output"),
("live-conformance", "live events, rooms and takes: signed records, commitments and their verdicts, no decoded output"),
("http-conformance", "objects and head proofs for transport: no case decodes a payload, and the component a fetch case picks is stated by its SHA-256"),
("cache-conformance", "cache rules: a driver, its controls and their reports, with no objects of its own"),
("byte-exact-conformance", "goldens over tests/vectors, whose sidecars state the SHA-256"),

View file

@ -0,0 +1,78 @@
//! `wai.live.events` (extensions/live-events.md): the committed corpus
//! `live-conformance/events/vectors.json` regenerates byte for byte from its fixed seeds,
//! every case in it gets the outcome it states, it covers every kind and every reason,
//! and the extension's §9 lists exactly the reasons the reference reports.
#![cfg(feature = "live_events")]
use std::collections::BTreeSet;
use std::path::PathBuf;
use wai::live_events::{corpus, kind_name, Reason, KINDS};
fn wai_dir() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("..")
}
fn committed() -> String {
std::fs::read_to_string(wai_dir().join("live-conformance/events/vectors.json")).expect("the committed corpus")
}
#[test]
fn the_committed_corpus_regenerates_from_its_seeds() {
assert!(committed() == corpus::generate(), "live-conformance/events/vectors.json differs from `wai_live_events_vectors gen`");
}
#[test]
fn every_case_gets_its_stated_outcome() {
let fails = corpus::verify(&committed());
assert!(fails.is_empty(), "{}", fails.join("\n"));
}
/// Every kind has an accepted event, every refusal reason a vector, policy case or binding
/// case refused with it, and the gap outcome a vector.
#[test]
fn the_corpus_covers_every_kind_and_reason() {
let doc: serde_json::Value = serde_json::from_str(&committed()).unwrap();
let mut kinds = BTreeSet::new();
let mut reasons = BTreeSet::new();
for v in doc["vectors"].as_array().unwrap() {
if let Some(r) = v["reason"].as_str() {
reasons.insert(r.to_string());
}
if v["outcome"] == "accept" {
for e in v["events"].as_array().unwrap() {
let h = e.as_str().unwrap();
let at = 2 * (4 + 1 + 32 + 32 + 8 + 32 + 8);
let flag = u8::from_str_radix(&h[at..at + 2], 16).unwrap();
let k = 2 * (4 + 1 + 32 + 32 + 8 + 32 + 8 + 1 + 48 * flag as usize);
kinds.insert(u8::from_str_radix(&h[k..k + 2], 16).unwrap());
}
}
}
for c in doc["policy_cases"].as_array().unwrap().iter().chain(doc["binding_cases"].as_array().unwrap()) {
if let Some(r) = c["reason"].as_str() {
reasons.insert(r.to_string());
}
}
for (k, name) in KINDS {
assert!(kinds.contains(&k), "no accepted {name} event");
}
for r in Reason::ALL {
assert!(reasons.contains(r.code()), "nothing in the corpus is refused with {r}");
}
assert!(kinds.iter().all(|k| kind_name(*k).is_some()));
}
/// §9 of the extension lists exactly the codes `Reason` has, each once.
#[test]
fn the_extension_lists_the_reasons() {
let text = std::fs::read_to_string(wai_dir().join("extensions/live-events.md")).expect("the extension");
let at = text.find("\n## 9. Reasons").expect("a §9");
let end = text[at + 1..].find("\n## ").map_or(text.len(), |e| at + 1 + e);
let listed: Vec<String> = text[at..end]
.lines()
.filter(|l| l.starts_with("| `"))
.filter_map(|l| l.split('`').nth(1).map(String::from))
.collect();
let want: Vec<String> = Reason::ALL.iter().map(|r| r.code().to_string()).collect();
assert_eq!(listed, want, "§9's table and Reason::ALL differ");
}

View file

@ -0,0 +1,59 @@
//! `wai.live.room` (extensions/live-rooms.md): the committed corpus
//! `live-conformance/rooms/vectors.json` regenerates byte for byte from its fixed seeds,
//! every vector in it gets the outcome it states, it refuses with every reason the
//! extension adds, and the extension's §11 lists exactly those reasons.
#![cfg(feature = "live_room")]
use std::collections::BTreeSet;
use std::path::PathBuf;
use wai::live_room::{corpus, Reason};
fn wai_dir() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("..")
}
fn committed() -> String {
std::fs::read_to_string(wai_dir().join("live-conformance/rooms/vectors.json")).expect("the committed corpus")
}
#[test]
fn the_committed_corpus_regenerates_from_its_seeds() {
assert!(committed() == corpus::generate(), "live-conformance/rooms/vectors.json differs from `wai_live_room_vectors gen`");
}
#[test]
fn every_vector_gets_its_stated_outcome() {
let fails = corpus::verify(&committed());
assert!(fails.is_empty(), "{}", fails.join("\n"));
}
/// Every reason this extension adds has a vector refused with it, and the corpus has the
/// five room shapes §12 names, accepted.
#[test]
fn the_corpus_covers_every_reason_and_room() {
let doc: serde_json::Value = serde_json::from_str(&committed()).unwrap();
let vectors = doc["vectors"].as_array().unwrap();
let reasons: BTreeSet<&str> = vectors.iter().filter_map(|v| v["reason"].as_str()).collect();
for r in Reason::ALL {
assert!(reasons.contains(r.code()), "nothing in the corpus is refused with {r}");
}
for name in ["lesson", "broadcast", "takeover", "redundant_failover", "promote_viewer"] {
let v = vectors.iter().find(|v| v["name"] == name).unwrap_or_else(|| panic!("no vector {name}"));
assert_eq!(v["outcome"], "accept", "{name}");
}
}
/// §11 of the extension lists exactly the codes `Reason::ALL` has, each once, in order.
#[test]
fn the_extension_lists_the_reasons() {
let text = std::fs::read_to_string(wai_dir().join("extensions/live-rooms.md")).expect("the extension");
let at = text.find("\n## 11. Reasons").expect("a §11");
let end = text[at + 1..].find("\n## ").map_or(text.len(), |e| at + 1 + e);
let listed: Vec<String> = text[at..end]
.lines()
.filter(|l| l.starts_with("| `"))
.filter_map(|l| l.split('`').nth(1).map(String::from))
.collect();
let want: Vec<String> = Reason::ALL.iter().map(|r| r.code().to_string()).collect();
assert_eq!(listed, want, "§11's table and Reason::ALL differ");
}