wai: wai.capture.take — a device's master recording bound to a live room (L4) #289
37 changed files with 24256 additions and 10 deletions
72
.github/workflows/wai-conformance.yml
vendored
72
.github/workflows/wai-conformance.yml
vendored
|
|
@ -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,
|
||||
|
|
|
|||
106
wai/CHANGELOG.md
106
wai/CHANGELOG.md
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
|
|
|||
41
wai/SPEC.md
41
wai/SPEC.md
|
|
@ -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
|
||||
|
|
|
|||
727
wai/extensions/capture-binding.md
Normal file
727
wai/extensions/capture-binding.md
Normal 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.
|
||||
|
|
@ -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
|
||||
|
|
|
|||
675
wai/extensions/live-events.md
Normal file
675
wai/extensions/live-events.md
Normal 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 |
|
||||
799
wai/extensions/live-rooms.md
Normal file
799
wai/extensions/live-rooms.md
Normal 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.
|
||||
24
wai/live-conformance/README.md
Normal file
24
wai/live-conformance/README.md
Normal 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
|
||||
```
|
||||
683
wai/live-conformance/capture_take_verify.py
Normal file
683
wai/live-conformance/capture_take_verify.py
Normal 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]))
|
||||
98
wai/live-conformance/events/README.md
Normal file
98
wai/live-conformance/events/README.md
Normal 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.
|
||||
1792
wai/live-conformance/events/vectors.json
Normal file
1792
wai/live-conformance/events/vectors.json
Normal file
File diff suppressed because it is too large
Load diff
754
wai/live-conformance/live_events_verify.py
Normal file
754
wai/live-conformance/live_events_verify.py
Normal 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]))
|
||||
986
wai/live-conformance/live_rooms_verify.py
Normal file
986
wai/live-conformance/live_rooms_verify.py
Normal 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]))
|
||||
94
wai/live-conformance/rooms/README.md
Normal file
94
wai/live-conformance/rooms/README.md
Normal 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.
|
||||
3375
wai/live-conformance/rooms/vectors.json
Normal file
3375
wai/live-conformance/rooms/vectors.json
Normal file
File diff suppressed because it is too large
Load diff
91
wai/live-conformance/takes/README.md
Normal file
91
wai/live-conformance/takes/README.md
Normal 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.
|
||||
4442
wai/live-conformance/takes/vectors.json
Normal file
4442
wai/live-conformance/takes/vectors.json
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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 ;
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
53
wai/wai-rs/src/bin/wai_capture_take_vectors.rs
Normal file
53
wai/wai-rs/src/bin/wai_capture_take_vectors.rs
Normal 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
|
||||
}
|
||||
54
wai/wai-rs/src/bin/wai_live_events_vectors.rs
Normal file
54
wai/wai-rs/src/bin/wai_live_events_vectors.rs
Normal 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
|
||||
}
|
||||
53
wai/wai-rs/src/bin/wai_live_room_vectors.rs
Normal file
53
wai/wai-rs/src/bin/wai_live_room_vectors.rs
Normal 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
|
||||
}
|
||||
1442
wai/wai-rs/src/capture_take.rs
Normal file
1442
wai/wai-rs/src/capture_take.rs
Normal file
File diff suppressed because it is too large
Load diff
964
wai/wai-rs/src/capture_take/corpus.rs
Normal file
964
wai/wai-rs/src/capture_take/corpus.rs
Normal 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
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
1987
wai/wai-rs/src/live_events.rs
Normal file
1987
wai/wai-rs/src/live_events.rs
Normal file
File diff suppressed because it is too large
Load diff
1029
wai/wai-rs/src/live_events/corpus.rs
Normal file
1029
wai/wai-rs/src/live_events/corpus.rs
Normal file
File diff suppressed because it is too large
Load diff
2133
wai/wai-rs/src/live_room.rs
Normal file
2133
wai/wai-rs/src/live_room.rs
Normal file
File diff suppressed because it is too large
Load diff
1181
wai/wai-rs/src/live_room/corpus.rs
Normal file
1181
wai/wai-rs/src/live_room/corpus.rs
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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);
|
||||
|
|
|
|||
77
wai/wai-rs/tests/capture_take.rs
Normal file
77
wai/wai-rs/tests/capture_take.rs
Normal 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");
|
||||
}
|
||||
|
|
@ -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"),
|
||||
|
|
|
|||
78
wai/wai-rs/tests/live_events.rs
Normal file
78
wai/wai-rs/tests/live_events.rs
Normal 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");
|
||||
}
|
||||
59
wai/wai-rs/tests/live_room.rs
Normal file
59
wai/wai-rs/tests/live_room.rs
Normal 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");
|
||||
}
|
||||
Loading…
Reference in a new issue