From 1a4bd20d2d663aec5ac47e86be56096ebba767b9 Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Tue, 20 Oct 2026 08:35:51 -0400 Subject: [PATCH 1/6] wai.live.watermark: forensic watermark session records, recovered sequences and disclosures (L14) The extension text (extensions/forensic-watermark.md), the wai-rs module behind the live_watermark feature, its corpus generator and test, and the registrations. Co-Authored-By: Claude Opus 5.5 --- wai/REGISTRY.md | 8 +- wai/SPEC.md | 27 +- wai/extensions/forensic-watermark.md | 738 +++++++ wai/extensions/jwp-receipts.md | 5 + wai/live-conformance/watermark/vectors.json | 1884 +++++++++++++++++ wai/wai-rs/Cargo.toml | 18 + .../src/bin/wai_live_watermark_vectors.rs | 74 + wai/wai-rs/src/codecs/registry.rs | 12 +- wai/wai-rs/src/lib.rs | 8 + wai/wai-rs/src/live_watermark.rs | 1564 ++++++++++++++ wai/wai-rs/src/live_watermark/corpus.rs | 1090 ++++++++++ wai/wai-rs/tests/live_watermark.rs | 53 + 12 files changed, 5474 insertions(+), 7 deletions(-) create mode 100644 wai/extensions/forensic-watermark.md create mode 100644 wai/live-conformance/watermark/vectors.json create mode 100644 wai/wai-rs/src/bin/wai_live_watermark_vectors.rs create mode 100644 wai/wai-rs/src/live_watermark.rs create mode 100644 wai/wai-rs/src/live_watermark/corpus.rs create mode 100644 wai/wai-rs/tests/live_watermark.rs diff --git a/wai/REGISTRY.md b/wai/REGISTRY.md index 8991995ee..8ea53010a 100644 --- a/wai/REGISTRY.md +++ b/wai/REGISTRY.md @@ -41,8 +41,8 @@ Every registered string has one kind. `wai.asset.provenance`, the quantum-operations receipts, the delivery records `wai.video.manifest` and its siblings; the live records `wai.live.events`, `wai.live.room`, `wai.capture.take`, `wai.live.compose`, `wai.live.recording`, - `wai.live.metering`, `wai.live.steering`, `wai.live.moderation` and `wai.live.e2ee`; - timed text, `wai.text.timed`). + `wai.live.metering`, `wai.live.steering`, `wai.live.moderation`, `wai.live.e2ee` and + `wai.live.watermark`; timed text, `wai.text.timed`). It names no payload format. A derivation or record string is not a payload capability, so it is not @@ -204,6 +204,7 @@ and a role are fixed once registered. | `wai.live.recording` | record | registered | — | — | — | — | — | §5 Interactive, temporal & computed content | | `wai.live.room` | record | registered | — | — | — | — | — | §5 Interactive, temporal & computed content | | `wai.live.steering` | record | registered | — | — | — | — | — | §5 Interactive, temporal & computed content | +| `wai.live.watermark` | 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 | @@ -330,6 +331,9 @@ and a role are fixed once registered. | `WTM1` | magic | defined | capture-binding §7.1 | | `WTX1` | magic | defined | timed-text §4.2 | | `WUR1` | magic | defined | capture-binding §6.2 | +| `WWD1` | magic | defined | forensic-watermark §8.1 | +| `WWQ1` | magic | defined | forensic-watermark §7.1 | +| `WWS1` | magic | defined | forensic-watermark §5.1 | | `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 | diff --git a/wai/SPEC.md b/wai/SPEC.md index 162bd070b..eac499c94 100644 --- a/wai/SPEC.md +++ b/wai/SPEC.md @@ -593,7 +593,8 @@ kind: delivery records `wai.video.manifest`, `wai.video.ssai`, `wai.video.keys`, `wai.video.package` and `wai.video.channel`; and the live records `wai.live.events`, `wai.live.room`, `wai.capture.take`, `wai.live.compose`, `wai.live.recording`, - `wai.live.metering`, `wai.live.steering`, `wai.live.moderation` and `wai.live.e2ee`, and + `wai.live.metering`, `wai.live.steering`, `wai.live.moderation`, `wai.live.e2ee` and + `wai.live.watermark`, and timed text, `wai.text.timed`), which names no payload format. Only payload, companion and refinement capabilities have a media class, a @@ -1018,6 +1019,25 @@ track's owner or was removed since, a replay, a bad tag and a plaintext that doe or does not match its sealed inner digest. Receipts bind the sealed bytes: [`extensions/confidentiality.md`](extensions/confidentiality.md). +And for **forensic watermark sessions** of a room's Lane A delivery (§6.5) — +`wai.live.watermark`: the edge that chooses, segment by segment, which of the ETSI TS 104 002 +A/B variants a viewer's session receives signs a session record, `WWS1`, when the session +ends: the room and the content, the span, the KIDs of the content keys the variants were +encrypted under, the TS's watermark token by its digest (the token, a CWT that may carry a +viewer's identifiers and can be presented again until it expires, travels beside the record +only to those who check it), the positions of the pattern it sequenced with the variant at +each, and a salted commitment to the session's grant and principal. The record names no +grant, principal or viewer; only the room authority, to which the edge gives each opening, +can link a session to one, and the edge keeps records within the room's region pin and its +retention bound. A claimant that recovers a variant sequence from a copy signs it, `WWQ1`, +with its erasures; a session agrees with it when it sequenced every stated position with the +stated variant, and a match is the one session that agrees, the TS leaving detection's +statistics and thresholds to its vendors. For a matched session the authority signs a +disclosure, `WWD1`, opening that one commitment, which a third party checks against that +record and the grant without learning other sessions. A room that is end-to-end encrypted has +no edge that can mark it, and a record for one is refused: +[`extensions/forensic-watermark.md`](extensions/forensic-watermark.md). + ### Payloads of the WAI-native integer codecs This revision pins a payload format to each WAI-native integer codec whose row named @@ -1545,8 +1565,9 @@ and decode happen inside that path. key by commitment and never carries it (`wai.video.keys`), the ad decision and the per-impression receipts that bind each served `creative_hash` and the quartile it reached (`wai.video.ssai`), a channel's program-as-run windows (`wai.video.channel`), - and the content hash of each WAI object delivered (jwp-receipts §2.1). None of them - covers a decoded frame or sample. + the records of the edges that chose a session's forensic A/B variants + (`wai.live.watermark`), and the content hash of each WAI object delivered + (jwp-receipts §2.1). None of them covers a decoded frame or sample. - A capability that synthesises output from parameters (grain, an enhancement layer, a generative stage) MAY apply to Lane A content only inside the protected path, in a decoder that the protection system certifies. WAI does not certify that decoder and diff --git a/wai/extensions/forensic-watermark.md b/wai/extensions/forensic-watermark.md new file mode 100644 index 000000000..34cb8d1d8 --- /dev/null +++ b/wai/extensions/forensic-watermark.md @@ -0,0 +1,738 @@ +# WAI Extension: Forensic Watermark Sessions + +> Status: Draft. Registers the record `wai.live.watermark`: a signed record of each session in +> which an edge chose forensic A/B variants for a viewer of a room, as ETSI TS 104 002 has an +> edge choose them. The edge signs a **session record**, `WWS1`, when its part in the session +> ends: the room and the content, the span, the content keys the variants were encrypted +> under, the watermark token by its digest, the positions of the pattern it embedded with the +> variant at each, and a salted commitment to the session's grant and principal that only the +> room authority can open. Anyone who recovers a variant sequence from a leaked copy states it +> in a signed **recovered sequence**, `WWQ1`; a deterministic rule says which session records +> it agrees with. For one matched session, the room authority signs a **disclosure**, `WWD1`, +> that opens the commitment: a third party checks it against that session record and the grant +> it names, and learns nothing of other sessions. The record covers Lane A delivery alone +> (SPEC §6.5): a room that is end-to-end encrypted has no edge that can mark it, and a record +> for one is refused. No new transport and no new token format: the token is TS 104 002's. +> The reference implementation is the `live_watermark` module of `wai-rs` (feature +> `live_watermark`); the corpus is [`live-conformance/watermark/`](../live-conformance/watermark/), +> and its second implementation is `live-conformance/live_watermark_verify.py`. +> Keywords **MUST**, **MUST NOT**, **SHOULD**, **MAY** are RFC 2119/8174. +> +> Texts read for this revision: ETSI TS 104 002 V1.1.1 (2023-08), "the TS", downloaded from the +> ETSI deliverables server, whose terms allow reading it and refer reproduction to ETSI: this +> text cites its clauses and reproduces none of it; RFC 8392 (CWT), which the TS requires the +> token to be; RFC 8949 §4.2 (deterministically encoded CBOR) and RFC 8610 (CDDL); RFC 9052 +> (COSE structures, which obsoletes the RFC 8152 that RFC 8392 cites, with the same structures) +> and RFC 9053 (COSE algorithms), which the TS cites; and RFC 4648 §5. A clause or section +> number of one of those texts is that revision's. + +## 1. Scope + +In scope: where the record applies, and why only Lane A (§3); how a WAI verifier reads the +TS's watermark token and what it takes from it (§4); the session record, its positions and +bits, the commitment to the session's grant, its signature, and who may sign it (§5); what +the edge keeps, where, for how long, and who reads what (§6); the recovered sequence and the +rule that matches it against session records (§7); the disclosure, when the authority signs +one, and how a third party checks it (§8); and verification (§9). + +Out of scope: a transport; the TS's content preparation, its WMPaceInfo carriage and its +origin endpoints, which this extension reads and does not change; how a vendor's detector +recovers a pattern from a copy, which the TS leaves out of its own scope (§5.8 there) and +which this extension does not define either (§7.5); the revocation of a token, also out of the +TS's scope; client-driven and client-side watermarking, which the TS leaves out (§4 there); +and how a recovered sequence or a disclosure reaches anyone. + +Relationship to the core spec: `wai.live.watermark` 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. It is one of the records SPEC §6.5 names for a Lane A title. + +Relationship to [live-rooms](live-rooms.md): a session record is bound to the grant its +session ran under (§4 there), through a commitment (§5.3), and an edge is admitted to sign one +through the authority's key log (§5 there; §5.6 here). The room's region pin and retention +bound (§3.7 there) bound what the edge keeps (§6). + +Relationship to [confidentiality](confidentiality.md): a room with a confidentiality record is +Lane B and has no session record (§3.2). + +Relationship to [live-metering](live-metering.md): an edge that signs session records is a hop +of the room's sessions and signs its per-hop record there as well; neither record names the +other. A per-hop record names grants in its ledger, which the deployment gives only to the +parties that verify the receipt (live-metering §11); a session record names none. + +## 2. Terms + +- **Room**, **room id**, **room authority**, **clock**, **room key**, **principal**, **grant**: + as in live-rooms §2. +- **Viewer**: a principal that subscribes to a room's tracks under a grant. +- **Edge**: the server that chooses, for each segment of a session, which variant it serves, + and signs the session record: the TS's edge (§5.1 there), here the hop that serves the + viewer's session (§3.3). +- **Segment**, **variant**, **WM pattern**, **sequencing**: as in the TS's §3.1. A variant is + numbered as the TS's `variantId` numbers it, from 0: variant 0 is A, variant 1 is B. +- **Token**: the TS's WM token (§5.4 there), as the bytes base64url (RFC 4648 §5) decodes it + to: one CBOR data item. +- **Pattern bit** *p*: bit *p* of the WM pattern, *p* from 0, addressed big-endian as the TS's + §5.4 requires: bit 7 − (*p* mod 8), the most significant first, of byte ⌊*p* / 8⌋. +- **Session**: one subscriber session of the edge (a MOQT session, or the HTTP requests of one + playback) under one grant and one token (§3.3). +- **Claimant**: whoever states a recovered sequence and signs it. +- *u64*, *u32*, *u16*, *u8*: little-endian unsigned integers of that many bits. A *short* + field is a u8 length and that many bytes. A digest is SHA-256 (FIPS 180-4), 32 bytes. + Ed25519 is RFC 8032's, verified strictly (SPEC §7 *Signatures*): a key that fails step 1 or + 2 is `bad_key`, and a signature that fails a later step is `bad_signature`. +- A **bitmap** of a pattern of `patlen` bits is ⌈`patlen` / 8⌉ bytes, bit *p* addressed as a + pattern bit is, and its bits from `patlen` on, in its last byte, are 0. + +## 3. Lane A only + +### 3.1 Where the record applies + +The record applies to a session whose media the edge delivered as Lane A content (SPEC §6.5): +segments encrypted under content keys that a platform's protected media path decrypts, with +the decoded frames out of the application's reach. That is the delivery the TS is written for: +its functional architecture has the content encrypted and protected by a DRM system, with the +A and B variants of a representation encrypted under one content key, so that one licence opens +whichever variant the edge serves (§5.2, §5.6.1 there). + +A session record names the key identifier (KID) of every content key its variants were +encrypted under, the KID a release decision of `wai.video.keys` names (SPEC §5), so the +session is tied to the release decisions of those keys without the record naming the licence +or the viewer. A record that names none is refused `not_protected`. A deployment that marks +content delivered in the clear is refused by nothing in WAI, and this record does not cover +it: a plaintext object of a room is already named by its content hash and its receipt, and an +edge's choice between two plaintext variants is not a Lane A delivery. + +### 3.2 Rooms that are end-to-end encrypted + +A room with a confidentiality record ([confidentiality](confidentiality.md) §3) is Lane B, and +has no edge that can mark it: + +- Each object of its media tracks is sealed by its publisher, and the AEAD authenticates the + object's Full Track Name, Group ID and Object ID (confidentiality §4.1, §4.7). An edge that + served an object of a variant track as an object of the viewer's track would have it refused + by the receiver, `bad_tag`; and MOQT holds one object at one location (confidentiality §6 + step 8), so one track cannot carry one object to one subscriber and another to the next. +- The only parties that could vary what one member receives are its publisher, which holds no + per-viewer choice in this model, and the member's own device, which is client-side + watermarking, outside the TS (§4 there). +- Lane B does not keep the decoded output from the member's application (SPEC §6.5), so the + premise of the TS's server-driven model, that the device only conveys the token and cannot + see or choose its variants, does not hold for it. + +A verifier given a room's confidentiality record refuses every session record of the room +`e2ee_room` (§9.1 step 2). An edge **MUST NOT** sign a session record for such a room. + +### 3.3 The edge + +The edge is the server of the TS's server-driven architecture (§5.1, §5.2 there): it validates +the viewer's token, which accompanies every request for a watermarked segment (§5.7.5.1 +there), reads each segment's WMPaceInfo (§5.5, §5.7.5.2 there), and serves the variant the +pattern selects. In a room, it is the hop that serves a subscriber session one of the room's +tracks: + +- **Over HTTP**, a rendition of the room's tracks (live-rooms §10.3) served as the TS's + §5.7 serves a manifest's segments, the token carried as its §5.7.3 carries it. +- **Over MOQT**, a relay serving a track of the room (live-rooms §6.1): a segment is one group + of the track; the variants of the track are two upstream tracks whose names are the + deployment's (this revision names none); the subscriber receives the track under the name + live-rooms §6.1 gives it, as the TS keeps the manifests a device receives neutral (§5.6.4 + there). How a MOQT subscriber presents the token is not defined in this revision; an edge + that receives it by the deployment's means signs the same record. + +The edge admits the session under the viewer's grant, with proof of possession of its key +(live-rooms §10.5), so it knows the grant and the principal. A session admitted without a grant +(live-rooms §10.4) names no principal, and has no session record. + +A session record covers one session under one token. An edge that is given a new token during +a session ends the record of the old one and starts another; the records of one session have +spans that do not overlap. + +## 4. The watermark token + +### 4.1 Its bytes and its digest + +The token travels as the TS's §5.4 has it: a CWT (RFC 8392) whose claims set has integer keys +and is deterministically encoded (RFC 8949 §4.2), signed as RFC 8392 §7 has it, and +base64url-encoded. Its **bytes** are what the base64url text decodes to, and its **digest** is +SHA-256 over those bytes. A session record names its token by the digest alone (§5.1). + +The record does not carry the token, for two reasons. The token may carry what identifies a +viewer: RFC 8392's `sub`, and in the TS's indirect mode a `wmid` that its vendor derives the +pattern from, which the TS leaves to the vendor and which may be an account's identifier (§5.4 +there); a record that carried it would put that in the hands of everyone who matches recovered +sequences. And until its `exp`, a token can be presented again to obtain segments marked with +its pattern. The token travels beside the record to the parties that check it (§5.7, §6.3). + +### 4.2 Reading a token + +A reader refuses a token at the first of these steps that fails, with the code it names: +`token_malformed` where the token is not what the TS and the RFCs it cites require, and +`token_unsupported` where it is a form they allow that this revision does not read. + +1. **CBOR.** The bytes are one data item (RFC 8949 §3), with no byte after it, read by these + rules, item by item from the start, each refused at the first rule it breaks: + - an item nested more than 16 deep (the token itself at depth 1, an item inside an array, + a map or a tag one deeper than it) is `token_unsupported`, checked before the item is + read; + - a head whose additional information is 28, 29, 30 or 31 (a reserved value, or an + indefinite length or a break, which deterministic encoding forbids) is + `token_malformed`; so is an argument not written in its shortest form (RFC 8949 + §4.2.1); + - in major type 7, a simple value written in one extra byte below 32 is `token_malformed` + (RFC 8949 §3.3); a floating-point value, or any simple value other than false, true and + null, is `token_unsupported`; + - a byte or text string longer than the bytes left, an array or a map that runs past them, + and a text string that is not UTF-8 (RFC 3629) are `token_malformed`; + - a map whose keys, each read when it is reached, are not in strictly ascending bytewise + order of their encodings (RFC 8949 §4.2.1, which also rules out a key repeated) is + `token_malformed`. +2. **The CWT tag.** When the item is tag 61 (RFC 8392 §6), the item it holds is read on, and + is a COSE tag (16, 17, 18, 96, 97 or 98, RFC 9052 §2): otherwise `token_malformed` (RFC 8392 + §7.2 step 2). +3. **The COSE tag.** The item is tag 17, COSE_Mac0, or tag 18, COSE_Sign1: the two structures + of the TS's algorithms (§4.3). Another COSE message, or an untagged one, whose type RFC 8392 + §7.2 step 3 leaves to an application context the TS does not fix, is `token_unsupported`. +4. **The structure.** The tag holds an array of four: a byte string (the protected header), a + map (the unprotected header), a byte string or null (the payload), and a byte string (the + tag, or the signature). Otherwise `token_malformed`. A null payload, detached, is + `token_unsupported`. +5. **The headers.** The protected header is empty, or holds one data item, read as step 1 + reads, that is a map (`token_malformed`). Every label of the two maps is 1 (`alg`) or 4 + (`kid`): another is `token_unsupported` (RFC 8392 §7.2 step 4). A label in both maps is + `token_malformed` (RFC 9052 §3). `alg` is in the protected map (`token_malformed`), and is 5 + or −7 (`token_unsupported`); 5 needs tag 17 and a tag of 32 bytes, −7 tag 18 and a signature + of 64 bytes (`token_malformed`). A `kid` is a byte string (`token_malformed`). +6. **The payload** holds one data item, read as step 1 reads. A tag 61 or a COSE tag there is + a nested CWT (RFC 8392 §7.2 step 6), `token_unsupported`. Otherwise it is a map + (`token_malformed`) whose every key is an integer (`token_malformed`, the TS's §5.4): the + claims set. +7. **The claims**, all `token_malformed` but where another code is named: + 1. `exp` (4) and `iat` (6) are present, as the TS requires, and are unsigned integers; + `nbf` (5), when present, is an unsigned integer; + 2. `iss` (1), `sub` (2) and `aud` (3), when present, are text strings, and `cti` (7) a + byte string; + 3. `wmver` (300) is present and an unsigned integer, and is 1 (`token_unsupported`: the TS + describes version 1); + 4. `wmvnd` (301) is present and an unsigned integer of at most 255, and `wmpatlen` (302) + one of at most 65 535; + 5. `wmsegduration` (303), when present, is an array of two unsigned integers; `wmid` (305), + when present, a text string; `wmopid` (306) and `wmkeyver` (307), when present, unsigned + integers; + 6. with `wmpattern` (304) present, the token is in **direct** mode, and the claim is a byte + string of exactly ⌈`wmpatlen` / 8⌉ bytes, the pattern **in the clear**; or an array of + three items, a byte string, a map, and a byte string or null (COSE_Encrypt0, RFC 9052 + §5.2), or of four whose fourth is an array of at least one item (COSE_Encrypt, §5.1), + the pattern **encrypted**. Without it, the token is in **indirect** mode, its pattern + **derived** by the vendor's core (Annex A there), and `wmid`, `wmopid` and `wmkeyver` + are present. + + Other claims are not read: the TS's claims set admits any integer-keyed extension. + +### 4.3 What a token gives + +A token that reads gives: its `alg`; its **vendor**, `wmvnd`; its **pattern length**, +`wmpatlen`; its **source**, 1 for a pattern in the clear, 2 for an encrypted one, 3 for a +derived one; the pattern itself where it is in the clear; and its **window**: it is good at the +instant *u*, in microseconds since 1970-01-01T00:00:00Z, when +max(`iat`, `nbf`) × 10^6 ≤ *u* < `exp` × 10^6, `nbf` counting as 0 when absent. + +### 4.4 What a WAI verifier does not check + +A WAI verifier does not check the token's MAC or signature. HMAC 256/256 is keyed by a secret +the token's issuer shares with the edge, and ES256 by the issuer's key, neither of which a +WAI verifier holds; the edge checks it (the TS's §5.7.5.1), and an edge **MUST NOT** sign a +session record for a session whose token did not validate. The edge's signature on the record +is the edge's statement that it did. Nor does a WAI verifier open an encrypted pattern or derive +an indirect one: the record states the pattern the edge sequenced by, and §5.7 checks it against +the token where the token holds it in the clear. + +## 5. The session record + +### 5.1 Layout + +| field | size | meaning | +|---|---|---| +| `magic` | 4 | `WWS1` (ASCII) | +| `version` | 1 | 1 in this revision | +| `room` | 32 | the room id | +| `edge` | 32 | the edge's Ed25519 key, which signs the record | +| `owner` | short | the content: the owner field of its Track Namespace (live-rooms §6.1), 64 lowercase hexadecimal digits of its publisher's key or `_` and a slot's name | +| `media` | 1 | the content's media kind: 1 audio, 2 video, 3 screen | +| `kids` | 1 + 16 × *k* | a u8 count (0 to 8), then the KID of each content key the variants were encrypted under (§3.1), strictly ascending | +| `start`, `end` | 8 each | u64: the session's span on the room clock, in microseconds: from `start`, up to and not including `end` | +| `token` | 32 | the digest of the session's token (§4.1) | +| `vendor` | 1 | the token's `wmvnd` | +| `source` | 1 | how the edge has the pattern: 1 in the clear, 2 decrypted, 3 derived (§4.3) | +| `patlen` | 2 | u16: the token's `wmpatlen` | +| `served` | ⌈`patlen` / 8⌉ | a bitmap: the positions at which the edge served the session a sequenced segment (§5.2) | +| `bits` | ⌈`patlen` / 8⌉ | a bitmap: at each position of `served`, the variant it served there (0 or 1); 0 elsewhere | +| `segments` | 4 | u32: the sequenced segments it served the session | +| `unmarked` | 4 | u32: the segments it served the session that were not sequenced (§5.2) | +| `binding` | 32 | the commitment to the session's grant and principal (§5.3) | +| `region` | 2 | where the edge keeps the record: an ISO 3166-1 alpha-2 code, two capitals (§6.1) | +| `expires` | 8 | u64: seconds since 1970-01-01T00:00:00Z, the instant from which the record, its token and its opening are kept no more (§6.1) | +| `sig` | 64 | §5.4 | + +### 5.2 Positions and bits + +Each segment the edge serves carries a WMPaceInfo `position` (the TS's §5.5.2). The edge +**sequences** a segment when sequencing is enabled at it (§5.3 there) and the position is at +least 0 and below `patlen`: it serves the variant the pattern bit at that position selects, +variant 0 for 0 and variant 1 for 1 (§10.3), sets the position in `served` and the variant in +`bits`, and counts the segment in `segments`. + +Every other segment it serves is **unmarked**, served from variant A's endpoint as the TS's +§5.3 and §5.7.5.1 have it, counted in `unmarked`, and in neither bitmap: a segment of position +−1 (not watermarked, or a contingency of §5.7.5.1 there, which that clause keeps out of +detection); one served while sequencing was disabled at the edge, when every viewer receives +variant A; and one whose position is at or beyond `patlen`, which has no bit in the pattern, +since the TS addresses bits within the pattern and defines no wrap. An edge that serves an ad +from another edge that does not sequence (§5.7.2 there) serves nothing for it. + +One position may be sequenced many times in a session, as positions repeat: each time the same +bit selects the same variant. So `bits` is the pattern at the positions of `served`, and 0 +elsewhere: what the edge embedded in the session, and nothing it did not. + +### 5.3 The binding + +The **binding** is SHA-256 over the 27 bytes `wai:live-watermark-binding` (ASCII) and 0x01, +followed by `salt` (32), `grant` (32) and `principal` (32): + +- `salt`: 32 bytes the edge draws for the session from a cryptographically secure generator; +- `grant`: the digest of the grant the session ran under (live-rooms §4.2); +- `principal`: that grant's `principal`. + +The **opening** of a record is its digest (§5.4), `salt`, `grant` and `principal`. The edge +gives the opening to the room authority when it signs the record (§6.2). Without the salt, the +binding reveals nothing of the grant or the principal: a verifier holding every grant of the +room cannot test them against it. + +Why a commitment, and not the grant's digest in the clear: every participant of a room holds +its grants (live-rooms §10.5), and a grant names its principal (live-rooms §13), so a grant's +digest names a viewer to anyone who holds the room's grants. The record goes to the parties +that match recovered sequences (§6.3), and names no viewer to them. + +### 5.4 Digest and signature + +The **digest** of a record is SHA-256 over its bytes before `sig`. The **signature** is +Ed25519 by `edge` over the 27 bytes `wai:live-watermark-session` (ASCII) and 0x01, followed by +the bytes the digest covers. + +### 5.5 Reading and rules + +A reader reads exactly the bytes it is given as one record, in the order of §5.1, and refuses +at the first of these that holds: + +1. Fewer than 4 bytes: `truncated`. A magic other than `WWS1`: `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 refuses as `record_invalid` a record that breaks any of these rules: + +- `owner` is 64 lowercase hexadecimal digits, or `_` followed by a slot name (live-rooms §2); + `media` is 1, 2 or 3; +- `kids` has at most 8 entries, strictly ascending, none of them 16 zero bytes; +- `start` is below `end`; `source` is 1, 2 or 3; `patlen` is at least 1; +- `served` and `bits` are bitmaps (their bits from `patlen` on are 0), and `bits` sets no bit + `served` does not; +- `segments` is at least the number of positions `served` sets; +- `region` is two capitals `A`–`Z`. + +### 5.6 Who may sign + +Where the room names an origin, the edge's key has an entry in the head given for the room +(live-rooms §5) whose `may_sign` holds the authority `live/watermark` +([jwp-receipts](jwp-receipts.md) §7.5): otherwise `edge_not_authorised`. An entry whose +`status` is `revoked` is `edge_revoked`. Where the room names no origin, the edge is a room key +(live-rooms §2): otherwise `edge_not_authorised`. + +`live/watermark` is an authority of its own, and not `live/relay`: a relay's key signs what it +carried, and a session record is a forensic statement about one viewer's session that an +origin gives to the edges it trusts with it. + +### 5.7 The token beside the record + +A verifier given the token with a record checks, in this order: + +1. its digest is the record's `token`: otherwise `token_mismatch`; +2. it reads (§4.2): `token_malformed`, `token_unsupported`; +3. its vendor, pattern length and source are the record's `vendor`, `patlen` and `source`; + and, for a pattern in the clear, the record's `bits` are the pattern's bits at the positions + of `served`: otherwise `token_mismatch`; +4. the session's span is within the token's window (§4.3): the token is good at `t0` + + `start` and at `t0` + `end` − 1, `t0` the descriptor's (live-rooms §3.1). Otherwise + `outside_token_window`. + +A record given without its token is verified all the same, and the verifier states that its +token was not given; the vendor, pattern length and bits are then the edge's statement alone. + +## 6. Retention and privacy + +### 6.1 Where, and for how long + +An edge keeps each session record, its token and, until it is delivered (§6.2), its opening, +and keeps them within the room's region pin (live-rooms §3.7). The pin covers what a relay +keeps of a room beyond delivery, and a record of a session of the room is such; this extension +holds it to the pin as capture-binding holds a take's master and live-recording a package. +`region` states where the record is kept, and a verifier refuses a region outside a pin that is +not empty: `region_not_pinned`. + +`expires` is the instant from which none of them is kept, by the edge or by the room authority. +It is at or after the session's end, `expires` × 10^6 ≥ `t0` + `end` (otherwise +`record_invalid`), and, when the descriptor's `retention_days` is not 0, at most that many days +after it: `expires` × 10^6 ≤ `t0` + `end` + `retention_days` × 86 400 × 10^6, otherwise +`retention_exceeded`. The room id commits to the descriptor, so every viewer that joined the +room joined under the bound on what is kept of its sessions. + +### 6.2 The opening + +The edge gives each record's opening (§5.3) to the room authority when it signs the record, by +the deployment's means, and **MUST NOT** keep it once the authority holds it. The authority +keeps the openings within the pin until each record's `expires`, and deletes them then. Nothing +but an opening links a record to a viewer: the edge admitted the session under its grant, and +keeps no other record of which grant a record's session ran under once the opening is +delivered. + +### 6.3 Who reads what + +- **A session record** goes to the parties the deployment names to match recovered sequences + (a rights holder, and its agents), and to a third party checking a disclosure. It names no + grant, principal or viewer: its span, its content, its token's digest and its pattern bits + identify a session, and only an opening links a session to a principal. +- **A token** goes to the room authority and to a third party checking a disclosure (§8.4). A + matching party needs only the records. +- **An opening** stays with the room authority, and is disclosed one session at a time (§8). + +A pattern the token's issuer draws once per session links nothing across sessions. A pattern +drawn per device or per account (the TS allows either, §4 there) is the same in every session +of that device or account, so the records of its sessions share their bits; a deployment that +needs a viewer's sessions unlinkable to a matching party draws a pattern per session. + +## 7. Matching a recovered sequence + +### 7.1 The recovered sequence + +A claimant that recovers a variant sequence from a copy states it in a recovered sequence, +`WWQ1`: + +| field | size | meaning | +|---|---|---| +| `magic` | 4 | `WWQ1` (ASCII) | +| `version` | 1 | 1 in this revision | +| `room` | 32 | the room id | +| `owner` | short | the content, as in §5.1 | +| `media` | 1 | the content's media kind | +| `vendor` | 1 | the `wmvnd` of the vendor whose detector recovered it | +| `patlen` | 2 | u16: the pattern length it was recovered at | +| `start`, `end` | 8 each | u64: the span of the room clock the copy was delivered in, as the claimant states it; 0 and 2^64 − 1 when it does not know | +| `known` | ⌈`patlen` / 8⌉ | a bitmap: the positions recovered | +| `bits` | ⌈`patlen` / 8⌉ | a bitmap: at each position of `known`, the variant recovered; 0 elsewhere | +| `copy` | 32 | the digest of the copy it was recovered from, as the claimant holds it; 32 zero bytes when not stated | +| `claimant` | 32 | the Ed25519 key that signs it | +| `sig` | 64 | Ed25519 by `claimant` over the 28 bytes `wai:live-watermark-sequence` (ASCII) and 0x01, followed by the bytes before `sig` | + +Its **digest** is SHA-256 over its bytes before `sig`. + +### 7.2 Reading and rules + +It is read as §5.5 steps 1 to 3 read a record, with `WWQ1` for `WWS1`, and refused as +`sequence_invalid` when: `owner` and `media` break §5.5's rules for them; `patlen` is 0; +`start` is not below `end`; `known` or `bits` is not a bitmap; `bits` sets a bit `known` does +not; or `known` sets none. Then its `room` is the room id (`wrong_room`), and its signature +verifies (`bad_key`, `bad_signature`). + +### 7.3 Candidates and agreement + +A session record *S* is a **candidate** for a recovered sequence *Q* when *S*'s `owner`, +`media`, `vendor` and `patlen` are *Q*'s, and their spans meet: *S*'s `start` is below *Q*'s +`end` and *Q*'s `start` below *S*'s `end`. + +A candidate **agrees** with *Q* when, at every position *Q*'s `known` sets, *S*'s `served` +sets it and *S*'s `bits` holds *Q*'s bit. Otherwise its **first disagreement** is the lowest +such position at which one of the two fails: **unserved** where *S* did not sequence it, +**differs** where it did and embedded the other variant. + +### 7.4 The outcome + +Over the session records it is given, each verified (§9.1), a verifier states each candidate in +the order given, with whether it agrees and, where it does not, its first disagreement and how; +and the number of positions *Q* states. Then: + +- **matched**: exactly one candidate agrees; the outcome names its digest; +- **unmatched**, `no_match`: no candidate agrees, or there is no candidate; +- **unmatched**, `ambiguous_match`: more than one candidate agrees; the outcome names each. + +### 7.5 What the rule leaves to detection + +The TS leaves detection, and the statistics of its decoder, out of its scope (§5.8 there): a +vendor's detector extracts the identifier from a copy of several minutes, and the TS sets no +error model, no tolerance and no threshold. This rule therefore binds what was embedded, and +decides nothing about how it was recovered: + +- A position the detector could not read with confidence is left out of `known`: an + **erasure**. A stated bit is compared exactly; this rule has no tolerance for a stated bit + that disagrees, since the TS gives none. +- How many positions tell sessions apart depends on how the token's issuer drew the patterns + (their length and their uniqueness, which the TS leaves to it); the outcome states the number + of positions stated, and sets no minimum. +- A copy assembled from several sessions' segments (collusion) is outside the TS's model and + this rule's: it agrees with none of them at every stated position unless their bits coincide + there. +- A segment served unmarked (§5.2) is in no record's `served`; a bit recovered from one makes + a true session disagree (unserved), which is why a claimant states only positions recovered + from the span in which the content was sequenced. + +## 8. Disclosure + +### 8.1 Layout + +| field | size | meaning | +|---|---|---| +| `magic` | 4 | `WWD1` (ASCII) | +| `version` | 1 | 1 in this revision | +| `room` | 32 | the room id | +| `session` | 32 | the digest of the session record it opens (§5.4) | +| `sequence` | 32 | the digest of the recovered sequence it answers (§7.1) | +| `grant` | 32 | the opening's grant digest | +| `principal` | 32 | the opening's principal | +| `salt` | 32 | the opening's salt | +| `t` | 8 | u64: seconds since 1970-01-01T00:00:00Z, when the authority signed it | +| `sig` | 64 | §8.2 | + +### 8.2 Digest and signature + +The **digest** is SHA-256 over the 205 bytes before `sig`. The **signature** is Ed25519 by the +descriptor's `authority` over the 30 bytes `wai:live-watermark-disclosure` (ASCII) and 0x01, +followed by the bytes the digest covers. A disclosure is read as §5.5 steps 1 to 3 read a +record, with `WWD1` for `WWS1`; it has no other field rule. + +### 8.3 When the authority discloses + +The room authority signs a disclosure only: + +- for a recovered sequence signed by its claimant, that reads and verifies (§7.2); +- whose match over every session record of that content the authority holds for the + sequence's span is **matched** (§7.4), and for that one session: a third party given one + record cannot check this, and the authority, which holds the openings, answers for it; +- before the record's `expires` (§6.1). + +It discloses one session per disclosure, and never an opening for a sequence that does not +agree with its record. Whether a claimant's request is lawful, and to whom the authority +answers it, is the deployment's and the law's; a disclosure records that the authority answered +this sequence with this session's opening. + +### 8.4 Checking a disclosure + +A third party is given the room (a descriptor, its grants, the head of the authority's key log +or none, and the room's confidentiality record if it has one), one session record and, if it +holds it, its token, one recovered sequence, and the disclosure. It runs §9.3, and from an +accepted disclosure learns: the session's grant and principal, and that the edge embedded in +that session, at every position the claimant states, the variant the claimant recovered. It +learns nothing of another session: it holds no other record, and the disclosure names one. + +## 9. Verification + +### 9.1 Session records + +A verifier is given the room (a descriptor, its grants, the head of the authority's key log or +none, and the room's confidentiality record or none) and a list of session records, each with +its token or none. It runs these steps and stops at the first refusal, which it reports with +the reason and where: + +1. **The room**: the descriptor reads and verifies (live-rooms §3.4, §3.2), at `descriptor`; + the key log is held (live-rooms §5 steps 1 to 4), at `key_log`; and the grants are checked + (live-rooms §4.3), at `grant` and the grant's index; each with that section's reasons. +2. **Confidentiality**, at `confidentiality`, when a confidentiality record is given: it reads + (confidentiality §3.3 step 1: `truncated`, `malformed`, `unknown_version`), names the room + (`wrong_room`), and its signature verifies under the descriptor's `authority` + (`bad_key`, `bad_signature`). A record that passes states that the room is end-to-end + encrypted: `e2ee_room` (§3.2). Its suites are confidentiality's to check, not this + extension's. +3. **Each session record**, in the list's order, at `session` and its index: + 1. it reads (§5.5): `truncated`, `malformed`, `unknown_version`, `record_invalid`; + 2. its `room` is the room id: `wrong_room`; + 3. its signature verifies (§5.4): `bad_key`, `bad_signature`; + 4. its edge may sign (§5.6): `edge_not_authorised`, `edge_revoked`; + 5. its content is the room's: an `owner` of the form `_` and a slot's name names a slot of + the descriptor, of the record's `media`. Otherwise `content_unknown`. A publisher's key + as `owner` is not checked here; + 6. it names a content key (§3.1): `not_protected`; + 7. its region and its `expires` (§6.1): `region_not_pinned`, `record_invalid`, + `retention_exceeded`; + 8. its `token` is no earlier record's in the list: `token_reused`. One token is one + session's (§3.3), and two records that shared one would share its pattern; + 9. with its token given, §5.7: `token_mismatch`, `token_malformed`, `token_unsupported`, + `outside_token_window`. + +The records are **accepted** when every one passes. The verifier states, for each, its digest, +its edge, its content, its span, its KIDs, its vendor, source and pattern length, the number of +positions it served, its `segments` and `unmarked`, its region and `expires`, and whether its +token was given, with what the token gives (§4.3) when it was. + +### 9.2 A match + +A verifier is given what §9.1 is given and a recovered sequence. It runs §9.1, then reads and +verifies the sequence (§7.2), at `sequence`, then states the outcome of §7.4. + +### 9.3 A disclosure + +A verifier is given what §8.4 lists. It runs these steps and stops at the first refusal: + +1. §9.1 over the one session record, with its token if given. +2. The sequence (§7.2), at `sequence`. +3. The disclosure, at `disclosure`: it reads (§8.2: `truncated`, `malformed`, + `unknown_version`); its `room` is the room id (`wrong_room`); its signature verifies under + the descriptor's `authority` (`bad_key`, `bad_signature`). +4. Its `session` is the record's digest and its `sequence` the sequence's: otherwise + `disclosure_mismatch`. +5. The record is a candidate for the sequence and agrees with it (§7.3): otherwise + `sequence_disagrees`. +6. Its opening opens the record's `binding` (§5.3): otherwise `opening_mismatch`. +7. A grant given has the digest `grant` (`grant_unknown`), and its `principal` is the + disclosure's (`grant_wrong_principal`). +8. That grant was good over the session's span: `nbf` × 10^6 ≤ `t0` + `start` and + `t0` + `end` ≤ `exp` × 10^6. Otherwise `outside_grant_window`. +9. Its `t` is at or before the record's `expires`: otherwise `disclosure_expired`. + +A disclosure that passes is **accepted**, and the verifier states its digest, the session's and +the sequence's digests, the grant's digest, the principal, the number of positions the sequence +states, and whether the session's token was given. + +### 9.4 Outcomes + +- **refuse**: the reason and where. +- **accept**, for session records and a disclosure, with what §9.1 and §9.3 state. +- **matched** or **unmatched**, for a match (§7.4), with what it states. + +## 10. Reading the documents together + +### 10.1 Algorithm identifiers + +The TS's §5.4 names the algorithms a recipient supports as "HMAC 256/256 (kty number 5)" and +"ES256 (kty number −7)", and its pattern encryption as "ECDH-SS+A128KW (key type −32)". RFC +9053 registers 5 (§3.1), −7 (§2.1) and −32 (§6.4) as `alg` values, not key types; this +extension reads them as the `alg` of the token's protected header (§4.2 step 5), and HMAC +256/256 as a COSE_Mac0 and ES256 as a COSE_Sign1, the structures RFC 9052 gives a MAC and a +signature with an implicit key. + +### 10.2 COSE + +RFC 8392 cites RFC 8152 for the COSE structures; RFC 9052 obsoletes it and keeps the +structures, their tags and their headers, and this extension reads them from RFC 9052. The +TS's CDDL gives `wmpattern` as `COSE_Encrypt0 // COSE_Encrypt // bytes`, which in RFC 9052's +CDDL are the untagged arrays; this extension reads them so (§4.2 step 7.6). + +### 10.3 From a bit to a variant + +The TS's §4 has each of two variants carry a watermark that encodes '0' or '1', and its §5.4 +addresses one bit of the pattern per position; it does not spell out which variant a bit +selects. This extension reads a bit *b* as selecting the variant numbered *b*: 0 is A, the +variant every viewer receives when nothing is sequenced (§5.3, §5.7.5.1 there), and 1 is B. +A third variant has no bit to select it, and this revision records A/B sequencing alone. + +### 10.4 Positions beyond the pattern + +The TS bounds a position by its WMPaceInfo carriage (a 15-bit field, or a 2-byte integer, §5.5 +there) and a pattern by `wmpatlen`, and does not say what an edge serves for a position at or +beyond `wmpatlen`. This extension serves such a segment unsequenced (§5.2), so that no record +states a bit the pattern does not hold. + +## 11. Reasons + +The codes this extension adds, each with where it is raised: + +| reason | raised at | +|---|---| +| `record_invalid` | §5.5, §6.1, §9.1 step 3 | +| `not_protected` | §3.1, §9.1 step 3 | +| `e2ee_room` | §3.2, §9.1 step 2 | +| `edge_not_authorised` | §5.6, §9.1 step 3 | +| `edge_revoked` | §5.6, §9.1 step 3 | +| `content_unknown` | §9.1 step 3 | +| `region_not_pinned` | §6.1, §9.1 step 3 | +| `retention_exceeded` | §6.1, §9.1 step 3 | +| `token_reused` | §9.1 step 3 | +| `token_malformed` | §4.2, §5.7 | +| `token_unsupported` | §4.2, §5.7 | +| `token_mismatch` | §5.7 | +| `outside_token_window` | §5.7 | +| `sequence_invalid` | §7.2 | +| `no_match` | §7.4 | +| `ambiguous_match` | §7.4 | +| `disclosure_mismatch` | §9.3 step 4 | +| `sequence_disagrees` | §9.3 step 5 | +| `opening_mismatch` | §9.3 step 6 | +| `disclosure_expired` | §8.3, §9.3 step 9 | + +A verifier of this extension also raises `truncated`, `malformed` and `unknown_version` for a +record, a sequence, a disclosure or a confidentiality record that does not read; `wrong_room`, +`bad_key` and `bad_signature` for one of another room or whose signature fails; every code a +descriptor, a key log or a grant raises (live-rooms §11 and the live-events codes it lists), at +the descriptor, the key log or the grant; and `grant_unknown`, `grant_wrong_principal` and `outside_grant_window`, which +live-rooms §11 lists, for a disclosure's grant (§9.3 steps 7 and 8). + +## 12. Conformance + +[`live-conformance/watermark/`](../live-conformance/watermark/) holds the corpus, in one file, +`vectors.json`, written from fixed seeds by `wai_live_watermark_vectors gen`. It has: + +- **Rooms**: a broadcast room whose origin's key log lists two edges with `live/watermark`, a + relay without it and an edge revoked; a room with no origin, whose clock is its edge; and an + end-to-end encrypted room with its confidentiality record. +- **Tokens**, each read by §4.2 with its outcome and what it gives: COSE_Mac0 tokens under HMAC + 256/256 in each mode (a pattern in the clear, encrypted as COSE_Encrypt0 and as + COSE_Encrypt, derived), one in the CWT tag, and one refused for each rule of §4.2 a corpus + token breaks. +- **Sessions**: an edge's records of four sessions of the broadcast room, each mode of token + among them, with their tokens and without; the room with no origin; and one refused list for + each reason of §11 a record or its token raises, each a control of one defect. +- **Matches**: a sequence recovered from one session's copy with erasures, matched; one that + agrees with two sessions; one whose bits disagree, one whose span meets no session, and one + of another vendor, each unmatched; and sequences refused. +- **Disclosures**: the authority's disclosure of the matched session, accepted, and one refused + for each reason of §9.3, among them a disclosure of another session, an opening of another + session, and a disclosure for a sequence that does not agree with its session. + +The tokens' MACs are HMAC-SHA-256 under a fixed test key, which the corpus states; no verifier +checks them (§4.4). + +A conforming verifier gives every vector the outcome, the reason, the place and the facts the +corpus states. `wai_live_watermark_vectors verify` and the test `tests/live_watermark.rs` check +that the file regenerates byte for byte, that `wai-rs` gives every vector its outcome, that +every reason of §11 appears, and that §11 lists exactly the reference's codes. +`live-conformance/live_watermark_verify.py` is the second implementation, written from this +text: it shares no code with `wai-rs`; it reads descriptors, grants and key logs with the +live-rooms corpus's second implementation (`live_rooms_verify.py`), verifies every signature +with WAI's strict rule (`claim-conformance/_ed25519.py`), reads every token with a CBOR reader +of its own written from RFC 8949, and requires every mutated copy of the corpus to fail. + +## 13. Security and privacy considerations + +- **What the record proves.** An edge's signature on a record is its statement that it served + that session those variants at those positions under that token, and that it admitted the + session under the grant its binding commits to. Nothing in the record proves the media were + delivered as stated; an edge that lies about its own session is caught only where another + record contradicts it. A recovered sequence that agrees with a record is evidence the copy + came from that session to the extent the stated positions tell sessions apart (§7.5). +- **Framing.** A token is a capability to obtain segments marked with its pattern. A token + presented by someone other than its viewer marks the copy with the viewer's pattern; the + edge binds each record to the grant it admitted with proof of possession (live-rooms §10.5), + so a record names the session that actually received the segments, and a token used twice is + two records with one token, `token_reused`. Records do not carry tokens (§4.1). +- **Who learns a viewer.** Only the room authority, through an opening, and only for the + sessions it discloses. A party matching sequences holds records that name no grant; a third + party checking a disclosure learns one session's principal. +- **The authority's duty.** The authority answers for the uniqueness of a match (§8.3), which a + third party cannot check. A disclosure is signed, and names the sequence it answers and the + claimant's key through it, so every disclosure the authority makes is attributable to it and + to a request. +- **Retention.** Records, tokens and openings are kept within the room's region pin and until + `expires`, bounded by the room's retention. A disclosure after `expires` is refused, so an + authority that keeps openings past the bound cannot present what it discloses as conforming. + The `t` of a disclosure is the authority's own statement. +- **Linkability.** Per-device or per-account patterns link a viewer's sessions to anyone who + holds their records (§6.3). +- **Lane B.** An end-to-end encrypted room offers no edge a choice to make, and its members' + applications hold the decoded output (§3.2, SPEC §6.5). Marking it would need the member's + device to mark what it decodes, which neither the TS nor this extension defines. diff --git a/wai/extensions/jwp-receipts.md b/wai/extensions/jwp-receipts.md index d5772320e..b51584496 100644 --- a/wai/extensions/jwp-receipts.md +++ b/wai/extensions/jwp-receipts.md @@ -3251,6 +3251,7 @@ A confirmation needs every one of them: | a per-hop session record ([live-metering](live-metering.md) §3) | `live/relay`, `live/compositor` or `live/ingest`, by its role; and `energy/` for its figure's class where it is metered | | a room-session receipt (live-metering §4) signed by a key other than the room authority | `live/meter`; and, from any signer, `energy/` for each class it totals | | a steering policy or steering decision ([energy-steering](energy-steering.md) §3.5) signed by a key other than the room authority | `live/steer`; and, from any signer of a decision, `energy/` for each class among its figures | +| a watermark session record ([forensic-watermark](forensic-watermark.md) §5) | `live/watermark` | 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. @@ -3353,6 +3354,10 @@ its keys is that party is the origin's decision, and its log records it: - **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`. +- **Watermark edges.** `live/watermark` lets a key act as an edge that chooses forensic A/B + variants for a room's viewers: sign the session records of what it embedded + ([forensic-watermark](forensic-watermark.md) §5.6). It is not pinned-only, and it is not + `live/relay` or `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, diff --git a/wai/live-conformance/watermark/vectors.json b/wai/live-conformance/watermark/vectors.json new file mode 100644 index 000000000..480d8b6ba --- /dev/null +++ b/wai/live-conformance/watermark/vectors.json @@ -0,0 +1,1884 @@ +{ + "corpus": "wai.live.watermark", + "extension": "extensions/forensic-watermark.md", + "description": "Forensic watermark session records (wai.live.watermark): tokens read by §4.2, session records verified by §9.1, recovered sequences matched by §7.3 and §7.4, and disclosures checked by §9.3. Keys, patterns, salts and KIDs are SHA-256 of a label and an index; the tokens' MACs are HMAC-SHA-256 under wmt_key, which no verifier checks (§4.4).", + "principals": { + "authority": "6cb333eb2b01f7c192e45d749d7f0f0662a045ecd4f04b9ee0055752b0b8c400", + "clock": "3a2224539295ba1bd1315867f0bc503dad7de13b2f110436fa8f05a6a0d4fe76", + "edge": "cf329a11544d57c236135f220f7b77b7ce5c2bcfe97015b7d83e075b81b789f3", + "edge2": "0d4b3cdae998fa7ade7d7600d29db16f397e14dd56c2864263fb1144b6cd03d5", + "relay": "91e5002715c3f826f41a430ad982e181fff91fe6949b1a4f834bb5e9803e2b13", + "edge_old": "a5a1c56272720c05d58a48e57913c316d448bbd6a50b87a11ce67e05b37e5597", + "broadcaster": "d510288a80239539921f8dc18ccca7ea45b168e11b10ed5d89e68d927685b7dd", + "viewer1": "2cb2d52b2fa17fa5bbcaff468f1f31a41d0b0c28f9ce1c1fbb0eeef78ea50cf1", + "viewer2": "3c0bb2a71c87d52373fba22cb6ba2e9e2412dd39fc0f5dcd5fd3deb7af90acc9", + "viewer3": "62d122c3c2774fd7447203d74703a0b240e91a7e8647f8fe746abc43d58676ed", + "viewer4": "3f6e554e086d1f33ffe354eec65f1eadd8f770fb4bf2131f5774f52c754e1b95", + "claimant": "d9ffbd99687a7f1ab17a0403f5d9c64751f460edf1f3a64a0b806ffe91e4dca6", + "log_root": "67cb9365d99a014deac01bc2cdad42814335e5a412aac2e8dad9db0c5422dff5", + "log_next": "85eb5a597759b55f3bcb0ece2d169932624901c88283f8c879c4c42d5490098f" + }, + "t0_us": 1792368000000000, + "wmt_key": "1b1cf012923cdde1079904600578f435510545ce4d19a383e4ea6675070db93e", + "rooms": { + "broadcast": { + "room_id": "13475f3e0f8a3a34c5acf8f3abb87af5840eef8e35870a8942f3de9b3e16873b", + "descriptor": "57524431016cb333eb2b01f7c192e45d749d7f0f0662a045ecd4f04b9ee0055752b0b8c4003a2224539295ba1bd1315867f0bc503dad7de13b2f110436fa8f05a6a0d4fe760060f231265e060040420f0000000000120068747470733a2f2f74762e6578616d706c654000000002002c0100005745503101fe1f0000000000000000000000000000000000000000000000000000000000000004f4013200000040420f00000000000100000040420f0000000000020b62726f61646361737465720201020000000000000000000000000000000000000000000000000000000000000000000676696577657200010200000000000000000000000000000000000000000000000000000000000000000001046d61696e0201000000000000000000000000000000000000000000000000000000000000000000000000000000000000011e00d0070000f40100000244454652a3736bd7ab79ca5fd360d39e176b02d83ed2310f9fbcbdd3bcc7de821168925673e91ab0643bfc2dc76f390bf1bc97fb1ad47e1bfcd3975d7938c6e13550a604", + "grants": [ + "575247310113475f3e0f8a3a34c5acf8f3abb87af5840eef8e35870a8942f3de9b3e16873bd510288a80239539921f8dc18ccca7ea45b168e11b10ed5d89e68d927685b7dd00000000000000000000000000000000000000000000000000000000000000000000000000000000704fd56a0000000000afd66a0000000000020102000000000000000000000000000000000000a65356094fa917ceecb8cab86548f81e574550310100000000000000000000000000000000000000000000000000000000000000000004f4013200000040420f00000000000100000040420f0000000000ffffffffffffffffffffffffffffffff00000000011268747470733a2f2f74762e6578616d706c650000365e19a3972f9d1cd58a09679d877fc1785fd9b4654e8338fd0719f56acf1da49f83c5198c023529b3ad4c4e0cda60b0f41af9e67e97f02e2d1bcf86a8552301", + "575247310113475f3e0f8a3a34c5acf8f3abb87af5840eef8e35870a8942f3de9b3e16873b2cb2d52b2fa17fa5bbcaff468f1f31a41d0b0c28f9ce1c1fbb0eeef78ea50cf100000000000000000000000000000000000000000000000000000000000000000000000000000000704fd56a0000000000afd66a000000000100010200000000000000000000000000000000000085c6b22ca03a2633b4b0ba1be7338285574550310100000000000000000000000000000000000000000000000000000000000000000004f4013200000040420f00000000000100000040420f0000000000ffffffffffffffffffffffffffffffff00000000011268747470733a2f2f74762e6578616d706c65000033df13c5bf7a88cdc0022ee634c06c5692a39bde15a91d3216df85f4ef77bde342111456d6c055a3e45d82ac443785b19f3e57d1d9b085ee91a47fc205391e0c", + "575247310113475f3e0f8a3a34c5acf8f3abb87af5840eef8e35870a8942f3de9b3e16873b3c0bb2a71c87d52373fba22cb6ba2e9e2412dd39fc0f5dcd5fd3deb7af90acc900000000000000000000000000000000000000000000000000000000000000000000000000000000704fd56a0000000000afd66a0000000001000102000000000000000000000000000000000000e8fd85d82893fbb80da381105c90f059574550310100000000000000000000000000000000000000000000000000000000000000000004f4013200000040420f00000000000100000040420f0000000000ffffffffffffffffffffffffffffffff00000000011268747470733a2f2f74762e6578616d706c650000847acb8e92f4c38dc6f71a0bdd64428f93bcb0bb00e6f1dd64b767d10b858a7b68f5cbb7821b169732f978d25aabaaba67f87e746984f2b9716d11f0347d930b", + "575247310113475f3e0f8a3a34c5acf8f3abb87af5840eef8e35870a8942f3de9b3e16873b62d122c3c2774fd7447203d74703a0b240e91a7e8647f8fe746abc43d58676ed00000000000000000000000000000000000000000000000000000000000000000000000000000000704fd56a0000000000afd66a00000000010001020000000000000000000000000000000000003bc73ee8f2cbcde3023aa5e1f887de84574550310100000000000000000000000000000000000000000000000000000000000000000004f4013200000040420f00000000000100000040420f0000000000ffffffffffffffffffffffffffffffff00000000011268747470733a2f2f74762e6578616d706c65000014e01c53a6ad6f0a697d2f3791abbefbab0036d8059203712a71aa772514553499fb3fe3eb0f28be80764d617d7a9c70a705aa9f3d852bca342628bc5ae4390a", + "575247310113475f3e0f8a3a34c5acf8f3abb87af5840eef8e35870a8942f3de9b3e16873b3f6e554e086d1f33ffe354eec65f1eadd8f770fb4bf2131f5774f52c754e1b9500000000000000000000000000000000000000000000000000000000000000000000000000000000704fd56a000000003062d56a0000000001000102000000000000000000000000000000000000ecdcba6481514803c669813a6dba1b26574550310100000000000000000000000000000000000000000000000000000000000000000004f4013200000040420f00000000000100000040420f0000000000ffffffffffffffffffffffffffffffff00000000011268747470733a2f2f74762e6578616d706c650000dfa4b0b898412e3fad702126a4f505592e23c95fd4f188d78c33139fe54fc8f786a1cba35b1e73fe2738650cf764551d9b30e87e638bb73fbe728886381b930d" + ], + "key_log": "{\"grant_max_s\":86400,\"issued_at\":\"2026-10-15T00:00:00Z\",\"keys\":[{\"compromised_since\":null,\"id\":\"room-authority\",\"key\":\"6cb333eb2b01f7c192e45d749d7f0f0662a045ecd4f04b9ee0055752b0b8c400\",\"may_sign\":[\"live/room\"],\"status\":\"active\",\"until\":null},{\"compromised_since\":null,\"id\":\"edge-1\",\"key\":\"cf329a11544d57c236135f220f7b77b7ce5c2bcfe97015b7d83e075b81b789f3\",\"may_sign\":[\"live/watermark\",\"live/relay\"],\"status\":\"active\",\"until\":null},{\"compromised_since\":null,\"id\":\"edge-2\",\"key\":\"0d4b3cdae998fa7ade7d7600d29db16f397e14dd56c2864263fb1144b6cd03d5\",\"may_sign\":[\"live/watermark\"],\"status\":\"active\",\"until\":null},{\"compromised_since\":null,\"id\":\"relay\",\"key\":\"91e5002715c3f826f41a430ad982e181fff91fe6949b1a4f834bb5e9803e2b13\",\"may_sign\":[\"live/relay\"],\"status\":\"active\",\"until\":null},{\"compromised_since\":\"2026-10-14T00:00:00Z\",\"id\":\"edge-old\",\"key\":\"a5a1c56272720c05d58a48e57913c316d448bbd6a50b87a11ce67e05b37e5597\",\"may_sign\":[\"live/watermark\"],\"status\":\"revoked\",\"until\":null}],\"kind\":\"wai-key-set\",\"next_root\":\"0036ca69453eb602298692bce8a7b84bc2b90db80e4af496638a52c82dba90a8\",\"origin\":\"https://tv.example\",\"prev\":null,\"revoked_modules\":[],\"root\":\"67cb9365d99a014deac01bc2cdad42814335e5a412aac2e8dad9db0c5422dff5\",\"seq\":0,\"sig\":\"72c25d5083ba78f38aca145c37bfd7811d65e32da5d8af6c8315cca3aaa19175d6695f85a8a45fcda98ecd43680d33ce2677d3b52d6e9caf3bf8c41467cfbc05\",\"status_max_age_s\":3600}", + "confidentiality": null + }, + "e2ee": { + "room_id": "262644692a2af5c4dc576e607c0fc56cb470ad5b5e3cd391f507b09c330f2bfe", + "descriptor": "57524431016cb333eb2b01f7c192e45d749d7f0f0662a045ecd4f04b9ee0055752b0b8c4003a2224539295ba1bd1315867f0bc503dad7de13b2f110436fa8f05a6a0d4fe760060f231265e060040420f000000000000004000000002002c0100005745503101fe1f0000000000000000000000000000000000000000000000000000000000000004f4013200000040420f00000000000100000040420f0000000000020b62726f61646361737465720201020000000000000000000000000000000000000000000000000000000000000000000676696577657200010200000000000000000000000000000000000000000000000000000000000000000001046d61696e0201000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000a936cf7b2f259b023fbca4f6a2be14c884c9cd42e8dcfecd17bd3453d586283e3239d6a8598374934104ed9ea738455b0a878f5c54c2807b7bd11d4a8bc13f01", + "grants": [ + "5752473101262644692a2af5c4dc576e607c0fc56cb470ad5b5e3cd391f507b09c330f2bfed510288a80239539921f8dc18ccca7ea45b168e11b10ed5d89e68d927685b7dd00000000000000000000000000000000000000000000000000000000000000000000000000000000704fd56a0000000000afd66a0000000000020102000000000000000000000000000000000000a65356094fa917ceecb8cab86548f81e574550310100000000000000000000000000000000000000000000000000000000000000000004f4013200000040420f00000000000100000040420f0000000000ffffffffffffffffffffffffffffffff00000000011268747470733a2f2f74762e6578616d706c6500007d52c5a6374102b9eee1e6de91e01193746820efe5a29d57f9eebedf3bb1e3ef691f3e9ef8dcd40a3139c61445b2fd553484c2291ebd534344d6f0482ad12308", + "5752473101262644692a2af5c4dc576e607c0fc56cb470ad5b5e3cd391f507b09c330f2bfe2cb2d52b2fa17fa5bbcaff468f1f31a41d0b0c28f9ce1c1fbb0eeef78ea50cf100000000000000000000000000000000000000000000000000000000000000000000000000000000704fd56a0000000000afd66a000000000100010200000000000000000000000000000000000085c6b22ca03a2633b4b0ba1be7338285574550310100000000000000000000000000000000000000000000000000000000000000000004f4013200000040420f00000000000100000040420f0000000000ffffffffffffffffffffffffffffffff00000000011268747470733a2f2f74762e6578616d706c650000d34749f7c3ac2e8f47d0e7f90b160cb2d77e2ec64b10061d662f18b35cbae316850b18de865886e1db4d7932aea4897b8d4e83a130a6b1b06bf9342a2896790a", + "5752473101262644692a2af5c4dc576e607c0fc56cb470ad5b5e3cd391f507b09c330f2bfe3c0bb2a71c87d52373fba22cb6ba2e9e2412dd39fc0f5dcd5fd3deb7af90acc900000000000000000000000000000000000000000000000000000000000000000000000000000000704fd56a0000000000afd66a0000000001000102000000000000000000000000000000000000e8fd85d82893fbb80da381105c90f059574550310100000000000000000000000000000000000000000000000000000000000000000004f4013200000040420f00000000000100000040420f0000000000ffffffffffffffffffffffffffffffff00000000011268747470733a2f2f74762e6578616d706c6500004f0b04d29a177d0d25b89637b197f23393a68e5f12f840360aed0dec254fc37cb92d37c6713b490460426805e59192ac8679eb0adb1b087a7759b036232cb808", + "5752473101262644692a2af5c4dc576e607c0fc56cb470ad5b5e3cd391f507b09c330f2bfe62d122c3c2774fd7447203d74703a0b240e91a7e8647f8fe746abc43d58676ed00000000000000000000000000000000000000000000000000000000000000000000000000000000704fd56a0000000000afd66a00000000010001020000000000000000000000000000000000003bc73ee8f2cbcde3023aa5e1f887de84574550310100000000000000000000000000000000000000000000000000000000000000000004f4013200000040420f00000000000100000040420f0000000000ffffffffffffffffffffffffffffffff00000000011268747470733a2f2f74762e6578616d706c6500006cf61a63a7832ce9e9287470cd69aa2b8d9b52b752c543864aa3869d39b15aa843b4acea7bb6829288d3434b3111aa6b94c074b2dbf0e0d75cd1444699894606", + "5752473101262644692a2af5c4dc576e607c0fc56cb470ad5b5e3cd391f507b09c330f2bfe3f6e554e086d1f33ffe354eec65f1eadd8f770fb4bf2131f5774f52c754e1b9500000000000000000000000000000000000000000000000000000000000000000000000000000000704fd56a000000003062d56a0000000001000102000000000000000000000000000000000000ecdcba6481514803c669813a6dba1b26574550310100000000000000000000000000000000000000000000000000000000000000000004f4013200000040420f00000000000100000040420f0000000000ffffffffffffffffffffffffffffffff00000000011268747470733a2f2f74762e6578616d706c6500008c7d67254df7fe8287cff1a82eff32a93b317122bd9194d6fcbc9b8a9533e629e5e15dc522c08f362c77cd62b50e3d4dfd1c40f24a56d63808aa2952eb04ba0a" + ], + "key_log": null, + "confidentiality": "5752453101262644692a2af5c4dc576e607c0fc56cb470ad5b5e3cd391f507b09c330f2bfe0400010055387112a850afe003631533c1e9fff3ae5fce9a9f12f4c3842994879ce86c9f69cdc591342bf10aee012c126968340122dbc726a9b51c56d99a49467a834501" + }, + "keyless": { + "room_id": "90b72587ed37038aa7d3e07747d1c9faf8c9791c3f38882f4ec2149bd7067bed", + "descriptor": "57524431016cb333eb2b01f7c192e45d749d7f0f0662a045ecd4f04b9ee0055752b0b8c400cf329a11544d57c236135f220f7b77b7ce5c2bcfe97015b7d83e075b81b789f30060f231265e060040420f000000000000004000000002002c0100005745503101fe1f0000000000000000000000000000000000000000000000000000000000000004f4013200000040420f00000000000100000040420f0000000000020b62726f61646361737465720201020000000000000000000000000000000000000000000000000000000000000000000676696577657200010200000000000000000000000000000000000000000000000000000000000000000001046d61696e02010000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000006a2c81076e43ec76b1976a2c1b685d63fe7208d68975c2f142764e3a09ee94736dcff943be5506159eac867b6e1e6125755a2b8d9003fbedabe838b890f93506", + "grants": [ + "575247310190b72587ed37038aa7d3e07747d1c9faf8c9791c3f38882f4ec2149bd7067bedd510288a80239539921f8dc18ccca7ea45b168e11b10ed5d89e68d927685b7dd00000000000000000000000000000000000000000000000000000000000000000000000000000000704fd56a0000000000afd66a0000000000020102000000000000000000000000000000000000a65356094fa917ceecb8cab86548f81e574550310100000000000000000000000000000000000000000000000000000000000000000004f4013200000040420f00000000000100000040420f0000000000ffffffffffffffffffffffffffffffff00000000011268747470733a2f2f74762e6578616d706c650000082d8804762f133687e5d1c188cbe57719135964aea4770a59e446ecc1a4836d9155f0b7d8206568df40695ce8f0a8eace8193170473636a3bc8232da6eb0003", + "575247310190b72587ed37038aa7d3e07747d1c9faf8c9791c3f38882f4ec2149bd7067bed2cb2d52b2fa17fa5bbcaff468f1f31a41d0b0c28f9ce1c1fbb0eeef78ea50cf100000000000000000000000000000000000000000000000000000000000000000000000000000000704fd56a0000000000afd66a000000000100010200000000000000000000000000000000000085c6b22ca03a2633b4b0ba1be7338285574550310100000000000000000000000000000000000000000000000000000000000000000004f4013200000040420f00000000000100000040420f0000000000ffffffffffffffffffffffffffffffff00000000011268747470733a2f2f74762e6578616d706c650000981314acad5de8ae320fe677211adbf3cc73fe11bbd1056bde0d1dfd1afa88c704ec144659c638eefc74811328412a27b594fa135087afa602d624ac32d83907", + "575247310190b72587ed37038aa7d3e07747d1c9faf8c9791c3f38882f4ec2149bd7067bed3c0bb2a71c87d52373fba22cb6ba2e9e2412dd39fc0f5dcd5fd3deb7af90acc900000000000000000000000000000000000000000000000000000000000000000000000000000000704fd56a0000000000afd66a0000000001000102000000000000000000000000000000000000e8fd85d82893fbb80da381105c90f059574550310100000000000000000000000000000000000000000000000000000000000000000004f4013200000040420f00000000000100000040420f0000000000ffffffffffffffffffffffffffffffff00000000011268747470733a2f2f74762e6578616d706c65000097999945bb665385648c1969b988d6af73e87b1f6de88b872eff00f86ea01970d89283ca69423a2080ae7e65fe7862c744fbd7687ad346660a5928b1ce1e1502", + "575247310190b72587ed37038aa7d3e07747d1c9faf8c9791c3f38882f4ec2149bd7067bed62d122c3c2774fd7447203d74703a0b240e91a7e8647f8fe746abc43d58676ed00000000000000000000000000000000000000000000000000000000000000000000000000000000704fd56a0000000000afd66a00000000010001020000000000000000000000000000000000003bc73ee8f2cbcde3023aa5e1f887de84574550310100000000000000000000000000000000000000000000000000000000000000000004f4013200000040420f00000000000100000040420f0000000000ffffffffffffffffffffffffffffffff00000000011268747470733a2f2f74762e6578616d706c6500008c2e4751720d2816272de62583340c6a8177438f6c6577265b7139b2a14b9c9cb381a6d17c17ade6ee4936406917e156ae0b79ee3f6ed9e617c77695bb71b409", + "575247310190b72587ed37038aa7d3e07747d1c9faf8c9791c3f38882f4ec2149bd7067bed3f6e554e086d1f33ffe354eec65f1eadd8f770fb4bf2131f5774f52c754e1b9500000000000000000000000000000000000000000000000000000000000000000000000000000000704fd56a000000003062d56a0000000001000102000000000000000000000000000000000000ecdcba6481514803c669813a6dba1b26574550310100000000000000000000000000000000000000000000000000000000000000000004f4013200000040420f00000000000100000040420f0000000000ffffffffffffffffffffffffffffffff00000000011268747470733a2f2f74762e6578616d706c650000271e10a1bcd3930d6e88ce852a2756180b38637853f1e2b0b59ddc3a7081abca544fdd7f63014ed4beb1d01c3154b742222e717b9d3586688930cc7375c64b04" + ], + "key_log": null, + "confidentiality": null + } + }, + "tokens": [ + { + "name": "mac0_clear", + "description": "a COSE_Mac0 under HMAC 256/256 whose wmpattern is the pattern in the clear (direct mode)", + "token": "d18443a10105a05826a6041a6ad6af00061a6ad55b2819012c0119012d0719012e184019013048b5389319475bd35658207ea9ace87c093dc8460a5b20be105fbfa73c6459980efc4496bcf2b44fe62e8e", + "outcome": "read", + "reason": null, + "facts": { + "cwt_tag": false, + "alg": 5, + "vendor": 7, + "patlen": 64, + "source": 1, + "pattern": "b5389319475bd356", + "iat": 1792367400, + "exp": 1792454400, + "nbf": null + } + }, + { + "name": "mac0_encrypt0", + "description": "direct mode, the pattern encrypted as a COSE_Encrypt0", + "token": "d18443a10105a0584ba6041a6ad6af00061a6ad55b2819012c0119012d0719012e18401901308343a10101a1054c864eb089cbff4f4fdf3115d95818d9f84724aea43438845fc548b73aa5b9b5674aecee56f6f6582038d0243522b816c8b69787552116ed48d3c5925e5ec060d9821b906ad986d9a6", + "outcome": "read", + "reason": null, + "facts": { + "cwt_tag": false, + "alg": 5, + "vendor": 7, + "patlen": 64, + "source": 2, + "pattern": null, + "iat": 1792367400, + "exp": 1792454400, + "nbf": null + } + }, + { + "name": "mac0_encrypt", + "description": "direct mode, the pattern encrypted as a COSE_Encrypt with one recipient (ECDH-SS + A128KW)", + "token": "d18443a10105a05877a6041a6ad6af00061a6ad55b2819012c0119012d0719012e18401901308443a10101a1054cf46eabc683e7d090f6d43377581894970b4173532522ae74a6b922b957117e5e7761bd36e630818344a101381fa1044876656e646f722d3758185b8ac7dcb5a26f2b5dd72d18b5eb3707a3d80764f666c0605820db0e503166c63f86853b9c10e6bfd310e0e82185b4d70316b5a2fae762f0c5f4", + "outcome": "read", + "reason": null, + "facts": { + "cwt_tag": false, + "alg": 5, + "vendor": 7, + "patlen": 64, + "source": 2, + "pattern": null, + "iat": 1792367400, + "exp": 1792454400, + "nbf": null + } + }, + { + "name": "mac0_derived", + "description": "indirect mode: wmid, wmopid and wmkeyver, no wmpattern", + "token": "d18443a10105a0582fa8041a6ad6af00061a6ad55b2819012c0119012d0719012e18401901316973657373696f6e2d331901320c1901330258207a583e7258827b52bba41cd3eab55102283a43ad000386391e6a42e96e228186", + "outcome": "read", + "reason": null, + "facts": { + "cwt_tag": false, + "alg": 5, + "vendor": 7, + "patlen": 64, + "source": 3, + "pattern": null, + "iat": 1792367400, + "exp": 1792454400, + "nbf": null + } + }, + { + "name": "cwt_tagged", + "description": "the clear token in the CWT tag (61)", + "token": "d83dd18443a10105a05826a6041a6ad6af00061a6ad55b2819012c0119012d0719012e184019013048b5389319475bd35658207ea9ace87c093dc8460a5b20be105fbfa73c6459980efc4496bcf2b44fe62e8e", + "outcome": "read", + "reason": null, + "facts": { + "cwt_tag": true, + "alg": 5, + "vendor": 7, + "patlen": 64, + "source": 1, + "pattern": "b5389319475bd356", + "iat": 1792367400, + "exp": 1792454400, + "nbf": null + } + }, + { + "name": "registered_claims", + "description": "the clear token with iss, aud, nbf, cti, wmsegduration and an extension claim", + "token": "d18443a10105a05870ac017668747470733a2f2f74762e6578616d706c652f776d74036465646765041a6ad6af00051a6ad55b28061a6ad55b2807440102030419012c0119012d0719012e184019012f8219ea6019271019013048b5389319475bd35619038472616e20657874656e73696f6e20636c61696d582055575a45dcd243701c8cffe06b7b99eb269c7d469fd818bcbc7919821e7abe3a", + "outcome": "read", + "reason": null, + "facts": { + "cwt_tag": false, + "alg": 5, + "vendor": 7, + "patlen": 64, + "source": 1, + "pattern": "b5389319475bd356", + "iat": 1792367400, + "exp": 1792454400, + "nbf": 1792367400 + } + }, + { + "name": "truncated", + "description": "the clear token without its last byte", + "token": "d18443a10105a05826a6041a6ad6af00061a6ad55b2819012c0119012d0719012e184019013048b5389319475bd35658207ea9ace87c093dc8460a5b20be105fbfa73c6459980efc4496bcf2b44fe62e", + "outcome": "refuse", + "reason": "token_malformed", + "facts": null + }, + { + "name": "trailing_byte", + "description": "the clear token followed by one byte", + "token": "d18443a10105a05826a6041a6ad6af00061a6ad55b2819012c0119012d0719012e184019013048b5389319475bd35658207ea9ace87c093dc8460a5b20be105fbfa73c6459980efc4496bcf2b44fe62e8e00", + "outcome": "refuse", + "reason": "token_malformed", + "facts": null + }, + { + "name": "tag_not_shortest", + "description": "tag 17 written with one extra byte (RFC 8949 §4.2.1)", + "token": "d8118443a10105a05826a6041a6ad6af00061a6ad55b2819012c0119012d0719012e184019013048b5389319475bd35658207ea9ace87c093dc8460a5b20be105fbfa73c6459980efc4496bcf2b44fe62e8e", + "outcome": "refuse", + "reason": "token_malformed", + "facts": null + }, + { + "name": "indefinite_array", + "description": "the COSE array of indefinite length", + "token": "d19f43a10105a05826a6041a6ad6af00061a6ad55b2819012c0119012d0719012e184019013048b5389319475bd35658207ea9ace87c093dc8460a5b20be105fbfa73c6459980efc4496bcf2b44fe62e8eff", + "outcome": "refuse", + "reason": "token_malformed", + "facts": null + }, + { + "name": "untagged", + "description": "the COSE_Mac0 array with no COSE tag (RFC 8392 §7.2 step 3)", + "token": "8443a10105a05826a6041a6ad6af00061a6ad55b2819012c0119012d0719012e184019013048b5389319475bd35658207ea9ace87c093dc8460a5b20be105fbfa73c6459980efc4496bcf2b44fe62e8e", + "outcome": "refuse", + "reason": "token_unsupported", + "facts": null + }, + { + "name": "cwt_tag_not_cose", + "description": "the CWT tag around an untagged array (RFC 8392 §7.2 step 2)", + "token": "d83d8443a10105a05826a6041a6ad6af00061a6ad55b2819012c0119012d0719012e184019013048b5389319475bd35658207ea9ace87c093dc8460a5b20be105fbfa73c6459980efc4496bcf2b44fe62e8e", + "outcome": "refuse", + "reason": "token_malformed", + "facts": null + }, + { + "name": "cose_sign", + "description": "a COSE_Sign (tag 98), which this revision does not read", + "token": "d8628443a10105a05826a6041a6ad6af00061a6ad55b2819012c0119012d0719012e184019013048b5389319475bd35658207ea9ace87c093dc8460a5b20be105fbfa73c6459980efc4496bcf2b44fe62e8e", + "outcome": "refuse", + "reason": "token_unsupported", + "facts": null + }, + { + "name": "detached_payload", + "description": "a null payload", + "token": "d18443a10105a0f658205a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a", + "outcome": "refuse", + "reason": "token_unsupported", + "facts": null + }, + { + "name": "alg_unknown", + "description": "alg 6 (HMAC 384/384)", + "token": "d18443a10106a05826a6041a6ad6af00061a6ad55b2819012c0119012d0719012e184019013048b5389319475bd3565820df72ac3f116b571a2b1422103148b92c76a3010f1e50ab70da90e5bbd646a22f", + "outcome": "refuse", + "reason": "token_unsupported", + "facts": null + }, + { + "name": "alg_on_wrong_structure", + "description": "ES256 on a COSE_Mac0", + "token": "d18443a10126a05826a6041a6ad6af00061a6ad55b2819012c0119012d0719012e184019013048b5389319475bd356582020ab2aee3c12b5854f0ac28892682444625eed15e682ea26b3036ed4024f1050", + "outcome": "refuse", + "reason": "token_malformed", + "facts": null + }, + { + "name": "tag_short", + "description": "a MAC of 16 bytes", + "token": "d18443a10105a05826a6041a6ad6af00061a6ad55b2819012c0119012d0719012e184019013048b5389319475bd356505a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a", + "outcome": "refuse", + "reason": "token_malformed", + "facts": null + }, + { + "name": "header_label_unknown", + "description": "a content-type header (label 3), not understood (RFC 8392 §7.2 step 4)", + "token": "d18443a10105a1036f6170706c69636174696f6e2f6377745826a6041a6ad6af00061a6ad55b2819012c0119012d0719012e184019013048b5389319475bd35658207ea9ace87c093dc8460a5b20be105fbfa73c6459980efc4496bcf2b44fe62e8e", + "outcome": "refuse", + "reason": "token_unsupported", + "facts": null + }, + { + "name": "alg_unprotected", + "description": "alg in the unprotected header, the protected header empty", + "token": "d18440a101055826a6041a6ad6af00061a6ad55b2819012c0119012d0719012e184019013048b5389319475bd35658203e2db8acef186234388f76e453dc31ed0f4e6f5482b50828b676e22e32763ec7", + "outcome": "refuse", + "reason": "token_malformed", + "facts": null + }, + { + "name": "kid_not_bytes", + "description": "a kid that is a text string", + "token": "d18443a10105a104626b315826a6041a6ad6af00061a6ad55b2819012c0119012d0719012e184019013048b5389319475bd35658207ea9ace87c093dc8460a5b20be105fbfa73c6459980efc4496bcf2b44fe62e8e", + "outcome": "refuse", + "reason": "token_malformed", + "facts": null + }, + { + "name": "nested_cwt", + "description": "a payload that is itself a COSE_Mac0 (RFC 8392 §7.2 step 6)", + "token": "d18443a10105a05851d18443a10105a05826a6041a6ad6af00061a6ad55b2819012c0119012d0719012e184019013048b5389319475bd35658207ea9ace87c093dc8460a5b20be105fbfa73c6459980efc4496bcf2b44fe62e8e5820bb94093359bc26ac2c3fce72dce93d62824e7e003c87e5d2a71eecc69071c0b3", + "outcome": "refuse", + "reason": "token_unsupported", + "facts": null + }, + { + "name": "claims_not_map", + "description": "a payload that is an array", + "token": "d18443a10105a042810158208223734db61b18329759df5d35981888bc332c3be23dbd6b7dc5c01aadff1a86", + "outcome": "refuse", + "reason": "token_malformed", + "facts": null + }, + { + "name": "text_claim_key", + "description": "a claim keyed by a text string (the TS's §5.4 has integer keys)", + "token": "d18443a10105a0582da7041a6ad6af00061a6ad55b2819012c0119012d0719012e184019013048b5389319475bd35665776d7665720158200fa6deb021c09ab55187b44e4bbd13ddf14d276ac4bf9981257b8659880c3bc2", + "outcome": "refuse", + "reason": "token_malformed", + "facts": null + }, + { + "name": "claims_unsorted", + "description": "the claims set with iat before exp, not in deterministic order", + "token": "d18443a10105a05826a6061a6ad55b28041a6ad6af0019012c0119012d0719012e184019013048b5389319475bd3565820f884e991f2417253e8666fb0c9bed783f63e6281d4bc5901e387cb1b995ff6f1", + "outcome": "refuse", + "reason": "token_malformed", + "facts": null + }, + { + "name": "exp_float", + "description": "exp written as a floating-point NumericDate", + "token": "d18443a10105a0582aa604fb41dab5abc0000000061a6ad55b2819012c0119012d0719012e184019013048b5389319475bd3565820ebecf8cdee494be183289c921edb1a0b24af8daf9f42e4041d8dc0a61e2cace2", + "outcome": "refuse", + "reason": "token_unsupported", + "facts": null + }, + { + "name": "extension_too_deep", + "description": "an extension claim whose arrays reach a depth of 17 in the claims set", + "token": "d18443a10105a0583aa7041a6ad6af00061a6ad55b2819012c0119012d0719012e184019013048b5389319475bd35619038581818181818181818181818181818181005820717bb944d069bbe59a02404df23fecc14e355cea09660ab500acc4f58e0efe88", + "outcome": "refuse", + "reason": "token_unsupported", + "facts": null + }, + { + "name": "iss_not_utf8", + "description": "an iss whose bytes are not UTF-8", + "token": "d18443a10105a0582aa70162c328041a6ad6af00061a6ad55b2819012c0119012d0719012e184019013048b5389319475bd356582005de4bfecb8a415dcad727b8b685dee5016a0aa81d56b77fdd77cff0171e7151", + "outcome": "refuse", + "reason": "token_malformed", + "facts": null + }, + { + "name": "no_exp", + "description": "no exp claim (the TS's §5.4 requires it)", + "token": "d18443a10105a05820a5061a6ad55b2819012c0119012d0719012e184019013048b5389319475bd35658205fc6585ee57778c3964b7255325611c9639bd4d58c0891d9973632ef0099f104", + "outcome": "refuse", + "reason": "token_malformed", + "facts": null + }, + { + "name": "no_iat", + "description": "no iat claim", + "token": "d18443a10105a05820a5041a6ad6af0019012c0119012d0719012e184019013048b5389319475bd35658203af4080f1ca38819e954f55e20efa6b41388d48cdb97f9459d57ce8092150f30", + "outcome": "refuse", + "reason": "token_malformed", + "facts": null + }, + { + "name": "wmver_2", + "description": "wmver 2: the TS describes version 1", + "token": "d18443a10105a05826a6041a6ad6af00061a6ad55b2819012c0219012d0719012e184019013048b5389319475bd356582072bf28c9072ea183eedf306ca1dd4bdc74c35f3f805ece3d6bdfc2387bfb0116", + "outcome": "refuse", + "reason": "token_unsupported", + "facts": null + }, + { + "name": "wmvnd_too_large", + "description": "a wmvnd of 256, past uint .size 1", + "token": "d18443a10105a05828a6041a6ad6af00061a6ad55b2819012c0119012d19010019012e184019013048b5389319475bd3565820f0ec6bf774c3519ebe248626691bce4cec59055242b3104b898cebb0608b3e47", + "outcome": "refuse", + "reason": "token_malformed", + "facts": null + }, + { + "name": "pattern_short", + "description": "a clear pattern of 7 bytes for a wmpatlen of 64", + "token": "d18443a10105a05825a6041a6ad6af00061a6ad55b2819012c0119012d0719012e184019013047b5389319475bd35820d5a79cad6d75ca8fa65f9421a58dbdfde36f7cbf1148327bf6f87950560d5d22", + "outcome": "refuse", + "reason": "token_malformed", + "facts": null + }, + { + "name": "segduration_three", + "description": "a wmsegduration of three integers", + "token": "d18443a10105a0582da7041a6ad6af00061a6ad55b2819012c0119012d0719012e184019012f8306010119013048b5389319475bd35658204d059bd834e9bad7bc86a375aeccf501e7dc62a9824ef027818e49877d3f4f09", + "outcome": "refuse", + "reason": "token_malformed", + "facts": null + }, + { + "name": "sub_not_text", + "description": "a sub that is a byte string", + "token": "d18443a10105a05829a7024101041a6ad6af00061a6ad55b2819012c0119012d0719012e184019013048b5389319475bd35658201dd7b953239e73794ac4fc617462827d839147473ca90ca3689039fa8fbb334e", + "outcome": "refuse", + "reason": "token_malformed", + "facts": null + }, + { + "name": "indirect_without_wmkeyver", + "description": "indirect mode with no wmkeyver", + "token": "d18443a10105a0582ba7041a6ad6af00061a6ad55b2819012c0119012d0719012e18401901316973657373696f6e2d331901320c5820924fd428c6c07a3d437376313e8e5d7534456afcfddb38705d4326107c63230e", + "outcome": "refuse", + "reason": "token_malformed", + "facts": null + } + ], + "sessions": [ + { + "name": "lane_a_sessions", + "description": "two edges' records of four sessions of the broadcast room, one token in each mode, each token given", + "room": "broadcast", + "records": [ + { + "record": "575753310113475f3e0f8a3a34c5acf8f3abb87af5840eef8e35870a8942f3de9b3e16873bcf329a11544d57c236135f220f7b77b7ce5c2bcfe97015b7d83e075b81b789f3055f6d61696e02025981acfea37ea8230ebe1eefade34de5a45d8ff9e6fb8223f8c66b401c1d83c100879303000000000059dd6e00000000cde4c7b9d37b39beb7b35fccacd6a7b2513b98f3ba97ff983c31fe8cb28c5fcb07014000ffffffffffffffffb5389319475bd3562c01000002000000931b38de9f7a751915773f5671327171983165ca667cef73b346d2a59c278ae54445449fde6a0000000030d693a14bbfd363e4f0e90d9bac8b588b623414ee8d3d50cc233fcb53c0ba8276924e7dd3ba5bf3f9ce06e639d265aa59ce351e2eb59b61cbff12f10c5e6809", + "token": "d18443a10105a05826a6041a6ad6af00061a6ad55b2819012c0119012d0719012e184019013048b5389319475bd35658207ea9ace87c093dc8460a5b20be105fbfa73c6459980efc4496bcf2b44fe62e8e" + }, + { + "record": "575753310113475f3e0f8a3a34c5acf8f3abb87af5840eef8e35870a8942f3de9b3e16873bcf329a11544d57c236135f220f7b77b7ce5c2bcfe97015b7d83e075b81b789f3055f6d61696e02025981acfea37ea8230ebe1eefade34de5a45d8ff9e6fb8223f8c66b401c1d83c100a3e1110000000000b1081900000000fc859f73eef73fb7ccf561bdfea3e55f616449253538f906ff7ab8432af12dc507024000fc00000000003fffd40000000000241f1400000000000000d64ebb0bab393058b7a9934f1df5da0ff4d729b210f0a06c406150b8f531abdb4445a499de6a00000000b54d24968f10d15a7829c1cbaa0ab445c80ec75c5d77a52d9b1d3628476ac2bb24e1e859b9741b41ee23bb63126a25d827248009d7c7d9c224ab16f4ba51a90f", + "token": "d18443a10105a0584ba6041a6ad6af00061a6ad55b2819012c0119012d0719012e18401901308343a10101a1054c864eb089cbff4f4fdf3115d95818d9f84724aea43438845fc548b73aa5b9b5674aecee56f6f6582038d0243522b816c8b69787552116ed48d3c5925e5ec060d9821b906ad986d9a6" + }, + { + "record": "575753310113475f3e0f8a3a34c5acf8f3abb87af5840eef8e35870a8942f3de9b3e16873b0d4b3cdae998fa7ade7d7600d29db16f397e14dd56c2864263fb1144b6cd03d5055f6d61696e02025981acfea37ea8230ebe1eefade34de5a45d8ff9e6fb8223f8c66b401c1d83c10046c3230000000000180d8f000000001f53ed7edd447726ed72961b854ae198652d02bbe37035e420987b4c857146dd07034000ffffffffffffffff5120f915333929682c01000000000000b641fb80943018dd6f3620f7082aad9f4ab4325131a267106f153b939b2d58c9465260a1de6a000000007c47c1d9b4f57c7e8d87e7e1ac7137e1631ef6095a91052b9890ac32b19b19a354383c8bbf062205ab149adf8c76f17d92cadf9d01cfbe3bf05b4c047f13d80c", + "token": "d18443a10105a0582fa8041a6ad6af00061a6ad55b2819012c0119012d0719012e18401901316973657373696f6e2d331901320c1901330258207a583e7258827b52bba41cd3eab55102283a43ad000386391e6a42e96e228186" + }, + { + "record": "575753310113475f3e0f8a3a34c5acf8f3abb87af5840eef8e35870a8942f3de9b3e16873b0d4b3cdae998fa7ade7d7600d29db16f397e14dd56c2864263fb1144b6cd03d5055f6d61696e02025981acfea37ea8230ebe1eefade34de5a45d8ff9e6fb8223f8c66b401c1d83c100180d8f00000000005ed0b20000000091fdbac2fe383e9153d13b0820e28d58f4dee0f9bacfaefacc67b28f8e1cf12a07014000ffffffffffffffff6bd0c1f00bd39dfd64000000000000006561878740fec7eb63a6758df07cedebcda05bb997a66797da86db89e2b5376a4652b8a3de6a000000002ba40624ece7170f6500343895606e2a029afa2d1871dc8581a2b15abd318a6b94be0e83dc0ad4706e39e11a661c86cdd592c1fb2d4d06061693754c27a43002", + "token": "d18443a10105a05826a6041a6ad6af00061a6ad55b2819012c0119012d0719012e1840190130486bd0c1f00bd39dfd5820522ed1eb21a8ce3435509963eafc7a3e4a56521ec66076ef3363bb9319e63829" + } + ], + "outcome": "accept", + "reason": null, + "at": null, + "sessions": [ + { + "digest": "6abc05ea764183518560731925ec2d262afccc34df86799f8f75467475d1889c", + "edge": "cf329a11544d57c236135f220f7b77b7ce5c2bcfe97015b7d83e075b81b789f3", + "owner": "_main", + "media": 2, + "kids": [ + "5981acfea37ea8230ebe1eefade34de5", + "a45d8ff9e6fb8223f8c66b401c1d83c1" + ], + "start": 60000000, + "end": 1860000000, + "vendor": 7, + "source": 1, + "patlen": 64, + "served": 64, + "segments": 300, + "unmarked": 2, + "region": "DE", + "expires": 1792974660, + "token": { + "cwt_tag": false, + "alg": 5, + "vendor": 7, + "patlen": 64, + "source": 1, + "pattern": "b5389319475bd356", + "iat": 1792367400, + "exp": 1792454400, + "nbf": null + } + }, + { + "digest": "969a41fe9f713fc9c787368b4942929b74a035bc2af75eeca2b7440c4465c29d", + "edge": "cf329a11544d57c236135f220f7b77b7ce5c2bcfe97015b7d83e075b81b789f3", + "owner": "_main", + "media": 2, + "kids": [ + "5981acfea37ea8230ebe1eefade34de5", + "a45d8ff9e6fb8223f8c66b401c1d83c1" + ], + "start": 300000000, + "end": 420000000, + "vendor": 7, + "source": 2, + "patlen": 64, + "served": 20, + "segments": 20, + "unmarked": 0, + "region": "DE", + "expires": 1792973220, + "token": { + "cwt_tag": false, + "alg": 5, + "vendor": 7, + "patlen": 64, + "source": 2, + "pattern": null, + "iat": 1792367400, + "exp": 1792454400, + "nbf": null + } + }, + { + "digest": "e5cc27e9ddf93cc74793dd5d51c4df3ce3fecccbdec0392bac6c487ca72d9dec", + "edge": "0d4b3cdae998fa7ade7d7600d29db16f397e14dd56c2864263fb1144b6cd03d5", + "owner": "_main", + "media": 2, + "kids": [ + "5981acfea37ea8230ebe1eefade34de5", + "a45d8ff9e6fb8223f8c66b401c1d83c1" + ], + "start": 600000000, + "end": 2400000000, + "vendor": 7, + "source": 3, + "patlen": 64, + "served": 64, + "segments": 300, + "unmarked": 0, + "region": "FR", + "expires": 1792975200, + "token": { + "cwt_tag": false, + "alg": 5, + "vendor": 7, + "patlen": 64, + "source": 3, + "pattern": null, + "iat": 1792367400, + "exp": 1792454400, + "nbf": null + } + }, + { + "digest": "036530e46019595c1cf23a54eff4ce24c1869ed19f0f70e10f607ac2ec2f804a", + "edge": "0d4b3cdae998fa7ade7d7600d29db16f397e14dd56c2864263fb1144b6cd03d5", + "owner": "_main", + "media": 2, + "kids": [ + "5981acfea37ea8230ebe1eefade34de5", + "a45d8ff9e6fb8223f8c66b401c1d83c1" + ], + "start": 2400000000, + "end": 3000000000, + "vendor": 7, + "source": 1, + "patlen": 64, + "served": 64, + "segments": 100, + "unmarked": 0, + "region": "FR", + "expires": 1792975800, + "token": { + "cwt_tag": false, + "alg": 5, + "vendor": 7, + "patlen": 64, + "source": 1, + "pattern": "6bd0c1f00bd39dfd", + "iat": 1792367400, + "exp": 1792454400, + "nbf": null + } + } + ] + }, + { + "name": "records_without_tokens", + "description": "the same records as a matching party holds them, no token given", + "room": "broadcast", + "records": [ + { + "record": "575753310113475f3e0f8a3a34c5acf8f3abb87af5840eef8e35870a8942f3de9b3e16873bcf329a11544d57c236135f220f7b77b7ce5c2bcfe97015b7d83e075b81b789f3055f6d61696e02025981acfea37ea8230ebe1eefade34de5a45d8ff9e6fb8223f8c66b401c1d83c100879303000000000059dd6e00000000cde4c7b9d37b39beb7b35fccacd6a7b2513b98f3ba97ff983c31fe8cb28c5fcb07014000ffffffffffffffffb5389319475bd3562c01000002000000931b38de9f7a751915773f5671327171983165ca667cef73b346d2a59c278ae54445449fde6a0000000030d693a14bbfd363e4f0e90d9bac8b588b623414ee8d3d50cc233fcb53c0ba8276924e7dd3ba5bf3f9ce06e639d265aa59ce351e2eb59b61cbff12f10c5e6809", + "token": null + }, + { + "record": "575753310113475f3e0f8a3a34c5acf8f3abb87af5840eef8e35870a8942f3de9b3e16873bcf329a11544d57c236135f220f7b77b7ce5c2bcfe97015b7d83e075b81b789f3055f6d61696e02025981acfea37ea8230ebe1eefade34de5a45d8ff9e6fb8223f8c66b401c1d83c100a3e1110000000000b1081900000000fc859f73eef73fb7ccf561bdfea3e55f616449253538f906ff7ab8432af12dc507024000fc00000000003fffd40000000000241f1400000000000000d64ebb0bab393058b7a9934f1df5da0ff4d729b210f0a06c406150b8f531abdb4445a499de6a00000000b54d24968f10d15a7829c1cbaa0ab445c80ec75c5d77a52d9b1d3628476ac2bb24e1e859b9741b41ee23bb63126a25d827248009d7c7d9c224ab16f4ba51a90f", + "token": null + }, + { + "record": "575753310113475f3e0f8a3a34c5acf8f3abb87af5840eef8e35870a8942f3de9b3e16873b0d4b3cdae998fa7ade7d7600d29db16f397e14dd56c2864263fb1144b6cd03d5055f6d61696e02025981acfea37ea8230ebe1eefade34de5a45d8ff9e6fb8223f8c66b401c1d83c10046c3230000000000180d8f000000001f53ed7edd447726ed72961b854ae198652d02bbe37035e420987b4c857146dd07034000ffffffffffffffff5120f915333929682c01000000000000b641fb80943018dd6f3620f7082aad9f4ab4325131a267106f153b939b2d58c9465260a1de6a000000007c47c1d9b4f57c7e8d87e7e1ac7137e1631ef6095a91052b9890ac32b19b19a354383c8bbf062205ab149adf8c76f17d92cadf9d01cfbe3bf05b4c047f13d80c", + "token": null + }, + { + "record": "575753310113475f3e0f8a3a34c5acf8f3abb87af5840eef8e35870a8942f3de9b3e16873b0d4b3cdae998fa7ade7d7600d29db16f397e14dd56c2864263fb1144b6cd03d5055f6d61696e02025981acfea37ea8230ebe1eefade34de5a45d8ff9e6fb8223f8c66b401c1d83c100180d8f00000000005ed0b20000000091fdbac2fe383e9153d13b0820e28d58f4dee0f9bacfaefacc67b28f8e1cf12a07014000ffffffffffffffff6bd0c1f00bd39dfd64000000000000006561878740fec7eb63a6758df07cedebcda05bb997a66797da86db89e2b5376a4652b8a3de6a000000002ba40624ece7170f6500343895606e2a029afa2d1871dc8581a2b15abd318a6b94be0e83dc0ad4706e39e11a661c86cdd592c1fb2d4d06061693754c27a43002", + "token": null + } + ], + "outcome": "accept", + "reason": null, + "at": null, + "sessions": [ + { + "digest": "6abc05ea764183518560731925ec2d262afccc34df86799f8f75467475d1889c", + "edge": "cf329a11544d57c236135f220f7b77b7ce5c2bcfe97015b7d83e075b81b789f3", + "owner": "_main", + "media": 2, + "kids": [ + "5981acfea37ea8230ebe1eefade34de5", + "a45d8ff9e6fb8223f8c66b401c1d83c1" + ], + "start": 60000000, + "end": 1860000000, + "vendor": 7, + "source": 1, + "patlen": 64, + "served": 64, + "segments": 300, + "unmarked": 2, + "region": "DE", + "expires": 1792974660, + "token": null + }, + { + "digest": "969a41fe9f713fc9c787368b4942929b74a035bc2af75eeca2b7440c4465c29d", + "edge": "cf329a11544d57c236135f220f7b77b7ce5c2bcfe97015b7d83e075b81b789f3", + "owner": "_main", + "media": 2, + "kids": [ + "5981acfea37ea8230ebe1eefade34de5", + "a45d8ff9e6fb8223f8c66b401c1d83c1" + ], + "start": 300000000, + "end": 420000000, + "vendor": 7, + "source": 2, + "patlen": 64, + "served": 20, + "segments": 20, + "unmarked": 0, + "region": "DE", + "expires": 1792973220, + "token": null + }, + { + "digest": "e5cc27e9ddf93cc74793dd5d51c4df3ce3fecccbdec0392bac6c487ca72d9dec", + "edge": "0d4b3cdae998fa7ade7d7600d29db16f397e14dd56c2864263fb1144b6cd03d5", + "owner": "_main", + "media": 2, + "kids": [ + "5981acfea37ea8230ebe1eefade34de5", + "a45d8ff9e6fb8223f8c66b401c1d83c1" + ], + "start": 600000000, + "end": 2400000000, + "vendor": 7, + "source": 3, + "patlen": 64, + "served": 64, + "segments": 300, + "unmarked": 0, + "region": "FR", + "expires": 1792975200, + "token": null + }, + { + "digest": "036530e46019595c1cf23a54eff4ce24c1869ed19f0f70e10f607ac2ec2f804a", + "edge": "0d4b3cdae998fa7ade7d7600d29db16f397e14dd56c2864263fb1144b6cd03d5", + "owner": "_main", + "media": 2, + "kids": [ + "5981acfea37ea8230ebe1eefade34de5", + "a45d8ff9e6fb8223f8c66b401c1d83c1" + ], + "start": 2400000000, + "end": 3000000000, + "vendor": 7, + "source": 1, + "patlen": 64, + "served": 64, + "segments": 100, + "unmarked": 0, + "region": "FR", + "expires": 1792975800, + "token": null + } + ] + }, + { + "name": "keyless_clock_is_edge", + "description": "a room with no origin whose clock is the edge that signs", + "room": "keyless", + "records": [ + { + "record": "575753310190b72587ed37038aa7d3e07747d1c9faf8c9791c3f38882f4ec2149bd7067bedcf329a11544d57c236135f220f7b77b7ce5c2bcfe97015b7d83e075b81b789f3055f6d61696e02025981acfea37ea8230ebe1eefade34de5a45d8ff9e6fb8223f8c66b401c1d83c100879303000000000059dd6e00000000cde4c7b9d37b39beb7b35fccacd6a7b2513b98f3ba97ff983c31fe8cb28c5fcb07014000ffffffffffffffffb5389319475bd3562c0100000200000027eadc5d5eb541b622663eb51c661946720736120b927e9ec3154526da046a8c4445449fde6a000000007d0a9c837fe9c8a7af270b6fa2ab257a34ca06278dd853670e17bf3aa10c427f3a956f196c09eb75b32b42e66f935d9f527ad12d2f6a24afa77393f68caf1c04", + "token": "d18443a10105a05826a6041a6ad6af00061a6ad55b2819012c0119012d0719012e184019013048b5389319475bd35658207ea9ace87c093dc8460a5b20be105fbfa73c6459980efc4496bcf2b44fe62e8e" + } + ], + "outcome": "accept", + "reason": null, + "at": null, + "sessions": [ + { + "digest": "c7054cbcc42b7e66bbbc9193f38316238275b460e53ecca2afc3b94387b6252b", + "edge": "cf329a11544d57c236135f220f7b77b7ce5c2bcfe97015b7d83e075b81b789f3", + "owner": "_main", + "media": 2, + "kids": [ + "5981acfea37ea8230ebe1eefade34de5", + "a45d8ff9e6fb8223f8c66b401c1d83c1" + ], + "start": 60000000, + "end": 1860000000, + "vendor": 7, + "source": 1, + "patlen": 64, + "served": 64, + "segments": 300, + "unmarked": 2, + "region": "DE", + "expires": 1792974660, + "token": { + "cwt_tag": false, + "alg": 5, + "vendor": 7, + "patlen": 64, + "source": 1, + "pattern": "b5389319475bd356", + "iat": 1792367400, + "exp": 1792454400, + "nbf": null + } + } + ] + }, + { + "name": "record_truncated", + "description": "session 1's record without its last byte", + "room": "broadcast", + "records": [ + { + "record": "575753310113475f3e0f8a3a34c5acf8f3abb87af5840eef8e35870a8942f3de9b3e16873bcf329a11544d57c236135f220f7b77b7ce5c2bcfe97015b7d83e075b81b789f3055f6d61696e02025981acfea37ea8230ebe1eefade34de5a45d8ff9e6fb8223f8c66b401c1d83c100879303000000000059dd6e00000000cde4c7b9d37b39beb7b35fccacd6a7b2513b98f3ba97ff983c31fe8cb28c5fcb07014000ffffffffffffffffb5389319475bd3562c01000002000000931b38de9f7a751915773f5671327171983165ca667cef73b346d2a59c278ae54445449fde6a0000000030d693a14bbfd363e4f0e90d9bac8b588b623414ee8d3d50cc233fcb53c0ba8276924e7dd3ba5bf3f9ce06e639d265aa59ce351e2eb59b61cbff12f10c5e68", + "token": null + } + ], + "outcome": "refuse", + "reason": "truncated", + "at": { + "place": "session", + "index": 0 + }, + "sessions": null + }, + { + "name": "record_trailing", + "description": "session 1's record followed by one byte", + "room": "broadcast", + "records": [ + { + "record": "575753310113475f3e0f8a3a34c5acf8f3abb87af5840eef8e35870a8942f3de9b3e16873bcf329a11544d57c236135f220f7b77b7ce5c2bcfe97015b7d83e075b81b789f3055f6d61696e02025981acfea37ea8230ebe1eefade34de5a45d8ff9e6fb8223f8c66b401c1d83c100879303000000000059dd6e00000000cde4c7b9d37b39beb7b35fccacd6a7b2513b98f3ba97ff983c31fe8cb28c5fcb07014000ffffffffffffffffb5389319475bd3562c01000002000000931b38de9f7a751915773f5671327171983165ca667cef73b346d2a59c278ae54445449fde6a0000000030d693a14bbfd363e4f0e90d9bac8b588b623414ee8d3d50cc233fcb53c0ba8276924e7dd3ba5bf3f9ce06e639d265aa59ce351e2eb59b61cbff12f10c5e680900", + "token": null + } + ], + "outcome": "refuse", + "reason": "malformed", + "at": { + "place": "session", + "index": 0 + }, + "sessions": null + }, + { + "name": "record_version_2", + "description": "a record of version 2", + "room": "broadcast", + "records": [ + { + "record": "575753310213475f3e0f8a3a34c5acf8f3abb87af5840eef8e35870a8942f3de9b3e16873bcf329a11544d57c236135f220f7b77b7ce5c2bcfe97015b7d83e075b81b789f3055f6d61696e02025981acfea37ea8230ebe1eefade34de5a45d8ff9e6fb8223f8c66b401c1d83c100879303000000000059dd6e00000000cde4c7b9d37b39beb7b35fccacd6a7b2513b98f3ba97ff983c31fe8cb28c5fcb07014000ffffffffffffffffb5389319475bd3562c01000002000000931b38de9f7a751915773f5671327171983165ca667cef73b346d2a59c278ae54445449fde6a0000000030d693a14bbfd363e4f0e90d9bac8b588b623414ee8d3d50cc233fcb53c0ba8276924e7dd3ba5bf3f9ce06e639d265aa59ce351e2eb59b61cbff12f10c5e6809", + "token": null + } + ], + "outcome": "refuse", + "reason": "unknown_version", + "at": { + "place": "session", + "index": 0 + }, + "sessions": null + }, + { + "name": "bits_not_served", + "description": "a record whose bits set a position its served does not (§5.5)", + "room": "broadcast", + "records": [ + { + "record": "575753310113475f3e0f8a3a34c5acf8f3abb87af5840eef8e35870a8942f3de9b3e16873bcf329a11544d57c236135f220f7b77b7ce5c2bcfe97015b7d83e075b81b789f3055f6d61696e02025981acfea37ea8230ebe1eefade34de5a45d8ff9e6fb8223f8c66b401c1d83c100879303000000000059dd6e00000000cde4c7b9d37b39beb7b35fccacd6a7b2513b98f3ba97ff983c31fe8cb28c5fcb070140007fffffffffffffffb5389319475bd3562c01000002000000931b38de9f7a751915773f5671327171983165ca667cef73b346d2a59c278ae54445449fde6a00000000df48408d41cc5adb97e9d3cd0687f193a2013966c83f0a66640f9a19c4c3d618cb6d6defd11da2792684e4a460b8a7159af67802e15aa704a76a1e1971a97401", + "token": null + } + ], + "outcome": "refuse", + "reason": "record_invalid", + "at": { + "place": "session", + "index": 0 + }, + "sessions": null + }, + { + "name": "wrong_edge_key", + "description": "session 1's record, naming the first edge, signed by the second edge's key", + "room": "broadcast", + "records": [ + { + "record": "575753310113475f3e0f8a3a34c5acf8f3abb87af5840eef8e35870a8942f3de9b3e16873bcf329a11544d57c236135f220f7b77b7ce5c2bcfe97015b7d83e075b81b789f3055f6d61696e02025981acfea37ea8230ebe1eefade34de5a45d8ff9e6fb8223f8c66b401c1d83c100879303000000000059dd6e00000000cde4c7b9d37b39beb7b35fccacd6a7b2513b98f3ba97ff983c31fe8cb28c5fcb07014000ffffffffffffffffb5389319475bd3562c01000002000000931b38de9f7a751915773f5671327171983165ca667cef73b346d2a59c278ae54445449fde6a00000000bc77770488b558717c0f736dda1249cd538a5ddbee86e5736592725edf11e9e09f858abab74d6ee28e6adf99cfafbf046d168d3797ba7339bf566ad13edbd10f", + "token": null + } + ], + "outcome": "refuse", + "reason": "bad_signature", + "at": { + "place": "session", + "index": 0 + }, + "sessions": null + }, + { + "name": "record_of_another_room", + "description": "a record naming another room", + "room": "broadcast", + "records": [ + { + "record": "575753310190b72587ed37038aa7d3e07747d1c9faf8c9791c3f38882f4ec2149bd7067bedcf329a11544d57c236135f220f7b77b7ce5c2bcfe97015b7d83e075b81b789f3055f6d61696e02025981acfea37ea8230ebe1eefade34de5a45d8ff9e6fb8223f8c66b401c1d83c100879303000000000059dd6e00000000cde4c7b9d37b39beb7b35fccacd6a7b2513b98f3ba97ff983c31fe8cb28c5fcb07014000ffffffffffffffffb5389319475bd3562c01000002000000931b38de9f7a751915773f5671327171983165ca667cef73b346d2a59c278ae54445449fde6a00000000ffb9f36d2267b1eefbf5e254af11de8a2f38877fc694a0060453501510a27cea3a71fcd23e62969fa17ee244eaca372ca46a70ce0f4bd4e894120d49592b0703", + "token": null + } + ], + "outcome": "refuse", + "reason": "wrong_room", + "at": { + "place": "session", + "index": 0 + }, + "sessions": null + }, + { + "name": "edge_without_authority", + "description": "a record signed by a key the log lists with live/relay and not live/watermark", + "room": "broadcast", + "records": [ + { + "record": "575753310113475f3e0f8a3a34c5acf8f3abb87af5840eef8e35870a8942f3de9b3e16873b91e5002715c3f826f41a430ad982e181fff91fe6949b1a4f834bb5e9803e2b13055f6d61696e02025981acfea37ea8230ebe1eefade34de5a45d8ff9e6fb8223f8c66b401c1d83c100879303000000000059dd6e00000000cde4c7b9d37b39beb7b35fccacd6a7b2513b98f3ba97ff983c31fe8cb28c5fcb07014000ffffffffffffffffb5389319475bd3562c01000002000000931b38de9f7a751915773f5671327171983165ca667cef73b346d2a59c278ae54445449fde6a000000002ec1fed1782b67872fd718dc8398e08d2399baf40eb809e2833f84f8acd8a372683d2f78136ad7470d4df37a1f423c7767aa70852a056e94450585cb95125e09", + "token": null + } + ], + "outcome": "refuse", + "reason": "edge_not_authorised", + "at": { + "place": "session", + "index": 0 + }, + "sessions": null + }, + { + "name": "edge_not_listed", + "description": "a record signed by a key the log does not list", + "room": "broadcast", + "records": [ + { + "record": "575753310113475f3e0f8a3a34c5acf8f3abb87af5840eef8e35870a8942f3de9b3e16873bd510288a80239539921f8dc18ccca7ea45b168e11b10ed5d89e68d927685b7dd055f6d61696e02025981acfea37ea8230ebe1eefade34de5a45d8ff9e6fb8223f8c66b401c1d83c100879303000000000059dd6e00000000cde4c7b9d37b39beb7b35fccacd6a7b2513b98f3ba97ff983c31fe8cb28c5fcb07014000ffffffffffffffffb5389319475bd3562c01000002000000931b38de9f7a751915773f5671327171983165ca667cef73b346d2a59c278ae54445449fde6a00000000f3dbc7f8b86cc82315f93c9f2ae883f3c7f2f348eb46e25b5c81b790013142922941b1440a9133c3f16dcb352ff0f72ba02eaa4f295e59d7c0a1a4677d535c05", + "token": null + } + ], + "outcome": "refuse", + "reason": "edge_not_authorised", + "at": { + "place": "session", + "index": 0 + }, + "sessions": null + }, + { + "name": "edge_revoked", + "description": "a record signed by an edge the log revokes", + "room": "broadcast", + "records": [ + { + "record": "575753310113475f3e0f8a3a34c5acf8f3abb87af5840eef8e35870a8942f3de9b3e16873ba5a1c56272720c05d58a48e57913c316d448bbd6a50b87a11ce67e05b37e5597055f6d61696e02025981acfea37ea8230ebe1eefade34de5a45d8ff9e6fb8223f8c66b401c1d83c100879303000000000059dd6e00000000cde4c7b9d37b39beb7b35fccacd6a7b2513b98f3ba97ff983c31fe8cb28c5fcb07014000ffffffffffffffffb5389319475bd3562c01000002000000931b38de9f7a751915773f5671327171983165ca667cef73b346d2a59c278ae54445449fde6a0000000031a5bb836bcadec3cd4353a0c53edffc50c90c9c178b8013101df8c0dc92109ba5458c871f821fa451fec439530211d75bcb384d96931d10cf23b797a39c7a0c", + "token": null + } + ], + "outcome": "refuse", + "reason": "edge_revoked", + "at": { + "place": "session", + "index": 0 + }, + "sessions": null + }, + { + "name": "keyless_edge_not_room_key", + "description": "the room with no origin, its record signed by a key that is not a room key", + "room": "keyless", + "records": [ + { + "record": "575753310190b72587ed37038aa7d3e07747d1c9faf8c9791c3f38882f4ec2149bd7067bed0d4b3cdae998fa7ade7d7600d29db16f397e14dd56c2864263fb1144b6cd03d5055f6d61696e02025981acfea37ea8230ebe1eefade34de5a45d8ff9e6fb8223f8c66b401c1d83c100879303000000000059dd6e00000000cde4c7b9d37b39beb7b35fccacd6a7b2513b98f3ba97ff983c31fe8cb28c5fcb07014000ffffffffffffffffb5389319475bd3562c0100000200000027eadc5d5eb541b622663eb51c661946720736120b927e9ec3154526da046a8c4445449fde6a000000007363b984c59f2157b0e3a5add922dc579ae6ce49c3ed1b3d13d35674f76afbf08bb36b3ee80ff247e2fb318864e6fef5740edf5eb77ef88303c8d2461633e500", + "token": null + } + ], + "outcome": "refuse", + "reason": "edge_not_authorised", + "at": { + "place": "session", + "index": 0 + }, + "sessions": null + }, + { + "name": "content_unknown_slot", + "description": "a record whose content is a slot the descriptor does not declare", + "room": "broadcast", + "records": [ + { + "record": "575753310113475f3e0f8a3a34c5acf8f3abb87af5840eef8e35870a8942f3de9b3e16873bcf329a11544d57c236135f220f7b77b7ce5c2bcfe97015b7d83e075b81b789f3075f6261636b757002025981acfea37ea8230ebe1eefade34de5a45d8ff9e6fb8223f8c66b401c1d83c100879303000000000059dd6e00000000cde4c7b9d37b39beb7b35fccacd6a7b2513b98f3ba97ff983c31fe8cb28c5fcb07014000ffffffffffffffffb5389319475bd3562c01000002000000931b38de9f7a751915773f5671327171983165ca667cef73b346d2a59c278ae54445449fde6a000000007e28cde1674ea80484c3e7706acc0c6858697f413bd288c51c5291d89c687fd6bc1cafb296f288d43839a41860a19034080f00bba4a9e25df326a3ed03fe0c07", + "token": null + } + ], + "outcome": "refuse", + "reason": "content_unknown", + "at": { + "place": "session", + "index": 0 + }, + "sessions": null + }, + { + "name": "content_wrong_media", + "description": "a record naming the video slot as audio", + "room": "broadcast", + "records": [ + { + "record": "575753310113475f3e0f8a3a34c5acf8f3abb87af5840eef8e35870a8942f3de9b3e16873bcf329a11544d57c236135f220f7b77b7ce5c2bcfe97015b7d83e075b81b789f3055f6d61696e01025981acfea37ea8230ebe1eefade34de5a45d8ff9e6fb8223f8c66b401c1d83c100879303000000000059dd6e00000000cde4c7b9d37b39beb7b35fccacd6a7b2513b98f3ba97ff983c31fe8cb28c5fcb07014000ffffffffffffffffb5389319475bd3562c01000002000000931b38de9f7a751915773f5671327171983165ca667cef73b346d2a59c278ae54445449fde6a00000000b2c6bfa9a62d55ad9f6e674a1df864ea7a865d07c07c58ee17ab3847a7dc2d9eadebc6998aa6076e7df9f85cee18957182a6f2cda14b8bf85fa5e43c506d8d0f", + "token": null + } + ], + "outcome": "refuse", + "reason": "content_unknown", + "at": { + "place": "session", + "index": 0 + }, + "sessions": null + }, + { + "name": "not_protected", + "description": "a record that names no content key: not a Lane A delivery (§3.1)", + "room": "broadcast", + "records": [ + { + "record": "575753310113475f3e0f8a3a34c5acf8f3abb87af5840eef8e35870a8942f3de9b3e16873bcf329a11544d57c236135f220f7b77b7ce5c2bcfe97015b7d83e075b81b789f3055f6d61696e020000879303000000000059dd6e00000000cde4c7b9d37b39beb7b35fccacd6a7b2513b98f3ba97ff983c31fe8cb28c5fcb07014000ffffffffffffffffb5389319475bd3562c01000002000000931b38de9f7a751915773f5671327171983165ca667cef73b346d2a59c278ae54445449fde6a0000000001a8d6b5e43f9ce2da9279325f4ce35ab70ba148878be1e0e0384fac9480baad0f5f2b8cac25539fd3dcb6cf3051f25952ac7dca1e534cfed3eeb947d5d34f0d", + "token": null + } + ], + "outcome": "refuse", + "reason": "not_protected", + "at": { + "place": "session", + "index": 0 + }, + "sessions": null + }, + { + "name": "region_not_pinned", + "description": "a record kept in a region outside the room's pin (DE, FR)", + "room": "broadcast", + "records": [ + { + "record": "575753310113475f3e0f8a3a34c5acf8f3abb87af5840eef8e35870a8942f3de9b3e16873bcf329a11544d57c236135f220f7b77b7ce5c2bcfe97015b7d83e075b81b789f3055f6d61696e02025981acfea37ea8230ebe1eefade34de5a45d8ff9e6fb8223f8c66b401c1d83c100879303000000000059dd6e00000000cde4c7b9d37b39beb7b35fccacd6a7b2513b98f3ba97ff983c31fe8cb28c5fcb07014000ffffffffffffffffb5389319475bd3562c01000002000000931b38de9f7a751915773f5671327171983165ca667cef73b346d2a59c278ae55553449fde6a0000000039065b7f3b7644184d5919fe08a05a15d1e07534d78c350174ad64ac902aa09e387368140e04da98ccf4cb0bdefdca88bf5acab362a391f9008643d33d8bd106", + "token": null + } + ], + "outcome": "refuse", + "reason": "region_not_pinned", + "at": { + "place": "session", + "index": 0 + }, + "sessions": null + }, + { + "name": "retention_exceeded", + "description": "a record kept 31 days past the session, the room's bound 30", + "room": "broadcast", + "records": [ + { + "record": "575753310113475f3e0f8a3a34c5acf8f3abb87af5840eef8e35870a8942f3de9b3e16873bcf329a11544d57c236135f220f7b77b7ce5c2bcfe97015b7d83e075b81b789f3055f6d61696e02025981acfea37ea8230ebe1eefade34de5a45d8ff9e6fb8223f8c66b401c1d83c100879303000000000059dd6e00000000cde4c7b9d37b39beb7b35fccacd6a7b2513b98f3ba97ff983c31fe8cb28c5fcb07014000ffffffffffffffffb5389319475bd3562c01000002000000931b38de9f7a751915773f5671327171983165ca667cef73b346d2a59c278ae544454443fe6a00000000b6c3da1649859c279b6e620fba30c00a8a972bf959df5b55921d1d6b4055f8fd78519bdfb4594ac674a3ddce5d4111f68b2df6b5db4eb1bbc7a76d88d7e91703", + "token": null + } + ], + "outcome": "refuse", + "reason": "retention_exceeded", + "at": { + "place": "session", + "index": 0 + }, + "sessions": null + }, + { + "name": "expires_before_end", + "description": "a record that expires before its session ends (§6.1)", + "room": "broadcast", + "records": [ + { + "record": "575753310113475f3e0f8a3a34c5acf8f3abb87af5840eef8e35870a8942f3de9b3e16873bcf329a11544d57c236135f220f7b77b7ce5c2bcfe97015b7d83e075b81b789f3055f6d61696e02025981acfea37ea8230ebe1eefade34de5a45d8ff9e6fb8223f8c66b401c1d83c100879303000000000059dd6e00000000cde4c7b9d37b39beb7b35fccacd6a7b2513b98f3ba97ff983c31fe8cb28c5fcb07014000ffffffffffffffffb5389319475bd3562c01000002000000931b38de9f7a751915773f5671327171983165ca667cef73b346d2a59c278ae54445805dd56a00000000df0604aab97d0956e60251a1d5294dad9d2335c3ed79bbeefebdd0f15adbd0f27eac6773c4e571ac0fe8fc7ad1d24c3ac468eb9c53d47947b419871e36179c07", + "token": null + } + ], + "outcome": "refuse", + "reason": "record_invalid", + "at": { + "place": "session", + "index": 0 + }, + "sessions": null + }, + { + "name": "token_reused", + "description": "two records naming one token", + "room": "broadcast", + "records": [ + { + "record": "575753310113475f3e0f8a3a34c5acf8f3abb87af5840eef8e35870a8942f3de9b3e16873bcf329a11544d57c236135f220f7b77b7ce5c2bcfe97015b7d83e075b81b789f3055f6d61696e02025981acfea37ea8230ebe1eefade34de5a45d8ff9e6fb8223f8c66b401c1d83c100879303000000000059dd6e00000000cde4c7b9d37b39beb7b35fccacd6a7b2513b98f3ba97ff983c31fe8cb28c5fcb07014000ffffffffffffffffb5389319475bd3562c01000002000000931b38de9f7a751915773f5671327171983165ca667cef73b346d2a59c278ae54445449fde6a0000000030d693a14bbfd363e4f0e90d9bac8b588b623414ee8d3d50cc233fcb53c0ba8276924e7dd3ba5bf3f9ce06e639d265aa59ce351e2eb59b61cbff12f10c5e6809", + "token": null + }, + { + "record": "575753310113475f3e0f8a3a34c5acf8f3abb87af5840eef8e35870a8942f3de9b3e16873b0d4b3cdae998fa7ade7d7600d29db16f397e14dd56c2864263fb1144b6cd03d5055f6d61696e02025981acfea37ea8230ebe1eefade34de5a45d8ff9e6fb8223f8c66b401c1d83c100180d8f00000000005ed0b200000000cde4c7b9d37b39beb7b35fccacd6a7b2513b98f3ba97ff983c31fe8cb28c5fcb07014000ffffffffffffffff6bd0c1f00bd39dfd64000000000000006561878740fec7eb63a6758df07cedebcda05bb997a66797da86db89e2b5376a4652b8a3de6a00000000573acdfbac598b03604c12117ae505d9033cbb24d5f3c27065945dfdb33dd26002677cc315af6bd8bf54e39161ab05d44e7349ebeede894474b1486d7bd9a800", + "token": null + } + ], + "outcome": "refuse", + "reason": "token_reused", + "at": { + "place": "session", + "index": 1 + }, + "sessions": null + }, + { + "name": "token_does_not_decode", + "description": "a record whose token is cut short: it is not one CBOR item", + "room": "broadcast", + "records": [ + { + "record": "575753310113475f3e0f8a3a34c5acf8f3abb87af5840eef8e35870a8942f3de9b3e16873bcf329a11544d57c236135f220f7b77b7ce5c2bcfe97015b7d83e075b81b789f3055f6d61696e02025981acfea37ea8230ebe1eefade34de5a45d8ff9e6fb8223f8c66b401c1d83c100879303000000000059dd6e000000002805f24f83109b7c6e9b32f8004cd1b24e3d3e9e24bac675f5229d7ca34740fa07014000ffffffffffffffffb5389319475bd3562c01000002000000931b38de9f7a751915773f5671327171983165ca667cef73b346d2a59c278ae54445449fde6a000000007a291e878483ad8289c5545a1efa30316f41350f20caa47191132a83bcf5ce53edad51d7cbab3d6cb0995df165cdb0d39b28d2935380b5af72c9c77966a09704", + "token": "d18443a101" + } + ], + "outcome": "refuse", + "reason": "token_malformed", + "at": { + "place": "session", + "index": 0 + }, + "sessions": null + }, + { + "name": "token_version_2", + "description": "a record whose token is of wmver 2", + "room": "broadcast", + "records": [ + { + "record": "575753310113475f3e0f8a3a34c5acf8f3abb87af5840eef8e35870a8942f3de9b3e16873bcf329a11544d57c236135f220f7b77b7ce5c2bcfe97015b7d83e075b81b789f3055f6d61696e02025981acfea37ea8230ebe1eefade34de5a45d8ff9e6fb8223f8c66b401c1d83c100879303000000000059dd6e00000000e5e47efafdd0aa4cb612d98b67cf9a2a1936e49d7f85ab4dbb2303aca387b76c07014000ffffffffffffffffb5389319475bd3562c01000002000000931b38de9f7a751915773f5671327171983165ca667cef73b346d2a59c278ae54445449fde6a00000000eef3d9a59f0ba8d05e15074281b0e1ca6a6b7b54987b263af3caffa3212a44445caf1102fba64feebe2361fee59504ca2f98bd438015636db121babaf5ffdd0b", + "token": "d18443a10105a05826a6041a6ad6af00061a6ad55b2819012c0219012d0719012e184019013048b5389319475bd356582072bf28c9072ea183eedf306ca1dd4bdc74c35f3f805ece3d6bdfc2387bfb0116" + } + ], + "outcome": "refuse", + "reason": "token_unsupported", + "at": { + "place": "session", + "index": 0 + }, + "sessions": null + }, + { + "name": "token_not_the_records", + "description": "session 1's record given session 3's token", + "room": "broadcast", + "records": [ + { + "record": "575753310113475f3e0f8a3a34c5acf8f3abb87af5840eef8e35870a8942f3de9b3e16873bcf329a11544d57c236135f220f7b77b7ce5c2bcfe97015b7d83e075b81b789f3055f6d61696e02025981acfea37ea8230ebe1eefade34de5a45d8ff9e6fb8223f8c66b401c1d83c100879303000000000059dd6e00000000cde4c7b9d37b39beb7b35fccacd6a7b2513b98f3ba97ff983c31fe8cb28c5fcb07014000ffffffffffffffffb5389319475bd3562c01000002000000931b38de9f7a751915773f5671327171983165ca667cef73b346d2a59c278ae54445449fde6a0000000030d693a14bbfd363e4f0e90d9bac8b588b623414ee8d3d50cc233fcb53c0ba8276924e7dd3ba5bf3f9ce06e639d265aa59ce351e2eb59b61cbff12f10c5e6809", + "token": "d18443a10105a0582fa8041a6ad6af00061a6ad55b2819012c0119012d0719012e18401901316973657373696f6e2d331901320c1901330258207a583e7258827b52bba41cd3eab55102283a43ad000386391e6a42e96e228186" + } + ], + "outcome": "refuse", + "reason": "token_mismatch", + "at": { + "place": "session", + "index": 0 + }, + "sessions": null + }, + { + "name": "bits_not_the_pattern", + "description": "a record whose bits differ from its clear token's pattern at one served position", + "room": "broadcast", + "records": [ + { + "record": "575753310113475f3e0f8a3a34c5acf8f3abb87af5840eef8e35870a8942f3de9b3e16873bcf329a11544d57c236135f220f7b77b7ce5c2bcfe97015b7d83e075b81b789f3055f6d61696e02025981acfea37ea8230ebe1eefade34de5a45d8ff9e6fb8223f8c66b401c1d83c100879303000000000059dd6e00000000cde4c7b9d37b39beb7b35fccacd6a7b2513b98f3ba97ff983c31fe8cb28c5fcb07014000ffffffffffffffffb5789319475bd3562c01000002000000931b38de9f7a751915773f5671327171983165ca667cef73b346d2a59c278ae54445449fde6a000000004f0756020535bd5ae308012040eb989857cd111c03da3077fd60f9a229aac5d83fa2d3e1d5df05716ed73ecf94f06a756eb918047aa866dca7cb7c610d495e01", + "token": "d18443a10105a05826a6041a6ad6af00061a6ad55b2819012c0119012d0719012e184019013048b5389319475bd35658207ea9ace87c093dc8460a5b20be105fbfa73c6459980efc4496bcf2b44fe62e8e" + } + ], + "outcome": "refuse", + "reason": "token_mismatch", + "at": { + "place": "session", + "index": 0 + }, + "sessions": null + }, + { + "name": "source_not_the_tokens", + "description": "a record that states a decrypted pattern for a token in the clear", + "room": "broadcast", + "records": [ + { + "record": "575753310113475f3e0f8a3a34c5acf8f3abb87af5840eef8e35870a8942f3de9b3e16873bcf329a11544d57c236135f220f7b77b7ce5c2bcfe97015b7d83e075b81b789f3055f6d61696e02025981acfea37ea8230ebe1eefade34de5a45d8ff9e6fb8223f8c66b401c1d83c100879303000000000059dd6e00000000cde4c7b9d37b39beb7b35fccacd6a7b2513b98f3ba97ff983c31fe8cb28c5fcb07024000ffffffffffffffffb5389319475bd3562c01000002000000931b38de9f7a751915773f5671327171983165ca667cef73b346d2a59c278ae54445449fde6a000000000c03ede36570689cdfd247b3eb00fb5cb57167afda886d10ec7829b2c0ed0df44962b2d96c80fd16da4c107fa0f95f182d5fe69664ca4596b004bc2d7f980b03", + "token": "d18443a10105a05826a6041a6ad6af00061a6ad55b2819012c0119012d0719012e184019013048b5389319475bd35658207ea9ace87c093dc8460a5b20be105fbfa73c6459980efc4496bcf2b44fe62e8e" + } + ], + "outcome": "refuse", + "reason": "token_mismatch", + "at": { + "place": "session", + "index": 0 + }, + "sessions": null + }, + { + "name": "token_expires_in_session", + "description": "a token whose exp falls before the session's end", + "room": "broadcast", + "records": [ + { + "record": "575753310113475f3e0f8a3a34c5acf8f3abb87af5840eef8e35870a8942f3de9b3e16873bcf329a11544d57c236135f220f7b77b7ce5c2bcfe97015b7d83e075b81b789f3055f6d61696e02025981acfea37ea8230ebe1eefade34de5a45d8ff9e6fb8223f8c66b401c1d83c100879303000000000059dd6e00000000578e3b6d45de0afd6ce192f9a06183bc7b7c0edecbbd9bac45690db18f56f90d07014000ffffffffffffffffb5389319475bd3562c01000002000000931b38de9f7a751915773f5671327171983165ca667cef73b346d2a59c278ae54445449fde6a000000007f5a0386156a20bce3560e8ac8baf7cd0b1bc653a3c9ec157e6dfb17ad2ec7625226749743b367261e68456f23e8b9abc8f38137774d5afbe5447fd657b2fb02", + "token": "d18443a10105a05826a6041a6ad56168061a6ad55b2819012c0119012d0719012e184019013048b5389319475bd356582004a4ff03d7810acab22e348b28403f87f9de77cb709c21f5bf5445aef3a3ece7" + } + ], + "outcome": "refuse", + "reason": "outside_token_window", + "at": { + "place": "session", + "index": 0 + }, + "sessions": null + }, + { + "name": "e2ee_room", + "description": "a record for a room whose authority signed a confidentiality record (Lane B, §3.2)", + "room": "e2ee", + "records": [ + { + "record": "5757533101262644692a2af5c4dc576e607c0fc56cb470ad5b5e3cd391f507b09c330f2bfe3a2224539295ba1bd1315867f0bc503dad7de13b2f110436fa8f05a6a0d4fe76055f6d61696e02025981acfea37ea8230ebe1eefade34de5a45d8ff9e6fb8223f8c66b401c1d83c100879303000000000059dd6e00000000cde4c7b9d37b39beb7b35fccacd6a7b2513b98f3ba97ff983c31fe8cb28c5fcb07014000ffffffffffffffffb5389319475bd3562c010000020000003fefad4bb7c938f9338d35b3fc7a668c9b66f827de50179fb6b89bf5340976da4445449fde6a00000000bd7c6604bf68ac76ff66583a222d151752a5e14d46f221871bd20a4c3b43731c189e32cd4e7455d419c94f25f9eca98a1d887307b1df610592d24fad976ec506", + "token": null + } + ], + "outcome": "refuse", + "reason": "e2ee_room", + "at": { + "place": "confidentiality" + }, + "sessions": null + }, + { + "name": "confidentiality_bad_signature", + "description": "the broadcast room given a confidentiality record its clock signed, not its authority", + "room": "broadcast", + "room_override": { + "confidentiality": "575245310113475f3e0f8a3a34c5acf8f3abb87af5840eef8e35870a8942f3de9b3e16873b04000100588873f250334c244b2e069a67fe7e221922b8797e9cc97b7aff7937a9a5f4eb084d1f8387f5bdb2f30dfc10ab7999dbc244f67d302faf855801d90102264d0c" + }, + "records": [ + { + "record": "575753310113475f3e0f8a3a34c5acf8f3abb87af5840eef8e35870a8942f3de9b3e16873bcf329a11544d57c236135f220f7b77b7ce5c2bcfe97015b7d83e075b81b789f3055f6d61696e02025981acfea37ea8230ebe1eefade34de5a45d8ff9e6fb8223f8c66b401c1d83c100879303000000000059dd6e00000000cde4c7b9d37b39beb7b35fccacd6a7b2513b98f3ba97ff983c31fe8cb28c5fcb07014000ffffffffffffffffb5389319475bd3562c01000002000000931b38de9f7a751915773f5671327171983165ca667cef73b346d2a59c278ae54445449fde6a0000000030d693a14bbfd363e4f0e90d9bac8b588b623414ee8d3d50cc233fcb53c0ba8276924e7dd3ba5bf3f9ce06e639d265aa59ce351e2eb59b61cbff12f10c5e6809", + "token": null + } + ], + "outcome": "refuse", + "reason": "bad_signature", + "at": { + "place": "confidentiality" + }, + "sessions": null + }, + { + "name": "key_log_missing", + "description": "the broadcast room, which names an origin, given no key-log head", + "room": "broadcast", + "room_override": { + "key_log": null + }, + "records": [ + { + "record": "575753310113475f3e0f8a3a34c5acf8f3abb87af5840eef8e35870a8942f3de9b3e16873bcf329a11544d57c236135f220f7b77b7ce5c2bcfe97015b7d83e075b81b789f3055f6d61696e02025981acfea37ea8230ebe1eefade34de5a45d8ff9e6fb8223f8c66b401c1d83c100879303000000000059dd6e00000000cde4c7b9d37b39beb7b35fccacd6a7b2513b98f3ba97ff983c31fe8cb28c5fcb07014000ffffffffffffffffb5389319475bd3562c01000002000000931b38de9f7a751915773f5671327171983165ca667cef73b346d2a59c278ae54445449fde6a0000000030d693a14bbfd363e4f0e90d9bac8b588b623414ee8d3d50cc233fcb53c0ba8276924e7dd3ba5bf3f9ce06e639d265aa59ce351e2eb59b61cbff12f10c5e6809", + "token": null + } + ], + "outcome": "refuse", + "reason": "key_log_missing", + "at": { + "place": "key_log" + }, + "sessions": null + }, + { + "name": "descriptor_bad_signature", + "description": "the descriptor with one bit of its signature flipped", + "room": "broadcast", + "room_override": { + "descriptor": "57524431016cb333eb2b01f7c192e45d749d7f0f0662a045ecd4f04b9ee0055752b0b8c4003a2224539295ba1bd1315867f0bc503dad7de13b2f110436fa8f05a6a0d4fe760060f231265e060040420f0000000000120068747470733a2f2f74762e6578616d706c654000000002002c0100005745503101fe1f0000000000000000000000000000000000000000000000000000000000000004f4013200000040420f00000000000100000040420f0000000000020b62726f61646361737465720201020000000000000000000000000000000000000000000000000000000000000000000676696577657200010200000000000000000000000000000000000000000000000000000000000000000001046d61696e0201000000000000000000000000000000000000000000000000000000000000000000000000000000000000011e00d0070000f40100000244454652a3736bd7ab79ca5fd360d39e176b02d83ed2310f9fbcbdd3bcc7de821168925673e91ab0643bfc2dc76f390bf1bc97fb1ad47e1bfcd3975d7939c6e13550a604" + }, + "records": [ + { + "record": "575753310113475f3e0f8a3a34c5acf8f3abb87af5840eef8e35870a8942f3de9b3e16873bcf329a11544d57c236135f220f7b77b7ce5c2bcfe97015b7d83e075b81b789f3055f6d61696e02025981acfea37ea8230ebe1eefade34de5a45d8ff9e6fb8223f8c66b401c1d83c100879303000000000059dd6e00000000cde4c7b9d37b39beb7b35fccacd6a7b2513b98f3ba97ff983c31fe8cb28c5fcb07014000ffffffffffffffffb5389319475bd3562c01000002000000931b38de9f7a751915773f5671327171983165ca667cef73b346d2a59c278ae54445449fde6a0000000030d693a14bbfd363e4f0e90d9bac8b588b623414ee8d3d50cc233fcb53c0ba8276924e7dd3ba5bf3f9ce06e639d265aa59ce351e2eb59b61cbff12f10c5e6809", + "token": null + } + ], + "outcome": "refuse", + "reason": "bad_signature", + "at": { + "place": "descriptor" + }, + "sessions": null + }, + { + "name": "grant_bad_signature", + "description": "a grant of the room with one bit of its signature flipped", + "room": "broadcast", + "room_override": { + "grants": [ + "575247310113475f3e0f8a3a34c5acf8f3abb87af5840eef8e35870a8942f3de9b3e16873bd510288a80239539921f8dc18ccca7ea45b168e11b10ed5d89e68d927685b7dd00000000000000000000000000000000000000000000000000000000000000000000000000000000704fd56a0000000000afd66a0000000000020102000000000000000000000000000000000000a65356094fa917ceecb8cab86548f81e574550310100000000000000000000000000000000000000000000000000000000000000000004f4013200000040420f00000000000100000040420f0000000000ffffffffffffffffffffffffffffffff00000000011268747470733a2f2f74762e6578616d706c650000365e19a3972f9d1cd58a09679d877fc1785fd9b4654e8338fd0719f56acf1da49f83c5198c023529b3ad4c4e0cda60b0f41af9e67e97f02e2d1bcf86a8552301", + "575247310113475f3e0f8a3a34c5acf8f3abb87af5840eef8e35870a8942f3de9b3e16873b2cb2d52b2fa17fa5bbcaff468f1f31a41d0b0c28f9ce1c1fbb0eeef78ea50cf100000000000000000000000000000000000000000000000000000000000000000000000000000000704fd56a0000000000afd66a000000000100010200000000000000000000000000000000000085c6b22ca03a2633b4b0ba1be7338285574550310100000000000000000000000000000000000000000000000000000000000000000004f4013200000040420f00000000000100000040420f0000000000ffffffffffffffffffffffffffffffff00000000011268747470733a2f2f74762e6578616d706c65000033df13c5bf7a88cdc0022ee634c06c5692a39bde15a91d3216df85f4ef77bde342111456d6c055a3e45d82ac443785b19f3e57d1d9b085ee91a57fc205391e0c", + "575247310113475f3e0f8a3a34c5acf8f3abb87af5840eef8e35870a8942f3de9b3e16873b3c0bb2a71c87d52373fba22cb6ba2e9e2412dd39fc0f5dcd5fd3deb7af90acc900000000000000000000000000000000000000000000000000000000000000000000000000000000704fd56a0000000000afd66a0000000001000102000000000000000000000000000000000000e8fd85d82893fbb80da381105c90f059574550310100000000000000000000000000000000000000000000000000000000000000000004f4013200000040420f00000000000100000040420f0000000000ffffffffffffffffffffffffffffffff00000000011268747470733a2f2f74762e6578616d706c650000847acb8e92f4c38dc6f71a0bdd64428f93bcb0bb00e6f1dd64b767d10b858a7b68f5cbb7821b169732f978d25aabaaba67f87e746984f2b9716d11f0347d930b", + "575247310113475f3e0f8a3a34c5acf8f3abb87af5840eef8e35870a8942f3de9b3e16873b62d122c3c2774fd7447203d74703a0b240e91a7e8647f8fe746abc43d58676ed00000000000000000000000000000000000000000000000000000000000000000000000000000000704fd56a0000000000afd66a00000000010001020000000000000000000000000000000000003bc73ee8f2cbcde3023aa5e1f887de84574550310100000000000000000000000000000000000000000000000000000000000000000004f4013200000040420f00000000000100000040420f0000000000ffffffffffffffffffffffffffffffff00000000011268747470733a2f2f74762e6578616d706c65000014e01c53a6ad6f0a697d2f3791abbefbab0036d8059203712a71aa772514553499fb3fe3eb0f28be80764d617d7a9c70a705aa9f3d852bca342628bc5ae4390a", + "575247310113475f3e0f8a3a34c5acf8f3abb87af5840eef8e35870a8942f3de9b3e16873b3f6e554e086d1f33ffe354eec65f1eadd8f770fb4bf2131f5774f52c754e1b9500000000000000000000000000000000000000000000000000000000000000000000000000000000704fd56a000000003062d56a0000000001000102000000000000000000000000000000000000ecdcba6481514803c669813a6dba1b26574550310100000000000000000000000000000000000000000000000000000000000000000004f4013200000040420f00000000000100000040420f0000000000ffffffffffffffffffffffffffffffff00000000011268747470733a2f2f74762e6578616d706c650000dfa4b0b898412e3fad702126a4f505592e23c95fd4f188d78c33139fe54fc8f786a1cba35b1e73fe2738650cf764551d9b30e87e638bb73fbe728886381b930d" + ] + }, + "records": [ + { + "record": "575753310113475f3e0f8a3a34c5acf8f3abb87af5840eef8e35870a8942f3de9b3e16873bcf329a11544d57c236135f220f7b77b7ce5c2bcfe97015b7d83e075b81b789f3055f6d61696e02025981acfea37ea8230ebe1eefade34de5a45d8ff9e6fb8223f8c66b401c1d83c100879303000000000059dd6e00000000cde4c7b9d37b39beb7b35fccacd6a7b2513b98f3ba97ff983c31fe8cb28c5fcb07014000ffffffffffffffffb5389319475bd3562c01000002000000931b38de9f7a751915773f5671327171983165ca667cef73b346d2a59c278ae54445449fde6a0000000030d693a14bbfd363e4f0e90d9bac8b588b623414ee8d3d50cc233fcb53c0ba8276924e7dd3ba5bf3f9ce06e639d265aa59ce351e2eb59b61cbff12f10c5e6809", + "token": null + } + ], + "outcome": "refuse", + "reason": "bad_signature", + "at": { + "place": "grant", + "index": 1 + }, + "sessions": null + } + ], + "matches": [ + { + "name": "recovered_from_session_1", + "description": "a copy of session 1, positions 0 to 47 recovered but three erasures: it agrees with session 1 alone", + "room": "broadcast", + "records": [ + { + "record": "575753310113475f3e0f8a3a34c5acf8f3abb87af5840eef8e35870a8942f3de9b3e16873bcf329a11544d57c236135f220f7b77b7ce5c2bcfe97015b7d83e075b81b789f3055f6d61696e02025981acfea37ea8230ebe1eefade34de5a45d8ff9e6fb8223f8c66b401c1d83c100879303000000000059dd6e00000000cde4c7b9d37b39beb7b35fccacd6a7b2513b98f3ba97ff983c31fe8cb28c5fcb07014000ffffffffffffffffb5389319475bd3562c01000002000000931b38de9f7a751915773f5671327171983165ca667cef73b346d2a59c278ae54445449fde6a0000000030d693a14bbfd363e4f0e90d9bac8b588b623414ee8d3d50cc233fcb53c0ba8276924e7dd3ba5bf3f9ce06e639d265aa59ce351e2eb59b61cbff12f10c5e6809", + "token": null + }, + { + "record": "575753310113475f3e0f8a3a34c5acf8f3abb87af5840eef8e35870a8942f3de9b3e16873bcf329a11544d57c236135f220f7b77b7ce5c2bcfe97015b7d83e075b81b789f3055f6d61696e02025981acfea37ea8230ebe1eefade34de5a45d8ff9e6fb8223f8c66b401c1d83c100a3e1110000000000b1081900000000fc859f73eef73fb7ccf561bdfea3e55f616449253538f906ff7ab8432af12dc507024000fc00000000003fffd40000000000241f1400000000000000d64ebb0bab393058b7a9934f1df5da0ff4d729b210f0a06c406150b8f531abdb4445a499de6a00000000b54d24968f10d15a7829c1cbaa0ab445c80ec75c5d77a52d9b1d3628476ac2bb24e1e859b9741b41ee23bb63126a25d827248009d7c7d9c224ab16f4ba51a90f", + "token": null + }, + { + "record": "575753310113475f3e0f8a3a34c5acf8f3abb87af5840eef8e35870a8942f3de9b3e16873b0d4b3cdae998fa7ade7d7600d29db16f397e14dd56c2864263fb1144b6cd03d5055f6d61696e02025981acfea37ea8230ebe1eefade34de5a45d8ff9e6fb8223f8c66b401c1d83c10046c3230000000000180d8f000000001f53ed7edd447726ed72961b854ae198652d02bbe37035e420987b4c857146dd07034000ffffffffffffffff5120f915333929682c01000000000000b641fb80943018dd6f3620f7082aad9f4ab4325131a267106f153b939b2d58c9465260a1de6a000000007c47c1d9b4f57c7e8d87e7e1ac7137e1631ef6095a91052b9890ac32b19b19a354383c8bbf062205ab149adf8c76f17d92cadf9d01cfbe3bf05b4c047f13d80c", + "token": null + }, + { + "record": "575753310113475f3e0f8a3a34c5acf8f3abb87af5840eef8e35870a8942f3de9b3e16873b0d4b3cdae998fa7ade7d7600d29db16f397e14dd56c2864263fb1144b6cd03d5055f6d61696e02025981acfea37ea8230ebe1eefade34de5a45d8ff9e6fb8223f8c66b401c1d83c100180d8f00000000005ed0b20000000091fdbac2fe383e9153d13b0820e28d58f4dee0f9bacfaefacc67b28f8e1cf12a07014000ffffffffffffffff6bd0c1f00bd39dfd64000000000000006561878740fec7eb63a6758df07cedebcda05bb997a66797da86db89e2b5376a4652b8a3de6a000000002ba40624ece7170f6500343895606e2a029afa2d1871dc8581a2b15abd318a6b94be0e83dc0ad4706e39e11a661c86cdd592c1fb2d4d06061693754c27a43002", + "token": null + } + ], + "sequence": "575751310113475f3e0f8a3a34c5acf8f3abb87af5840eef8e35870a8942f3de9b3e16873b055f6d61696e020740000046c3230000000000d2496b00000000fbffbfffbfff0000b1389319075b0000fb819f34365474922b1c738a09fb346faa64a1363341889c08889cd66df4d7d2d9ffbd99687a7f1ab17a0403f5d9c64751f460edf1f3a64a0b806ffe91e4dca647d07da9f9c03dd51306360fd8755385cbb0c6e502921c6e96884c542908a75d163a0cbf46831817d28259d313cee6a3736672c9dda55fcc70dcf506be8e8b09", + "outcome": "matched", + "reason": null, + "at": null, + "match": { + "sequence": "b756cec606497456d09f0dd4da3ee436ddea3bd6d513a2289d90b86314b6aa32", + "stated": 45, + "candidates": [ + { + "index": 0, + "digest": "6abc05ea764183518560731925ec2d262afccc34df86799f8f75467475d1889c", + "agrees": true, + "first": null + }, + { + "index": 2, + "digest": "e5cc27e9ddf93cc74793dd5d51c4df3ce3fecccbdec0392bac6c487ca72d9dec", + "agrees": false, + "first": { + "position": 0, + "how": "differs" + } + } + ], + "session": "6abc05ea764183518560731925ec2d262afccc34df86799f8f75467475d1889c" + } + }, + { + "name": "agrees_with_two", + "description": "a few positions at which sessions 1 and 3 embedded the same variants, the span unknown: two sessions agree", + "room": "broadcast", + "records": [ + { + "record": "575753310113475f3e0f8a3a34c5acf8f3abb87af5840eef8e35870a8942f3de9b3e16873bcf329a11544d57c236135f220f7b77b7ce5c2bcfe97015b7d83e075b81b789f3055f6d61696e02025981acfea37ea8230ebe1eefade34de5a45d8ff9e6fb8223f8c66b401c1d83c100879303000000000059dd6e00000000cde4c7b9d37b39beb7b35fccacd6a7b2513b98f3ba97ff983c31fe8cb28c5fcb07014000ffffffffffffffffb5389319475bd3562c01000002000000931b38de9f7a751915773f5671327171983165ca667cef73b346d2a59c278ae54445449fde6a0000000030d693a14bbfd363e4f0e90d9bac8b588b623414ee8d3d50cc233fcb53c0ba8276924e7dd3ba5bf3f9ce06e639d265aa59ce351e2eb59b61cbff12f10c5e6809", + "token": null + }, + { + "record": "575753310113475f3e0f8a3a34c5acf8f3abb87af5840eef8e35870a8942f3de9b3e16873bcf329a11544d57c236135f220f7b77b7ce5c2bcfe97015b7d83e075b81b789f3055f6d61696e02025981acfea37ea8230ebe1eefade34de5a45d8ff9e6fb8223f8c66b401c1d83c100a3e1110000000000b1081900000000fc859f73eef73fb7ccf561bdfea3e55f616449253538f906ff7ab8432af12dc507024000fc00000000003fffd40000000000241f1400000000000000d64ebb0bab393058b7a9934f1df5da0ff4d729b210f0a06c406150b8f531abdb4445a499de6a00000000b54d24968f10d15a7829c1cbaa0ab445c80ec75c5d77a52d9b1d3628476ac2bb24e1e859b9741b41ee23bb63126a25d827248009d7c7d9c224ab16f4ba51a90f", + "token": null + }, + { + "record": "575753310113475f3e0f8a3a34c5acf8f3abb87af5840eef8e35870a8942f3de9b3e16873b0d4b3cdae998fa7ade7d7600d29db16f397e14dd56c2864263fb1144b6cd03d5055f6d61696e02025981acfea37ea8230ebe1eefade34de5a45d8ff9e6fb8223f8c66b401c1d83c10046c3230000000000180d8f000000001f53ed7edd447726ed72961b854ae198652d02bbe37035e420987b4c857146dd07034000ffffffffffffffff5120f915333929682c01000000000000b641fb80943018dd6f3620f7082aad9f4ab4325131a267106f153b939b2d58c9465260a1de6a000000007c47c1d9b4f57c7e8d87e7e1ac7137e1631ef6095a91052b9890ac32b19b19a354383c8bbf062205ab149adf8c76f17d92cadf9d01cfbe3bf05b4c047f13d80c", + "token": null + }, + { + "record": "575753310113475f3e0f8a3a34c5acf8f3abb87af5840eef8e35870a8942f3de9b3e16873b0d4b3cdae998fa7ade7d7600d29db16f397e14dd56c2864263fb1144b6cd03d5055f6d61696e02025981acfea37ea8230ebe1eefade34de5a45d8ff9e6fb8223f8c66b401c1d83c100180d8f00000000005ed0b20000000091fdbac2fe383e9153d13b0820e28d58f4dee0f9bacfaefacc67b28f8e1cf12a07014000ffffffffffffffff6bd0c1f00bd39dfd64000000000000006561878740fec7eb63a6758df07cedebcda05bb997a66797da86db89e2b5376a4652b8a3de6a000000002ba40624ece7170f6500343895606e2a029afa2d1871dc8581a2b15abd318a6b94be0e83dc0ad4706e39e11a661c86cdd592c1fb2d4d06061693754c27a43002", + "token": null + } + ], + "sequence": "575751310113475f3e0f8a3a34c5acf8f3abb87af5840eef8e35870a8942f3de9b3e16873b055f6d61696e020740000000000000000000ffffffffffffffff03800000000000000100000000000000fb819f34365474922b1c738a09fb346faa64a1363341889c08889cd66df4d7d2d9ffbd99687a7f1ab17a0403f5d9c64751f460edf1f3a64a0b806ffe91e4dca6b2b93a59d11f64503981953bff1e37f19691815d2d64260327b534f91ff5b0f381614b31f3f3b3f0af9f3bbfc029f20b4cd1ddd370ed4416944f039e35a47b06", + "outcome": "unmatched", + "reason": "ambiguous_match", + "at": null, + "match": { + "sequence": "540847ca20088a3298f7b0680b8348591773c00dfbd119af3ed0b59972749b6d", + "stated": 3, + "candidates": [ + { + "index": 0, + "digest": "6abc05ea764183518560731925ec2d262afccc34df86799f8f75467475d1889c", + "agrees": true, + "first": null + }, + { + "index": 1, + "digest": "969a41fe9f713fc9c787368b4942929b74a035bc2af75eeca2b7440c4465c29d", + "agrees": false, + "first": { + "position": 6, + "how": "unserved" + } + }, + { + "index": 2, + "digest": "e5cc27e9ddf93cc74793dd5d51c4df3ce3fecccbdec0392bac6c487ca72d9dec", + "agrees": true, + "first": null + }, + { + "index": 3, + "digest": "036530e46019595c1cf23a54eff4ce24c1869ed19f0f70e10f607ac2ec2f804a", + "agrees": false, + "first": { + "position": 6, + "how": "differs" + } + } + ], + "session": null + } + }, + { + "name": "bits_disagree", + "description": "session 1's copy with the bit at position 0 the other variant: no session agrees", + "room": "broadcast", + "records": [ + { + "record": "575753310113475f3e0f8a3a34c5acf8f3abb87af5840eef8e35870a8942f3de9b3e16873bcf329a11544d57c236135f220f7b77b7ce5c2bcfe97015b7d83e075b81b789f3055f6d61696e02025981acfea37ea8230ebe1eefade34de5a45d8ff9e6fb8223f8c66b401c1d83c100879303000000000059dd6e00000000cde4c7b9d37b39beb7b35fccacd6a7b2513b98f3ba97ff983c31fe8cb28c5fcb07014000ffffffffffffffffb5389319475bd3562c01000002000000931b38de9f7a751915773f5671327171983165ca667cef73b346d2a59c278ae54445449fde6a0000000030d693a14bbfd363e4f0e90d9bac8b588b623414ee8d3d50cc233fcb53c0ba8276924e7dd3ba5bf3f9ce06e639d265aa59ce351e2eb59b61cbff12f10c5e6809", + "token": null + }, + { + "record": "575753310113475f3e0f8a3a34c5acf8f3abb87af5840eef8e35870a8942f3de9b3e16873bcf329a11544d57c236135f220f7b77b7ce5c2bcfe97015b7d83e075b81b789f3055f6d61696e02025981acfea37ea8230ebe1eefade34de5a45d8ff9e6fb8223f8c66b401c1d83c100a3e1110000000000b1081900000000fc859f73eef73fb7ccf561bdfea3e55f616449253538f906ff7ab8432af12dc507024000fc00000000003fffd40000000000241f1400000000000000d64ebb0bab393058b7a9934f1df5da0ff4d729b210f0a06c406150b8f531abdb4445a499de6a00000000b54d24968f10d15a7829c1cbaa0ab445c80ec75c5d77a52d9b1d3628476ac2bb24e1e859b9741b41ee23bb63126a25d827248009d7c7d9c224ab16f4ba51a90f", + "token": null + }, + { + "record": "575753310113475f3e0f8a3a34c5acf8f3abb87af5840eef8e35870a8942f3de9b3e16873b0d4b3cdae998fa7ade7d7600d29db16f397e14dd56c2864263fb1144b6cd03d5055f6d61696e02025981acfea37ea8230ebe1eefade34de5a45d8ff9e6fb8223f8c66b401c1d83c10046c3230000000000180d8f000000001f53ed7edd447726ed72961b854ae198652d02bbe37035e420987b4c857146dd07034000ffffffffffffffff5120f915333929682c01000000000000b641fb80943018dd6f3620f7082aad9f4ab4325131a267106f153b939b2d58c9465260a1de6a000000007c47c1d9b4f57c7e8d87e7e1ac7137e1631ef6095a91052b9890ac32b19b19a354383c8bbf062205ab149adf8c76f17d92cadf9d01cfbe3bf05b4c047f13d80c", + "token": null + }, + { + "record": "575753310113475f3e0f8a3a34c5acf8f3abb87af5840eef8e35870a8942f3de9b3e16873b0d4b3cdae998fa7ade7d7600d29db16f397e14dd56c2864263fb1144b6cd03d5055f6d61696e02025981acfea37ea8230ebe1eefade34de5a45d8ff9e6fb8223f8c66b401c1d83c100180d8f00000000005ed0b20000000091fdbac2fe383e9153d13b0820e28d58f4dee0f9bacfaefacc67b28f8e1cf12a07014000ffffffffffffffff6bd0c1f00bd39dfd64000000000000006561878740fec7eb63a6758df07cedebcda05bb997a66797da86db89e2b5376a4652b8a3de6a000000002ba40624ece7170f6500343895606e2a029afa2d1871dc8581a2b15abd318a6b94be0e83dc0ad4706e39e11a661c86cdd592c1fb2d4d06061693754c27a43002", + "token": null + } + ], + "sequence": "575751310113475f3e0f8a3a34c5acf8f3abb87af5840eef8e35870a8942f3de9b3e16873b055f6d61696e020740000046c3230000000000d2496b00000000fbffbfffbfff000031389319075b0000fb819f34365474922b1c738a09fb346faa64a1363341889c08889cd66df4d7d2d9ffbd99687a7f1ab17a0403f5d9c64751f460edf1f3a64a0b806ffe91e4dca6c1347461c144e5e20152f072b2f7dd24e001fdc3ccb41c206cced0ae5ea7bbc2cf66f5d5459a78c8913dc64339af602a1c5702d990f55fc8a1a043c4b8138e00", + "outcome": "unmatched", + "reason": "no_match", + "at": null, + "match": { + "sequence": "d57d2cd3d6ad9e99de2d51901ad649bfa4807e4ce6fa613ccecaf499c12966de", + "stated": 45, + "candidates": [ + { + "index": 0, + "digest": "6abc05ea764183518560731925ec2d262afccc34df86799f8f75467475d1889c", + "agrees": false, + "first": { + "position": 0, + "how": "differs" + } + }, + { + "index": 2, + "digest": "e5cc27e9ddf93cc74793dd5d51c4df3ce3fecccbdec0392bac6c487ca72d9dec", + "agrees": false, + "first": { + "position": 1, + "how": "differs" + } + } + ], + "session": null + } + }, + { + "name": "span_meets_none", + "description": "session 1's copy stated as delivered after every session ended: no candidate", + "room": "broadcast", + "records": [ + { + "record": "575753310113475f3e0f8a3a34c5acf8f3abb87af5840eef8e35870a8942f3de9b3e16873bcf329a11544d57c236135f220f7b77b7ce5c2bcfe97015b7d83e075b81b789f3055f6d61696e02025981acfea37ea8230ebe1eefade34de5a45d8ff9e6fb8223f8c66b401c1d83c100879303000000000059dd6e00000000cde4c7b9d37b39beb7b35fccacd6a7b2513b98f3ba97ff983c31fe8cb28c5fcb07014000ffffffffffffffffb5389319475bd3562c01000002000000931b38de9f7a751915773f5671327171983165ca667cef73b346d2a59c278ae54445449fde6a0000000030d693a14bbfd363e4f0e90d9bac8b588b623414ee8d3d50cc233fcb53c0ba8276924e7dd3ba5bf3f9ce06e639d265aa59ce351e2eb59b61cbff12f10c5e6809", + "token": null + }, + { + "record": "575753310113475f3e0f8a3a34c5acf8f3abb87af5840eef8e35870a8942f3de9b3e16873bcf329a11544d57c236135f220f7b77b7ce5c2bcfe97015b7d83e075b81b789f3055f6d61696e02025981acfea37ea8230ebe1eefade34de5a45d8ff9e6fb8223f8c66b401c1d83c100a3e1110000000000b1081900000000fc859f73eef73fb7ccf561bdfea3e55f616449253538f906ff7ab8432af12dc507024000fc00000000003fffd40000000000241f1400000000000000d64ebb0bab393058b7a9934f1df5da0ff4d729b210f0a06c406150b8f531abdb4445a499de6a00000000b54d24968f10d15a7829c1cbaa0ab445c80ec75c5d77a52d9b1d3628476ac2bb24e1e859b9741b41ee23bb63126a25d827248009d7c7d9c224ab16f4ba51a90f", + "token": null + }, + { + "record": "575753310113475f3e0f8a3a34c5acf8f3abb87af5840eef8e35870a8942f3de9b3e16873b0d4b3cdae998fa7ade7d7600d29db16f397e14dd56c2864263fb1144b6cd03d5055f6d61696e02025981acfea37ea8230ebe1eefade34de5a45d8ff9e6fb8223f8c66b401c1d83c10046c3230000000000180d8f000000001f53ed7edd447726ed72961b854ae198652d02bbe37035e420987b4c857146dd07034000ffffffffffffffff5120f915333929682c01000000000000b641fb80943018dd6f3620f7082aad9f4ab4325131a267106f153b939b2d58c9465260a1de6a000000007c47c1d9b4f57c7e8d87e7e1ac7137e1631ef6095a91052b9890ac32b19b19a354383c8bbf062205ab149adf8c76f17d92cadf9d01cfbe3bf05b4c047f13d80c", + "token": null + }, + { + "record": "575753310113475f3e0f8a3a34c5acf8f3abb87af5840eef8e35870a8942f3de9b3e16873b0d4b3cdae998fa7ade7d7600d29db16f397e14dd56c2864263fb1144b6cd03d5055f6d61696e02025981acfea37ea8230ebe1eefade34de5a45d8ff9e6fb8223f8c66b401c1d83c100180d8f00000000005ed0b20000000091fdbac2fe383e9153d13b0820e28d58f4dee0f9bacfaefacc67b28f8e1cf12a07014000ffffffffffffffff6bd0c1f00bd39dfd64000000000000006561878740fec7eb63a6758df07cedebcda05bb997a66797da86db89e2b5376a4652b8a3de6a000000002ba40624ece7170f6500343895606e2a029afa2d1871dc8581a2b15abd318a6b94be0e83dc0ad4706e39e11a661c86cdd592c1fb2d4d06061693754c27a43002", + "token": null + } + ], + "sequence": "575751310113475f3e0f8a3a34c5acf8f3abb87af5840eef8e35870a8942f3de9b3e16873b055f6d61696e0207400000f2052a0100000000bca06501000000fbffbfffbfff0000b1389319075b0000fb819f34365474922b1c738a09fb346faa64a1363341889c08889cd66df4d7d2d9ffbd99687a7f1ab17a0403f5d9c64751f460edf1f3a64a0b806ffe91e4dca65e8cb437cf8e863e7be63bc66f22640e4d949c9dcf42f9e2e56ad0ad633c7bdcb6c24e93a3c301add236a4b62fb2528be27f79f57020328c3541a1731c881c09", + "outcome": "unmatched", + "reason": "no_match", + "at": null, + "match": { + "sequence": "5faa808ec924be10495cceabc3616831ce106f154899b7debe1c90735fba6ff2", + "stated": 45, + "candidates": [], + "session": null + } + }, + { + "name": "another_vendor", + "description": "session 1's bits as another vendor's detector recovered them: no candidate", + "room": "broadcast", + "records": [ + { + "record": "575753310113475f3e0f8a3a34c5acf8f3abb87af5840eef8e35870a8942f3de9b3e16873bcf329a11544d57c236135f220f7b77b7ce5c2bcfe97015b7d83e075b81b789f3055f6d61696e02025981acfea37ea8230ebe1eefade34de5a45d8ff9e6fb8223f8c66b401c1d83c100879303000000000059dd6e00000000cde4c7b9d37b39beb7b35fccacd6a7b2513b98f3ba97ff983c31fe8cb28c5fcb07014000ffffffffffffffffb5389319475bd3562c01000002000000931b38de9f7a751915773f5671327171983165ca667cef73b346d2a59c278ae54445449fde6a0000000030d693a14bbfd363e4f0e90d9bac8b588b623414ee8d3d50cc233fcb53c0ba8276924e7dd3ba5bf3f9ce06e639d265aa59ce351e2eb59b61cbff12f10c5e6809", + "token": null + }, + { + "record": "575753310113475f3e0f8a3a34c5acf8f3abb87af5840eef8e35870a8942f3de9b3e16873bcf329a11544d57c236135f220f7b77b7ce5c2bcfe97015b7d83e075b81b789f3055f6d61696e02025981acfea37ea8230ebe1eefade34de5a45d8ff9e6fb8223f8c66b401c1d83c100a3e1110000000000b1081900000000fc859f73eef73fb7ccf561bdfea3e55f616449253538f906ff7ab8432af12dc507024000fc00000000003fffd40000000000241f1400000000000000d64ebb0bab393058b7a9934f1df5da0ff4d729b210f0a06c406150b8f531abdb4445a499de6a00000000b54d24968f10d15a7829c1cbaa0ab445c80ec75c5d77a52d9b1d3628476ac2bb24e1e859b9741b41ee23bb63126a25d827248009d7c7d9c224ab16f4ba51a90f", + "token": null + }, + { + "record": "575753310113475f3e0f8a3a34c5acf8f3abb87af5840eef8e35870a8942f3de9b3e16873b0d4b3cdae998fa7ade7d7600d29db16f397e14dd56c2864263fb1144b6cd03d5055f6d61696e02025981acfea37ea8230ebe1eefade34de5a45d8ff9e6fb8223f8c66b401c1d83c10046c3230000000000180d8f000000001f53ed7edd447726ed72961b854ae198652d02bbe37035e420987b4c857146dd07034000ffffffffffffffff5120f915333929682c01000000000000b641fb80943018dd6f3620f7082aad9f4ab4325131a267106f153b939b2d58c9465260a1de6a000000007c47c1d9b4f57c7e8d87e7e1ac7137e1631ef6095a91052b9890ac32b19b19a354383c8bbf062205ab149adf8c76f17d92cadf9d01cfbe3bf05b4c047f13d80c", + "token": null + }, + { + "record": "575753310113475f3e0f8a3a34c5acf8f3abb87af5840eef8e35870a8942f3de9b3e16873b0d4b3cdae998fa7ade7d7600d29db16f397e14dd56c2864263fb1144b6cd03d5055f6d61696e02025981acfea37ea8230ebe1eefade34de5a45d8ff9e6fb8223f8c66b401c1d83c100180d8f00000000005ed0b20000000091fdbac2fe383e9153d13b0820e28d58f4dee0f9bacfaefacc67b28f8e1cf12a07014000ffffffffffffffff6bd0c1f00bd39dfd64000000000000006561878740fec7eb63a6758df07cedebcda05bb997a66797da86db89e2b5376a4652b8a3de6a000000002ba40624ece7170f6500343895606e2a029afa2d1871dc8581a2b15abd318a6b94be0e83dc0ad4706e39e11a661c86cdd592c1fb2d4d06061693754c27a43002", + "token": null + } + ], + "sequence": "575751310113475f3e0f8a3a34c5acf8f3abb87af5840eef8e35870a8942f3de9b3e16873b055f6d61696e020940000046c3230000000000d2496b00000000fbffbfffbfff0000b1389319075b0000fb819f34365474922b1c738a09fb346faa64a1363341889c08889cd66df4d7d2d9ffbd99687a7f1ab17a0403f5d9c64751f460edf1f3a64a0b806ffe91e4dca67970ef4b818e47c872671e9287f29f550fb9eb6593cf8091ab6c7b294ac173b689a45bde819fe2424db3c2754cdff65104dd443e1976f69df0512a5674608e0a", + "outcome": "unmatched", + "reason": "no_match", + "at": null, + "match": { + "sequence": "1b76224f56c9ff20b279506262cf73df6fc9100f902d783f65d1ff6e83e19a1d", + "stated": 45, + "candidates": [], + "session": null + } + }, + { + "name": "sequence_states_nothing", + "description": "a sequence that recovered no position", + "room": "broadcast", + "records": [ + { + "record": "575753310113475f3e0f8a3a34c5acf8f3abb87af5840eef8e35870a8942f3de9b3e16873bcf329a11544d57c236135f220f7b77b7ce5c2bcfe97015b7d83e075b81b789f3055f6d61696e02025981acfea37ea8230ebe1eefade34de5a45d8ff9e6fb8223f8c66b401c1d83c100879303000000000059dd6e00000000cde4c7b9d37b39beb7b35fccacd6a7b2513b98f3ba97ff983c31fe8cb28c5fcb07014000ffffffffffffffffb5389319475bd3562c01000002000000931b38de9f7a751915773f5671327171983165ca667cef73b346d2a59c278ae54445449fde6a0000000030d693a14bbfd363e4f0e90d9bac8b588b623414ee8d3d50cc233fcb53c0ba8276924e7dd3ba5bf3f9ce06e639d265aa59ce351e2eb59b61cbff12f10c5e6809", + "token": null + }, + { + "record": "575753310113475f3e0f8a3a34c5acf8f3abb87af5840eef8e35870a8942f3de9b3e16873bcf329a11544d57c236135f220f7b77b7ce5c2bcfe97015b7d83e075b81b789f3055f6d61696e02025981acfea37ea8230ebe1eefade34de5a45d8ff9e6fb8223f8c66b401c1d83c100a3e1110000000000b1081900000000fc859f73eef73fb7ccf561bdfea3e55f616449253538f906ff7ab8432af12dc507024000fc00000000003fffd40000000000241f1400000000000000d64ebb0bab393058b7a9934f1df5da0ff4d729b210f0a06c406150b8f531abdb4445a499de6a00000000b54d24968f10d15a7829c1cbaa0ab445c80ec75c5d77a52d9b1d3628476ac2bb24e1e859b9741b41ee23bb63126a25d827248009d7c7d9c224ab16f4ba51a90f", + "token": null + }, + { + "record": "575753310113475f3e0f8a3a34c5acf8f3abb87af5840eef8e35870a8942f3de9b3e16873b0d4b3cdae998fa7ade7d7600d29db16f397e14dd56c2864263fb1144b6cd03d5055f6d61696e02025981acfea37ea8230ebe1eefade34de5a45d8ff9e6fb8223f8c66b401c1d83c10046c3230000000000180d8f000000001f53ed7edd447726ed72961b854ae198652d02bbe37035e420987b4c857146dd07034000ffffffffffffffff5120f915333929682c01000000000000b641fb80943018dd6f3620f7082aad9f4ab4325131a267106f153b939b2d58c9465260a1de6a000000007c47c1d9b4f57c7e8d87e7e1ac7137e1631ef6095a91052b9890ac32b19b19a354383c8bbf062205ab149adf8c76f17d92cadf9d01cfbe3bf05b4c047f13d80c", + "token": null + }, + { + "record": "575753310113475f3e0f8a3a34c5acf8f3abb87af5840eef8e35870a8942f3de9b3e16873b0d4b3cdae998fa7ade7d7600d29db16f397e14dd56c2864263fb1144b6cd03d5055f6d61696e02025981acfea37ea8230ebe1eefade34de5a45d8ff9e6fb8223f8c66b401c1d83c100180d8f00000000005ed0b20000000091fdbac2fe383e9153d13b0820e28d58f4dee0f9bacfaefacc67b28f8e1cf12a07014000ffffffffffffffff6bd0c1f00bd39dfd64000000000000006561878740fec7eb63a6758df07cedebcda05bb997a66797da86db89e2b5376a4652b8a3de6a000000002ba40624ece7170f6500343895606e2a029afa2d1871dc8581a2b15abd318a6b94be0e83dc0ad4706e39e11a661c86cdd592c1fb2d4d06061693754c27a43002", + "token": null + } + ], + "sequence": "575751310113475f3e0f8a3a34c5acf8f3abb87af5840eef8e35870a8942f3de9b3e16873b055f6d61696e020740000046c3230000000000d2496b0000000000000000000000000000000000000000fb819f34365474922b1c738a09fb346faa64a1363341889c08889cd66df4d7d2d9ffbd99687a7f1ab17a0403f5d9c64751f460edf1f3a64a0b806ffe91e4dca640600e3d4147bec5581ad9bf65300e3fbd50d96e61f7aac6de8c885d5b2e90c47780322109e556520e307c7060c5a478eaf965b785a3f0b833c516c9adfdcd03", + "outcome": "refuse", + "reason": "sequence_invalid", + "at": { + "place": "sequence" + }, + "match": null + }, + { + "name": "bits_not_known", + "description": "a sequence with a bit at an erasure", + "room": "broadcast", + "records": [ + { + "record": "575753310113475f3e0f8a3a34c5acf8f3abb87af5840eef8e35870a8942f3de9b3e16873bcf329a11544d57c236135f220f7b77b7ce5c2bcfe97015b7d83e075b81b789f3055f6d61696e02025981acfea37ea8230ebe1eefade34de5a45d8ff9e6fb8223f8c66b401c1d83c100879303000000000059dd6e00000000cde4c7b9d37b39beb7b35fccacd6a7b2513b98f3ba97ff983c31fe8cb28c5fcb07014000ffffffffffffffffb5389319475bd3562c01000002000000931b38de9f7a751915773f5671327171983165ca667cef73b346d2a59c278ae54445449fde6a0000000030d693a14bbfd363e4f0e90d9bac8b588b623414ee8d3d50cc233fcb53c0ba8276924e7dd3ba5bf3f9ce06e639d265aa59ce351e2eb59b61cbff12f10c5e6809", + "token": null + }, + { + "record": "575753310113475f3e0f8a3a34c5acf8f3abb87af5840eef8e35870a8942f3de9b3e16873bcf329a11544d57c236135f220f7b77b7ce5c2bcfe97015b7d83e075b81b789f3055f6d61696e02025981acfea37ea8230ebe1eefade34de5a45d8ff9e6fb8223f8c66b401c1d83c100a3e1110000000000b1081900000000fc859f73eef73fb7ccf561bdfea3e55f616449253538f906ff7ab8432af12dc507024000fc00000000003fffd40000000000241f1400000000000000d64ebb0bab393058b7a9934f1df5da0ff4d729b210f0a06c406150b8f531abdb4445a499de6a00000000b54d24968f10d15a7829c1cbaa0ab445c80ec75c5d77a52d9b1d3628476ac2bb24e1e859b9741b41ee23bb63126a25d827248009d7c7d9c224ab16f4ba51a90f", + "token": null + }, + { + "record": "575753310113475f3e0f8a3a34c5acf8f3abb87af5840eef8e35870a8942f3de9b3e16873b0d4b3cdae998fa7ade7d7600d29db16f397e14dd56c2864263fb1144b6cd03d5055f6d61696e02025981acfea37ea8230ebe1eefade34de5a45d8ff9e6fb8223f8c66b401c1d83c10046c3230000000000180d8f000000001f53ed7edd447726ed72961b854ae198652d02bbe37035e420987b4c857146dd07034000ffffffffffffffff5120f915333929682c01000000000000b641fb80943018dd6f3620f7082aad9f4ab4325131a267106f153b939b2d58c9465260a1de6a000000007c47c1d9b4f57c7e8d87e7e1ac7137e1631ef6095a91052b9890ac32b19b19a354383c8bbf062205ab149adf8c76f17d92cadf9d01cfbe3bf05b4c047f13d80c", + "token": null + }, + { + "record": "575753310113475f3e0f8a3a34c5acf8f3abb87af5840eef8e35870a8942f3de9b3e16873b0d4b3cdae998fa7ade7d7600d29db16f397e14dd56c2864263fb1144b6cd03d5055f6d61696e02025981acfea37ea8230ebe1eefade34de5a45d8ff9e6fb8223f8c66b401c1d83c100180d8f00000000005ed0b20000000091fdbac2fe383e9153d13b0820e28d58f4dee0f9bacfaefacc67b28f8e1cf12a07014000ffffffffffffffff6bd0c1f00bd39dfd64000000000000006561878740fec7eb63a6758df07cedebcda05bb997a66797da86db89e2b5376a4652b8a3de6a000000002ba40624ece7170f6500343895606e2a029afa2d1871dc8581a2b15abd318a6b94be0e83dc0ad4706e39e11a661c86cdd592c1fb2d4d06061693754c27a43002", + "token": null + } + ], + "sequence": "575751310113475f3e0f8a3a34c5acf8f3abb87af5840eef8e35870a8942f3de9b3e16873b055f6d61696e020740000046c3230000000000d2496b00000000fbffbfffbfff0000b5389319075b0000fb819f34365474922b1c738a09fb346faa64a1363341889c08889cd66df4d7d2d9ffbd99687a7f1ab17a0403f5d9c64751f460edf1f3a64a0b806ffe91e4dca6fc6386687d4fa9f5db691f8af7f4094292ca6eef093950a7b039614525075754438988f601bc02dbf4b4b4aebd6d3c46ab518b84f6e137b83ed01b066a6e5900", + "outcome": "refuse", + "reason": "sequence_invalid", + "at": { + "place": "sequence" + }, + "match": null + }, + { + "name": "sequence_bad_signature", + "description": "session 1's sequence signed by a key other than its claimant's", + "room": "broadcast", + "records": [ + { + "record": "575753310113475f3e0f8a3a34c5acf8f3abb87af5840eef8e35870a8942f3de9b3e16873bcf329a11544d57c236135f220f7b77b7ce5c2bcfe97015b7d83e075b81b789f3055f6d61696e02025981acfea37ea8230ebe1eefade34de5a45d8ff9e6fb8223f8c66b401c1d83c100879303000000000059dd6e00000000cde4c7b9d37b39beb7b35fccacd6a7b2513b98f3ba97ff983c31fe8cb28c5fcb07014000ffffffffffffffffb5389319475bd3562c01000002000000931b38de9f7a751915773f5671327171983165ca667cef73b346d2a59c278ae54445449fde6a0000000030d693a14bbfd363e4f0e90d9bac8b588b623414ee8d3d50cc233fcb53c0ba8276924e7dd3ba5bf3f9ce06e639d265aa59ce351e2eb59b61cbff12f10c5e6809", + "token": null + }, + { + "record": "575753310113475f3e0f8a3a34c5acf8f3abb87af5840eef8e35870a8942f3de9b3e16873bcf329a11544d57c236135f220f7b77b7ce5c2bcfe97015b7d83e075b81b789f3055f6d61696e02025981acfea37ea8230ebe1eefade34de5a45d8ff9e6fb8223f8c66b401c1d83c100a3e1110000000000b1081900000000fc859f73eef73fb7ccf561bdfea3e55f616449253538f906ff7ab8432af12dc507024000fc00000000003fffd40000000000241f1400000000000000d64ebb0bab393058b7a9934f1df5da0ff4d729b210f0a06c406150b8f531abdb4445a499de6a00000000b54d24968f10d15a7829c1cbaa0ab445c80ec75c5d77a52d9b1d3628476ac2bb24e1e859b9741b41ee23bb63126a25d827248009d7c7d9c224ab16f4ba51a90f", + "token": null + }, + { + "record": "575753310113475f3e0f8a3a34c5acf8f3abb87af5840eef8e35870a8942f3de9b3e16873b0d4b3cdae998fa7ade7d7600d29db16f397e14dd56c2864263fb1144b6cd03d5055f6d61696e02025981acfea37ea8230ebe1eefade34de5a45d8ff9e6fb8223f8c66b401c1d83c10046c3230000000000180d8f000000001f53ed7edd447726ed72961b854ae198652d02bbe37035e420987b4c857146dd07034000ffffffffffffffff5120f915333929682c01000000000000b641fb80943018dd6f3620f7082aad9f4ab4325131a267106f153b939b2d58c9465260a1de6a000000007c47c1d9b4f57c7e8d87e7e1ac7137e1631ef6095a91052b9890ac32b19b19a354383c8bbf062205ab149adf8c76f17d92cadf9d01cfbe3bf05b4c047f13d80c", + "token": null + }, + { + "record": "575753310113475f3e0f8a3a34c5acf8f3abb87af5840eef8e35870a8942f3de9b3e16873b0d4b3cdae998fa7ade7d7600d29db16f397e14dd56c2864263fb1144b6cd03d5055f6d61696e02025981acfea37ea8230ebe1eefade34de5a45d8ff9e6fb8223f8c66b401c1d83c100180d8f00000000005ed0b20000000091fdbac2fe383e9153d13b0820e28d58f4dee0f9bacfaefacc67b28f8e1cf12a07014000ffffffffffffffff6bd0c1f00bd39dfd64000000000000006561878740fec7eb63a6758df07cedebcda05bb997a66797da86db89e2b5376a4652b8a3de6a000000002ba40624ece7170f6500343895606e2a029afa2d1871dc8581a2b15abd318a6b94be0e83dc0ad4706e39e11a661c86cdd592c1fb2d4d06061693754c27a43002", + "token": null + } + ], + "sequence": "575751310113475f3e0f8a3a34c5acf8f3abb87af5840eef8e35870a8942f3de9b3e16873b055f6d61696e020740000046c3230000000000d2496b00000000fbffbfffbfff0000b1389319075b0000fb819f34365474922b1c738a09fb346faa64a1363341889c08889cd66df4d7d2d9ffbd99687a7f1ab17a0403f5d9c64751f460edf1f3a64a0b806ffe91e4dca65e9b961c3e7a7d95dbe5c34afe7d3d4c060bbdab747cb108172469613e443d82e93379ec71ea7d65a6aa609712f7de442a5e633680ea2b9f8e3bd485cd89fe07", + "outcome": "refuse", + "reason": "bad_signature", + "at": { + "place": "sequence" + }, + "match": null + }, + { + "name": "sequence_of_another_room", + "description": "a sequence naming another room", + "room": "broadcast", + "records": [ + { + "record": "575753310113475f3e0f8a3a34c5acf8f3abb87af5840eef8e35870a8942f3de9b3e16873bcf329a11544d57c236135f220f7b77b7ce5c2bcfe97015b7d83e075b81b789f3055f6d61696e02025981acfea37ea8230ebe1eefade34de5a45d8ff9e6fb8223f8c66b401c1d83c100879303000000000059dd6e00000000cde4c7b9d37b39beb7b35fccacd6a7b2513b98f3ba97ff983c31fe8cb28c5fcb07014000ffffffffffffffffb5389319475bd3562c01000002000000931b38de9f7a751915773f5671327171983165ca667cef73b346d2a59c278ae54445449fde6a0000000030d693a14bbfd363e4f0e90d9bac8b588b623414ee8d3d50cc233fcb53c0ba8276924e7dd3ba5bf3f9ce06e639d265aa59ce351e2eb59b61cbff12f10c5e6809", + "token": null + }, + { + "record": "575753310113475f3e0f8a3a34c5acf8f3abb87af5840eef8e35870a8942f3de9b3e16873bcf329a11544d57c236135f220f7b77b7ce5c2bcfe97015b7d83e075b81b789f3055f6d61696e02025981acfea37ea8230ebe1eefade34de5a45d8ff9e6fb8223f8c66b401c1d83c100a3e1110000000000b1081900000000fc859f73eef73fb7ccf561bdfea3e55f616449253538f906ff7ab8432af12dc507024000fc00000000003fffd40000000000241f1400000000000000d64ebb0bab393058b7a9934f1df5da0ff4d729b210f0a06c406150b8f531abdb4445a499de6a00000000b54d24968f10d15a7829c1cbaa0ab445c80ec75c5d77a52d9b1d3628476ac2bb24e1e859b9741b41ee23bb63126a25d827248009d7c7d9c224ab16f4ba51a90f", + "token": null + }, + { + "record": "575753310113475f3e0f8a3a34c5acf8f3abb87af5840eef8e35870a8942f3de9b3e16873b0d4b3cdae998fa7ade7d7600d29db16f397e14dd56c2864263fb1144b6cd03d5055f6d61696e02025981acfea37ea8230ebe1eefade34de5a45d8ff9e6fb8223f8c66b401c1d83c10046c3230000000000180d8f000000001f53ed7edd447726ed72961b854ae198652d02bbe37035e420987b4c857146dd07034000ffffffffffffffff5120f915333929682c01000000000000b641fb80943018dd6f3620f7082aad9f4ab4325131a267106f153b939b2d58c9465260a1de6a000000007c47c1d9b4f57c7e8d87e7e1ac7137e1631ef6095a91052b9890ac32b19b19a354383c8bbf062205ab149adf8c76f17d92cadf9d01cfbe3bf05b4c047f13d80c", + "token": null + }, + { + "record": "575753310113475f3e0f8a3a34c5acf8f3abb87af5840eef8e35870a8942f3de9b3e16873b0d4b3cdae998fa7ade7d7600d29db16f397e14dd56c2864263fb1144b6cd03d5055f6d61696e02025981acfea37ea8230ebe1eefade34de5a45d8ff9e6fb8223f8c66b401c1d83c100180d8f00000000005ed0b20000000091fdbac2fe383e9153d13b0820e28d58f4dee0f9bacfaefacc67b28f8e1cf12a07014000ffffffffffffffff6bd0c1f00bd39dfd64000000000000006561878740fec7eb63a6758df07cedebcda05bb997a66797da86db89e2b5376a4652b8a3de6a000000002ba40624ece7170f6500343895606e2a029afa2d1871dc8581a2b15abd318a6b94be0e83dc0ad4706e39e11a661c86cdd592c1fb2d4d06061693754c27a43002", + "token": null + } + ], + "sequence": "575751310190b72587ed37038aa7d3e07747d1c9faf8c9791c3f38882f4ec2149bd7067bed055f6d61696e020740000046c3230000000000d2496b00000000fbffbfffbfff0000b1389319075b0000fb819f34365474922b1c738a09fb346faa64a1363341889c08889cd66df4d7d2d9ffbd99687a7f1ab17a0403f5d9c64751f460edf1f3a64a0b806ffe91e4dca6afc2c6f1ca61f2a71fedcef6d8395ffe3167258644ea1c18a5da1377a0b2d7520545dad6710b0bf8937ae0b3c0fc9f5de66515a9c8922441bb78ace5cf04910c", + "outcome": "refuse", + "reason": "wrong_room", + "at": { + "place": "sequence" + }, + "match": null + }, + { + "name": "sequence_truncated", + "description": "session 1's sequence without its last byte", + "room": "broadcast", + "records": [ + { + "record": "575753310113475f3e0f8a3a34c5acf8f3abb87af5840eef8e35870a8942f3de9b3e16873bcf329a11544d57c236135f220f7b77b7ce5c2bcfe97015b7d83e075b81b789f3055f6d61696e02025981acfea37ea8230ebe1eefade34de5a45d8ff9e6fb8223f8c66b401c1d83c100879303000000000059dd6e00000000cde4c7b9d37b39beb7b35fccacd6a7b2513b98f3ba97ff983c31fe8cb28c5fcb07014000ffffffffffffffffb5389319475bd3562c01000002000000931b38de9f7a751915773f5671327171983165ca667cef73b346d2a59c278ae54445449fde6a0000000030d693a14bbfd363e4f0e90d9bac8b588b623414ee8d3d50cc233fcb53c0ba8276924e7dd3ba5bf3f9ce06e639d265aa59ce351e2eb59b61cbff12f10c5e6809", + "token": null + }, + { + "record": "575753310113475f3e0f8a3a34c5acf8f3abb87af5840eef8e35870a8942f3de9b3e16873bcf329a11544d57c236135f220f7b77b7ce5c2bcfe97015b7d83e075b81b789f3055f6d61696e02025981acfea37ea8230ebe1eefade34de5a45d8ff9e6fb8223f8c66b401c1d83c100a3e1110000000000b1081900000000fc859f73eef73fb7ccf561bdfea3e55f616449253538f906ff7ab8432af12dc507024000fc00000000003fffd40000000000241f1400000000000000d64ebb0bab393058b7a9934f1df5da0ff4d729b210f0a06c406150b8f531abdb4445a499de6a00000000b54d24968f10d15a7829c1cbaa0ab445c80ec75c5d77a52d9b1d3628476ac2bb24e1e859b9741b41ee23bb63126a25d827248009d7c7d9c224ab16f4ba51a90f", + "token": null + }, + { + "record": "575753310113475f3e0f8a3a34c5acf8f3abb87af5840eef8e35870a8942f3de9b3e16873b0d4b3cdae998fa7ade7d7600d29db16f397e14dd56c2864263fb1144b6cd03d5055f6d61696e02025981acfea37ea8230ebe1eefade34de5a45d8ff9e6fb8223f8c66b401c1d83c10046c3230000000000180d8f000000001f53ed7edd447726ed72961b854ae198652d02bbe37035e420987b4c857146dd07034000ffffffffffffffff5120f915333929682c01000000000000b641fb80943018dd6f3620f7082aad9f4ab4325131a267106f153b939b2d58c9465260a1de6a000000007c47c1d9b4f57c7e8d87e7e1ac7137e1631ef6095a91052b9890ac32b19b19a354383c8bbf062205ab149adf8c76f17d92cadf9d01cfbe3bf05b4c047f13d80c", + "token": null + }, + { + "record": "575753310113475f3e0f8a3a34c5acf8f3abb87af5840eef8e35870a8942f3de9b3e16873b0d4b3cdae998fa7ade7d7600d29db16f397e14dd56c2864263fb1144b6cd03d5055f6d61696e02025981acfea37ea8230ebe1eefade34de5a45d8ff9e6fb8223f8c66b401c1d83c100180d8f00000000005ed0b20000000091fdbac2fe383e9153d13b0820e28d58f4dee0f9bacfaefacc67b28f8e1cf12a07014000ffffffffffffffff6bd0c1f00bd39dfd64000000000000006561878740fec7eb63a6758df07cedebcda05bb997a66797da86db89e2b5376a4652b8a3de6a000000002ba40624ece7170f6500343895606e2a029afa2d1871dc8581a2b15abd318a6b94be0e83dc0ad4706e39e11a661c86cdd592c1fb2d4d06061693754c27a43002", + "token": null + } + ], + "sequence": "575751310113475f3e0f8a3a34c5acf8f3abb87af5840eef8e35870a8942f3de9b3e16873b055f6d61696e020740000046c3230000000000d2496b00000000fbffbfffbfff0000b1389319075b0000fb819f34365474922b1c738a09fb346faa64a1363341889c08889cd66df4d7d2d9ffbd99687a7f1ab17a0403f5d9c64751f460edf1f3a64a0b806ffe91e4dca647d07da9f9c03dd51306360fd8755385cbb0c6e502921c6e96884c542908a75d163a0cbf46831817d28259d313cee6a3736672c9dda55fcc70dcf506be8e8b", + "outcome": "refuse", + "reason": "truncated", + "at": { + "place": "sequence" + }, + "match": null + } + ], + "disclosures": [ + { + "name": "disclosure_of_session_1", + "description": "the authority opens session 1 for the sequence that matched it; its token given", + "room": "broadcast", + "record": { + "record": "575753310113475f3e0f8a3a34c5acf8f3abb87af5840eef8e35870a8942f3de9b3e16873bcf329a11544d57c236135f220f7b77b7ce5c2bcfe97015b7d83e075b81b789f3055f6d61696e02025981acfea37ea8230ebe1eefade34de5a45d8ff9e6fb8223f8c66b401c1d83c100879303000000000059dd6e00000000cde4c7b9d37b39beb7b35fccacd6a7b2513b98f3ba97ff983c31fe8cb28c5fcb07014000ffffffffffffffffb5389319475bd3562c01000002000000931b38de9f7a751915773f5671327171983165ca667cef73b346d2a59c278ae54445449fde6a0000000030d693a14bbfd363e4f0e90d9bac8b588b623414ee8d3d50cc233fcb53c0ba8276924e7dd3ba5bf3f9ce06e639d265aa59ce351e2eb59b61cbff12f10c5e6809", + "token": "d18443a10105a05826a6041a6ad6af00061a6ad55b2819012c0119012d0719012e184019013048b5389319475bd35658207ea9ace87c093dc8460a5b20be105fbfa73c6459980efc4496bcf2b44fe62e8e" + }, + "sequence": "575751310113475f3e0f8a3a34c5acf8f3abb87af5840eef8e35870a8942f3de9b3e16873b055f6d61696e020740000046c3230000000000d2496b00000000fbffbfffbfff0000b1389319075b0000fb819f34365474922b1c738a09fb346faa64a1363341889c08889cd66df4d7d2d9ffbd99687a7f1ab17a0403f5d9c64751f460edf1f3a64a0b806ffe91e4dca647d07da9f9c03dd51306360fd8755385cbb0c6e502921c6e96884c542908a75d163a0cbf46831817d28259d313cee6a3736672c9dda55fcc70dcf506be8e8b09", + "disclosure": "575744310113475f3e0f8a3a34c5acf8f3abb87af5840eef8e35870a8942f3de9b3e16873b6abc05ea764183518560731925ec2d262afccc34df86799f8f75467475d1889cb756cec606497456d09f0dd4da3ee436ddea3bd6d513a2289d90b86314b6aa32781094311386b95b02af8fb1437ec9717b5c4df4e74382abe09d71bd38930d722cb2d52b2fa17fa5bbcaff468f1f31a41d0b0c28f9ce1c1fbb0eeef78ea50cf11a0366f49cf95f46f7c76d456638ac260ca830af7b27b5df8eca7d25d3eb35b900afd66a0000000031df0057cc9c77144be909ba583a88672e9b584b61fcf13c652d515abca689771865f6798d2478b34b9c445ca9139d66ddcb7e8c8d7afecac44df011fd191600", + "outcome": "accept", + "reason": null, + "at": null, + "facts": { + "digest": "1dd29be60b3cb02432539eb30d81a6a727390828e5bf2a34bb6de99b5c2dd713", + "session": "6abc05ea764183518560731925ec2d262afccc34df86799f8f75467475d1889c", + "sequence": "b756cec606497456d09f0dd4da3ee436ddea3bd6d513a2289d90b86314b6aa32", + "grant": "781094311386b95b02af8fb1437ec9717b5c4df4e74382abe09d71bd38930d72", + "principal": "2cb2d52b2fa17fa5bbcaff468f1f31a41d0b0c28f9ce1c1fbb0eeef78ea50cf1", + "stated": 45, + "token_given": true + } + }, + { + "name": "disclosure_without_token", + "description": "the same disclosure, checked without the token", + "room": "broadcast", + "record": { + "record": "575753310113475f3e0f8a3a34c5acf8f3abb87af5840eef8e35870a8942f3de9b3e16873bcf329a11544d57c236135f220f7b77b7ce5c2bcfe97015b7d83e075b81b789f3055f6d61696e02025981acfea37ea8230ebe1eefade34de5a45d8ff9e6fb8223f8c66b401c1d83c100879303000000000059dd6e00000000cde4c7b9d37b39beb7b35fccacd6a7b2513b98f3ba97ff983c31fe8cb28c5fcb07014000ffffffffffffffffb5389319475bd3562c01000002000000931b38de9f7a751915773f5671327171983165ca667cef73b346d2a59c278ae54445449fde6a0000000030d693a14bbfd363e4f0e90d9bac8b588b623414ee8d3d50cc233fcb53c0ba8276924e7dd3ba5bf3f9ce06e639d265aa59ce351e2eb59b61cbff12f10c5e6809", + "token": null + }, + "sequence": "575751310113475f3e0f8a3a34c5acf8f3abb87af5840eef8e35870a8942f3de9b3e16873b055f6d61696e020740000046c3230000000000d2496b00000000fbffbfffbfff0000b1389319075b0000fb819f34365474922b1c738a09fb346faa64a1363341889c08889cd66df4d7d2d9ffbd99687a7f1ab17a0403f5d9c64751f460edf1f3a64a0b806ffe91e4dca647d07da9f9c03dd51306360fd8755385cbb0c6e502921c6e96884c542908a75d163a0cbf46831817d28259d313cee6a3736672c9dda55fcc70dcf506be8e8b09", + "disclosure": "575744310113475f3e0f8a3a34c5acf8f3abb87af5840eef8e35870a8942f3de9b3e16873b6abc05ea764183518560731925ec2d262afccc34df86799f8f75467475d1889cb756cec606497456d09f0dd4da3ee436ddea3bd6d513a2289d90b86314b6aa32781094311386b95b02af8fb1437ec9717b5c4df4e74382abe09d71bd38930d722cb2d52b2fa17fa5bbcaff468f1f31a41d0b0c28f9ce1c1fbb0eeef78ea50cf11a0366f49cf95f46f7c76d456638ac260ca830af7b27b5df8eca7d25d3eb35b900afd66a0000000031df0057cc9c77144be909ba583a88672e9b584b61fcf13c652d515abca689771865f6798d2478b34b9c445ca9139d66ddcb7e8c8d7afecac44df011fd191600", + "outcome": "accept", + "reason": null, + "at": null, + "facts": { + "digest": "1dd29be60b3cb02432539eb30d81a6a727390828e5bf2a34bb6de99b5c2dd713", + "session": "6abc05ea764183518560731925ec2d262afccc34df86799f8f75467475d1889c", + "sequence": "b756cec606497456d09f0dd4da3ee436ddea3bd6d513a2289d90b86314b6aa32", + "grant": "781094311386b95b02af8fb1437ec9717b5c4df4e74382abe09d71bd38930d72", + "principal": "2cb2d52b2fa17fa5bbcaff468f1f31a41d0b0c28f9ce1c1fbb0eeef78ea50cf1", + "stated": 45, + "token_given": false + } + }, + { + "name": "disclosure_of_another_session", + "description": "a disclosure of session 3, checked against session 1's record", + "room": "broadcast", + "record": { + "record": "575753310113475f3e0f8a3a34c5acf8f3abb87af5840eef8e35870a8942f3de9b3e16873bcf329a11544d57c236135f220f7b77b7ce5c2bcfe97015b7d83e075b81b789f3055f6d61696e02025981acfea37ea8230ebe1eefade34de5a45d8ff9e6fb8223f8c66b401c1d83c100879303000000000059dd6e00000000cde4c7b9d37b39beb7b35fccacd6a7b2513b98f3ba97ff983c31fe8cb28c5fcb07014000ffffffffffffffffb5389319475bd3562c01000002000000931b38de9f7a751915773f5671327171983165ca667cef73b346d2a59c278ae54445449fde6a0000000030d693a14bbfd363e4f0e90d9bac8b588b623414ee8d3d50cc233fcb53c0ba8276924e7dd3ba5bf3f9ce06e639d265aa59ce351e2eb59b61cbff12f10c5e6809", + "token": null + }, + "sequence": "575751310113475f3e0f8a3a34c5acf8f3abb87af5840eef8e35870a8942f3de9b3e16873b055f6d61696e020740000046c3230000000000d2496b00000000fbffbfffbfff0000b1389319075b0000fb819f34365474922b1c738a09fb346faa64a1363341889c08889cd66df4d7d2d9ffbd99687a7f1ab17a0403f5d9c64751f460edf1f3a64a0b806ffe91e4dca647d07da9f9c03dd51306360fd8755385cbb0c6e502921c6e96884c542908a75d163a0cbf46831817d28259d313cee6a3736672c9dda55fcc70dcf506be8e8b09", + "disclosure": "575744310113475f3e0f8a3a34c5acf8f3abb87af5840eef8e35870a8942f3de9b3e16873be5cc27e9ddf93cc74793dd5d51c4df3ce3fecccbdec0392bac6c487ca72d9decb756cec606497456d09f0dd4da3ee436ddea3bd6d513a2289d90b86314b6aa3295b69a27559d9fd7d27eaeeda6964fadbb1e272ebc1335a02b79b4d9a66fd33f62d122c3c2774fd7447203d74703a0b240e91a7e8647f8fe746abc43d58676ed502a9db47467b57c48ad05fb70ce4ce0e78519285106954cd60937dfd59f692000afd66a000000008e21c9e7812f7ddfa4bd18e3e07b24b708623f68faee6841094749d8442f836e031c516cd13b4d6a78a56a3a6afe2338555df2f2049da26a1b9f8c1151d86809", + "outcome": "refuse", + "reason": "disclosure_mismatch", + "at": { + "place": "disclosure" + }, + "facts": null + }, + { + "name": "disclosure_for_another_sequence", + "description": "a disclosure answering another sequence than the one given", + "room": "broadcast", + "record": { + "record": "575753310113475f3e0f8a3a34c5acf8f3abb87af5840eef8e35870a8942f3de9b3e16873bcf329a11544d57c236135f220f7b77b7ce5c2bcfe97015b7d83e075b81b789f3055f6d61696e02025981acfea37ea8230ebe1eefade34de5a45d8ff9e6fb8223f8c66b401c1d83c100879303000000000059dd6e00000000cde4c7b9d37b39beb7b35fccacd6a7b2513b98f3ba97ff983c31fe8cb28c5fcb07014000ffffffffffffffffb5389319475bd3562c01000002000000931b38de9f7a751915773f5671327171983165ca667cef73b346d2a59c278ae54445449fde6a0000000030d693a14bbfd363e4f0e90d9bac8b588b623414ee8d3d50cc233fcb53c0ba8276924e7dd3ba5bf3f9ce06e639d265aa59ce351e2eb59b61cbff12f10c5e6809", + "token": null + }, + "sequence": "575751310113475f3e0f8a3a34c5acf8f3abb87af5840eef8e35870a8942f3de9b3e16873b055f6d61696e020740000046c3230000000000d2496b00000000fbffbfffbfff0000b1389319075b0000fb819f34365474922b1c738a09fb346faa64a1363341889c08889cd66df4d7d2d9ffbd99687a7f1ab17a0403f5d9c64751f460edf1f3a64a0b806ffe91e4dca647d07da9f9c03dd51306360fd8755385cbb0c6e502921c6e96884c542908a75d163a0cbf46831817d28259d313cee6a3736672c9dda55fcc70dcf506be8e8b09", + "disclosure": "575744310113475f3e0f8a3a34c5acf8f3abb87af5840eef8e35870a8942f3de9b3e16873b6abc05ea764183518560731925ec2d262afccc34df86799f8f75467475d1889cd57d2cd3d6ad9e99de2d51901ad649bfa4807e4ce6fa613ccecaf499c12966de781094311386b95b02af8fb1437ec9717b5c4df4e74382abe09d71bd38930d722cb2d52b2fa17fa5bbcaff468f1f31a41d0b0c28f9ce1c1fbb0eeef78ea50cf11a0366f49cf95f46f7c76d456638ac260ca830af7b27b5df8eca7d25d3eb35b900afd66a000000004ea230c2e7f847db7cc98594a06956a064c784b87c24c62b52f240aaaf813496f6281e9b2ec7d67f7a3dcce3dc0abd67c2fe4fe38580f70d4aacd6b58c92f002", + "outcome": "refuse", + "reason": "disclosure_mismatch", + "at": { + "place": "disclosure" + }, + "facts": null + }, + { + "name": "opening_of_another_session", + "description": "session 1 and its sequence named, with session 3's opening", + "room": "broadcast", + "record": { + "record": "575753310113475f3e0f8a3a34c5acf8f3abb87af5840eef8e35870a8942f3de9b3e16873bcf329a11544d57c236135f220f7b77b7ce5c2bcfe97015b7d83e075b81b789f3055f6d61696e02025981acfea37ea8230ebe1eefade34de5a45d8ff9e6fb8223f8c66b401c1d83c100879303000000000059dd6e00000000cde4c7b9d37b39beb7b35fccacd6a7b2513b98f3ba97ff983c31fe8cb28c5fcb07014000ffffffffffffffffb5389319475bd3562c01000002000000931b38de9f7a751915773f5671327171983165ca667cef73b346d2a59c278ae54445449fde6a0000000030d693a14bbfd363e4f0e90d9bac8b588b623414ee8d3d50cc233fcb53c0ba8276924e7dd3ba5bf3f9ce06e639d265aa59ce351e2eb59b61cbff12f10c5e6809", + "token": null + }, + "sequence": "575751310113475f3e0f8a3a34c5acf8f3abb87af5840eef8e35870a8942f3de9b3e16873b055f6d61696e020740000046c3230000000000d2496b00000000fbffbfffbfff0000b1389319075b0000fb819f34365474922b1c738a09fb346faa64a1363341889c08889cd66df4d7d2d9ffbd99687a7f1ab17a0403f5d9c64751f460edf1f3a64a0b806ffe91e4dca647d07da9f9c03dd51306360fd8755385cbb0c6e502921c6e96884c542908a75d163a0cbf46831817d28259d313cee6a3736672c9dda55fcc70dcf506be8e8b09", + "disclosure": "575744310113475f3e0f8a3a34c5acf8f3abb87af5840eef8e35870a8942f3de9b3e16873b6abc05ea764183518560731925ec2d262afccc34df86799f8f75467475d1889cb756cec606497456d09f0dd4da3ee436ddea3bd6d513a2289d90b86314b6aa3295b69a27559d9fd7d27eaeeda6964fadbb1e272ebc1335a02b79b4d9a66fd33f62d122c3c2774fd7447203d74703a0b240e91a7e8647f8fe746abc43d58676ed502a9db47467b57c48ad05fb70ce4ce0e78519285106954cd60937dfd59f692000afd66a00000000446081f8ab14df58f8f3509ce6d59c5463b3b29abce898c30d907e26940eff4535207204bf48fe8a41231c640edd88e64c6326339ed97e270cc005796d1cb109", + "outcome": "refuse", + "reason": "opening_mismatch", + "at": { + "place": "disclosure" + }, + "facts": null + }, + { + "name": "sequence_disagrees", + "description": "session 3 opened for session 1's sequence, which does not agree with it", + "room": "broadcast", + "record": { + "record": "575753310113475f3e0f8a3a34c5acf8f3abb87af5840eef8e35870a8942f3de9b3e16873b0d4b3cdae998fa7ade7d7600d29db16f397e14dd56c2864263fb1144b6cd03d5055f6d61696e02025981acfea37ea8230ebe1eefade34de5a45d8ff9e6fb8223f8c66b401c1d83c10046c3230000000000180d8f000000001f53ed7edd447726ed72961b854ae198652d02bbe37035e420987b4c857146dd07034000ffffffffffffffff5120f915333929682c01000000000000b641fb80943018dd6f3620f7082aad9f4ab4325131a267106f153b939b2d58c9465260a1de6a000000007c47c1d9b4f57c7e8d87e7e1ac7137e1631ef6095a91052b9890ac32b19b19a354383c8bbf062205ab149adf8c76f17d92cadf9d01cfbe3bf05b4c047f13d80c", + "token": null + }, + "sequence": "575751310113475f3e0f8a3a34c5acf8f3abb87af5840eef8e35870a8942f3de9b3e16873b055f6d61696e020740000046c3230000000000d2496b00000000fbffbfffbfff0000b1389319075b0000fb819f34365474922b1c738a09fb346faa64a1363341889c08889cd66df4d7d2d9ffbd99687a7f1ab17a0403f5d9c64751f460edf1f3a64a0b806ffe91e4dca647d07da9f9c03dd51306360fd8755385cbb0c6e502921c6e96884c542908a75d163a0cbf46831817d28259d313cee6a3736672c9dda55fcc70dcf506be8e8b09", + "disclosure": "575744310113475f3e0f8a3a34c5acf8f3abb87af5840eef8e35870a8942f3de9b3e16873be5cc27e9ddf93cc74793dd5d51c4df3ce3fecccbdec0392bac6c487ca72d9decb756cec606497456d09f0dd4da3ee436ddea3bd6d513a2289d90b86314b6aa3295b69a27559d9fd7d27eaeeda6964fadbb1e272ebc1335a02b79b4d9a66fd33f62d122c3c2774fd7447203d74703a0b240e91a7e8647f8fe746abc43d58676ed502a9db47467b57c48ad05fb70ce4ce0e78519285106954cd60937dfd59f692000afd66a000000008e21c9e7812f7ddfa4bd18e3e07b24b708623f68faee6841094749d8442f836e031c516cd13b4d6a78a56a3a6afe2338555df2f2049da26a1b9f8c1151d86809", + "outcome": "refuse", + "reason": "sequence_disagrees", + "at": { + "place": "disclosure" + }, + "facts": null + }, + { + "name": "grant_not_given", + "description": "the disclosure checked with every grant of the room but the one it opens to", + "room": "broadcast", + "room_override": { + "grants": [ + "575247310113475f3e0f8a3a34c5acf8f3abb87af5840eef8e35870a8942f3de9b3e16873bd510288a80239539921f8dc18ccca7ea45b168e11b10ed5d89e68d927685b7dd00000000000000000000000000000000000000000000000000000000000000000000000000000000704fd56a0000000000afd66a0000000000020102000000000000000000000000000000000000a65356094fa917ceecb8cab86548f81e574550310100000000000000000000000000000000000000000000000000000000000000000004f4013200000040420f00000000000100000040420f0000000000ffffffffffffffffffffffffffffffff00000000011268747470733a2f2f74762e6578616d706c650000365e19a3972f9d1cd58a09679d877fc1785fd9b4654e8338fd0719f56acf1da49f83c5198c023529b3ad4c4e0cda60b0f41af9e67e97f02e2d1bcf86a8552301", + "575247310113475f3e0f8a3a34c5acf8f3abb87af5840eef8e35870a8942f3de9b3e16873b3c0bb2a71c87d52373fba22cb6ba2e9e2412dd39fc0f5dcd5fd3deb7af90acc900000000000000000000000000000000000000000000000000000000000000000000000000000000704fd56a0000000000afd66a0000000001000102000000000000000000000000000000000000e8fd85d82893fbb80da381105c90f059574550310100000000000000000000000000000000000000000000000000000000000000000004f4013200000040420f00000000000100000040420f0000000000ffffffffffffffffffffffffffffffff00000000011268747470733a2f2f74762e6578616d706c650000847acb8e92f4c38dc6f71a0bdd64428f93bcb0bb00e6f1dd64b767d10b858a7b68f5cbb7821b169732f978d25aabaaba67f87e746984f2b9716d11f0347d930b", + "575247310113475f3e0f8a3a34c5acf8f3abb87af5840eef8e35870a8942f3de9b3e16873b62d122c3c2774fd7447203d74703a0b240e91a7e8647f8fe746abc43d58676ed00000000000000000000000000000000000000000000000000000000000000000000000000000000704fd56a0000000000afd66a00000000010001020000000000000000000000000000000000003bc73ee8f2cbcde3023aa5e1f887de84574550310100000000000000000000000000000000000000000000000000000000000000000004f4013200000040420f00000000000100000040420f0000000000ffffffffffffffffffffffffffffffff00000000011268747470733a2f2f74762e6578616d706c65000014e01c53a6ad6f0a697d2f3791abbefbab0036d8059203712a71aa772514553499fb3fe3eb0f28be80764d617d7a9c70a705aa9f3d852bca342628bc5ae4390a", + "575247310113475f3e0f8a3a34c5acf8f3abb87af5840eef8e35870a8942f3de9b3e16873b3f6e554e086d1f33ffe354eec65f1eadd8f770fb4bf2131f5774f52c754e1b9500000000000000000000000000000000000000000000000000000000000000000000000000000000704fd56a000000003062d56a0000000001000102000000000000000000000000000000000000ecdcba6481514803c669813a6dba1b26574550310100000000000000000000000000000000000000000000000000000000000000000004f4013200000040420f00000000000100000040420f0000000000ffffffffffffffffffffffffffffffff00000000011268747470733a2f2f74762e6578616d706c650000dfa4b0b898412e3fad702126a4f505592e23c95fd4f188d78c33139fe54fc8f786a1cba35b1e73fe2738650cf764551d9b30e87e638bb73fbe728886381b930d" + ] + }, + "record": { + "record": "575753310113475f3e0f8a3a34c5acf8f3abb87af5840eef8e35870a8942f3de9b3e16873bcf329a11544d57c236135f220f7b77b7ce5c2bcfe97015b7d83e075b81b789f3055f6d61696e02025981acfea37ea8230ebe1eefade34de5a45d8ff9e6fb8223f8c66b401c1d83c100879303000000000059dd6e00000000cde4c7b9d37b39beb7b35fccacd6a7b2513b98f3ba97ff983c31fe8cb28c5fcb07014000ffffffffffffffffb5389319475bd3562c01000002000000931b38de9f7a751915773f5671327171983165ca667cef73b346d2a59c278ae54445449fde6a0000000030d693a14bbfd363e4f0e90d9bac8b588b623414ee8d3d50cc233fcb53c0ba8276924e7dd3ba5bf3f9ce06e639d265aa59ce351e2eb59b61cbff12f10c5e6809", + "token": null + }, + "sequence": "575751310113475f3e0f8a3a34c5acf8f3abb87af5840eef8e35870a8942f3de9b3e16873b055f6d61696e020740000046c3230000000000d2496b00000000fbffbfffbfff0000b1389319075b0000fb819f34365474922b1c738a09fb346faa64a1363341889c08889cd66df4d7d2d9ffbd99687a7f1ab17a0403f5d9c64751f460edf1f3a64a0b806ffe91e4dca647d07da9f9c03dd51306360fd8755385cbb0c6e502921c6e96884c542908a75d163a0cbf46831817d28259d313cee6a3736672c9dda55fcc70dcf506be8e8b09", + "disclosure": "575744310113475f3e0f8a3a34c5acf8f3abb87af5840eef8e35870a8942f3de9b3e16873b6abc05ea764183518560731925ec2d262afccc34df86799f8f75467475d1889cb756cec606497456d09f0dd4da3ee436ddea3bd6d513a2289d90b86314b6aa32781094311386b95b02af8fb1437ec9717b5c4df4e74382abe09d71bd38930d722cb2d52b2fa17fa5bbcaff468f1f31a41d0b0c28f9ce1c1fbb0eeef78ea50cf11a0366f49cf95f46f7c76d456638ac260ca830af7b27b5df8eca7d25d3eb35b900afd66a0000000031df0057cc9c77144be909ba583a88672e9b584b61fcf13c652d515abca689771865f6798d2478b34b9c445ca9139d66ddcb7e8c8d7afecac44df011fd191600", + "outcome": "refuse", + "reason": "grant_unknown", + "at": { + "place": "disclosure" + }, + "facts": null + }, + { + "name": "grant_wrong_principal", + "description": "an edge's binding of one viewer's grant to another viewer's key, opened as committed", + "room": "broadcast", + "record": { + "record": "575753310113475f3e0f8a3a34c5acf8f3abb87af5840eef8e35870a8942f3de9b3e16873bcf329a11544d57c236135f220f7b77b7ce5c2bcfe97015b7d83e075b81b789f3055f6d61696e02025981acfea37ea8230ebe1eefade34de5a45d8ff9e6fb8223f8c66b401c1d83c100879303000000000059dd6e00000000cde4c7b9d37b39beb7b35fccacd6a7b2513b98f3ba97ff983c31fe8cb28c5fcb07014000ffffffffffffffffb5389319475bd3562c01000002000000e9aa9d57320ea8861e6f8a2dcfd157d47e37306363242dbae32392793d2e95184445449fde6a0000000081c0f48a3479cceecbc9a7db9636b4cd9379ed18725d820bd55200d79089f22d2dbfdb9eaf6747642b22454037d3f49915ad83c04303b380a98df94a84e6e804", + "token": null + }, + "sequence": "575751310113475f3e0f8a3a34c5acf8f3abb87af5840eef8e35870a8942f3de9b3e16873b055f6d61696e020740000046c3230000000000d2496b00000000fbffbfffbfff0000b1389319075b0000fb819f34365474922b1c738a09fb346faa64a1363341889c08889cd66df4d7d2d9ffbd99687a7f1ab17a0403f5d9c64751f460edf1f3a64a0b806ffe91e4dca647d07da9f9c03dd51306360fd8755385cbb0c6e502921c6e96884c542908a75d163a0cbf46831817d28259d313cee6a3736672c9dda55fcc70dcf506be8e8b09", + "disclosure": "575744310113475f3e0f8a3a34c5acf8f3abb87af5840eef8e35870a8942f3de9b3e16873bde700da1afa800447e6b66834ce92dc191c1b0045f13f09ae142f4423190a4f4b756cec606497456d09f0dd4da3ee436ddea3bd6d513a2289d90b86314b6aa32781094311386b95b02af8fb1437ec9717b5c4df4e74382abe09d71bd38930d723c0bb2a71c87d52373fba22cb6ba2e9e2412dd39fc0f5dcd5fd3deb7af90acc9394dc3bbbd65ebd05e313f3d6ed36a6e7d96aa1142979574d8c905c65b8e1cbc00afd66a0000000041cd042450fc1356a1a338772adb96c1c5ed6716f98f0dea034f9ebe84973584c9417666430b3a51723f6591f0b62b619828e56df32bec86561abfb898975c0c", + "outcome": "refuse", + "reason": "grant_wrong_principal", + "at": { + "place": "disclosure" + }, + "facts": null + }, + { + "name": "outside_grant_window", + "description": "a session that ran past the end of its viewer's grant", + "room": "broadcast", + "record": { + "record": "575753310113475f3e0f8a3a34c5acf8f3abb87af5840eef8e35870a8942f3de9b3e16873bcf329a11544d57c236135f220f7b77b7ce5c2bcfe97015b7d83e075b81b789f3055f6d61696e02025981acfea37ea8230ebe1eefade34de5a45d8ff9e6fb8223f8c66b401c1d83c10046c323000000000059dd6e000000004edff57c1be1434765ca499d98f01fcf25d2924fa57ec1f42935b941272f6ebc07014000ffffffffffffffff83590e83ebb206f3d200000000000000e2c18a0ce0e09e6a6a5187781dabcfc8e434275c17014309e1e3bbabc3b01c484445449fde6a000000001472e2972919101be39a2ddf4920967fb12cd6ed95eb98156df27b2f3950238e466e8009a7cb63fdd0f75f032aaa46c169403a6e73b0ab0615814ee20dbd2a0a", + "token": null + }, + "sequence": "575751310113475f3e0f8a3a34c5acf8f3abb87af5840eef8e35870a8942f3de9b3e16873b055f6d61696e020740000046c3230000000000d2496b00000000ffffffff0000000083590e830000000043998cbe6beccaf96c8a19f8f6c3cfc0dbaa914e46ce4de7fcbc23e875f66927d9ffbd99687a7f1ab17a0403f5d9c64751f460edf1f3a64a0b806ffe91e4dca61ec071ee303241a33cf4d1772aa10d3d91785646cde414b6f0d0a68bf0c998af2150c289c831ce51018ddf931fd0737629c24ff51661f3e27052ddacb812a30c", + "disclosure": "575744310113475f3e0f8a3a34c5acf8f3abb87af5840eef8e35870a8942f3de9b3e16873ba11c92862d234c02ed5f268f88bd9edf2e38b95284a690169d10703b3ba9eab93c04968f4a4924f9e3809c80c2cc48452c3666d2f384613e8f50caf78bd70a524659906a48c4aa1f703ed85778151d07ba191dd5fc260f35328a96a9020e79273f6e554e086d1f33ffe354eec65f1eadd8f770fb4bf2131f5774f52c754e1b9576c65a04071da5926cb744b2b057f0f6b1679c2e3720ee4a5fa554669c8f143200afd66a00000000299ac4d9b1a31ea3391e4fed5a35bbb122a4c6183064770e731c26ce141ff691f0aa7c92a81a3e63cfd5a0085cb7ea0c5c214eb067465a628ff4568d4ef95808", + "outcome": "refuse", + "reason": "outside_grant_window", + "at": { + "place": "disclosure" + }, + "facts": null + }, + { + "name": "disclosure_expired", + "description": "a disclosure signed one second after the record's expires", + "room": "broadcast", + "record": { + "record": "575753310113475f3e0f8a3a34c5acf8f3abb87af5840eef8e35870a8942f3de9b3e16873bcf329a11544d57c236135f220f7b77b7ce5c2bcfe97015b7d83e075b81b789f3055f6d61696e02025981acfea37ea8230ebe1eefade34de5a45d8ff9e6fb8223f8c66b401c1d83c100879303000000000059dd6e00000000cde4c7b9d37b39beb7b35fccacd6a7b2513b98f3ba97ff983c31fe8cb28c5fcb07014000ffffffffffffffffb5389319475bd3562c01000002000000931b38de9f7a751915773f5671327171983165ca667cef73b346d2a59c278ae54445449fde6a0000000030d693a14bbfd363e4f0e90d9bac8b588b623414ee8d3d50cc233fcb53c0ba8276924e7dd3ba5bf3f9ce06e639d265aa59ce351e2eb59b61cbff12f10c5e6809", + "token": null + }, + "sequence": "575751310113475f3e0f8a3a34c5acf8f3abb87af5840eef8e35870a8942f3de9b3e16873b055f6d61696e020740000046c3230000000000d2496b00000000fbffbfffbfff0000b1389319075b0000fb819f34365474922b1c738a09fb346faa64a1363341889c08889cd66df4d7d2d9ffbd99687a7f1ab17a0403f5d9c64751f460edf1f3a64a0b806ffe91e4dca647d07da9f9c03dd51306360fd8755385cbb0c6e502921c6e96884c542908a75d163a0cbf46831817d28259d313cee6a3736672c9dda55fcc70dcf506be8e8b09", + "disclosure": "575744310113475f3e0f8a3a34c5acf8f3abb87af5840eef8e35870a8942f3de9b3e16873b6abc05ea764183518560731925ec2d262afccc34df86799f8f75467475d1889cb756cec606497456d09f0dd4da3ee436ddea3bd6d513a2289d90b86314b6aa32781094311386b95b02af8fb1437ec9717b5c4df4e74382abe09d71bd38930d722cb2d52b2fa17fa5bbcaff468f1f31a41d0b0c28f9ce1c1fbb0eeef78ea50cf11a0366f49cf95f46f7c76d456638ac260ca830af7b27b5df8eca7d25d3eb35b9459fde6a000000008ea399a42b2cc7345a6cef0ade30ce9ba91474a3b1b02586a016ed96dae420f8c7566de00daf4a9e374ca2f3280230a204a8f45f27c955f6f3f20472d7865f03", + "outcome": "refuse", + "reason": "disclosure_expired", + "at": { + "place": "disclosure" + }, + "facts": null + }, + { + "name": "disclosure_bad_signature", + "description": "the disclosure signed by the edge, not the room authority", + "room": "broadcast", + "record": { + "record": "575753310113475f3e0f8a3a34c5acf8f3abb87af5840eef8e35870a8942f3de9b3e16873bcf329a11544d57c236135f220f7b77b7ce5c2bcfe97015b7d83e075b81b789f3055f6d61696e02025981acfea37ea8230ebe1eefade34de5a45d8ff9e6fb8223f8c66b401c1d83c100879303000000000059dd6e00000000cde4c7b9d37b39beb7b35fccacd6a7b2513b98f3ba97ff983c31fe8cb28c5fcb07014000ffffffffffffffffb5389319475bd3562c01000002000000931b38de9f7a751915773f5671327171983165ca667cef73b346d2a59c278ae54445449fde6a0000000030d693a14bbfd363e4f0e90d9bac8b588b623414ee8d3d50cc233fcb53c0ba8276924e7dd3ba5bf3f9ce06e639d265aa59ce351e2eb59b61cbff12f10c5e6809", + "token": null + }, + "sequence": "575751310113475f3e0f8a3a34c5acf8f3abb87af5840eef8e35870a8942f3de9b3e16873b055f6d61696e020740000046c3230000000000d2496b00000000fbffbfffbfff0000b1389319075b0000fb819f34365474922b1c738a09fb346faa64a1363341889c08889cd66df4d7d2d9ffbd99687a7f1ab17a0403f5d9c64751f460edf1f3a64a0b806ffe91e4dca647d07da9f9c03dd51306360fd8755385cbb0c6e502921c6e96884c542908a75d163a0cbf46831817d28259d313cee6a3736672c9dda55fcc70dcf506be8e8b09", + "disclosure": "575744310113475f3e0f8a3a34c5acf8f3abb87af5840eef8e35870a8942f3de9b3e16873b6abc05ea764183518560731925ec2d262afccc34df86799f8f75467475d1889cb756cec606497456d09f0dd4da3ee436ddea3bd6d513a2289d90b86314b6aa32781094311386b95b02af8fb1437ec9717b5c4df4e74382abe09d71bd38930d722cb2d52b2fa17fa5bbcaff468f1f31a41d0b0c28f9ce1c1fbb0eeef78ea50cf11a0366f49cf95f46f7c76d456638ac260ca830af7b27b5df8eca7d25d3eb35b900afd66a00000000911ca023610350269f9e95807c409fd69e255f6939b5e7401231f0d81a922eb05831982eab41ff18181681e843d9a1e7e60424076c8b6ae49792dc3e5fdab101", + "outcome": "refuse", + "reason": "bad_signature", + "at": { + "place": "disclosure" + }, + "facts": null + }, + { + "name": "disclosure_of_another_room", + "description": "a disclosure naming another room", + "room": "broadcast", + "record": { + "record": "575753310113475f3e0f8a3a34c5acf8f3abb87af5840eef8e35870a8942f3de9b3e16873bcf329a11544d57c236135f220f7b77b7ce5c2bcfe97015b7d83e075b81b789f3055f6d61696e02025981acfea37ea8230ebe1eefade34de5a45d8ff9e6fb8223f8c66b401c1d83c100879303000000000059dd6e00000000cde4c7b9d37b39beb7b35fccacd6a7b2513b98f3ba97ff983c31fe8cb28c5fcb07014000ffffffffffffffffb5389319475bd3562c01000002000000931b38de9f7a751915773f5671327171983165ca667cef73b346d2a59c278ae54445449fde6a0000000030d693a14bbfd363e4f0e90d9bac8b588b623414ee8d3d50cc233fcb53c0ba8276924e7dd3ba5bf3f9ce06e639d265aa59ce351e2eb59b61cbff12f10c5e6809", + "token": null + }, + "sequence": "575751310113475f3e0f8a3a34c5acf8f3abb87af5840eef8e35870a8942f3de9b3e16873b055f6d61696e020740000046c3230000000000d2496b00000000fbffbfffbfff0000b1389319075b0000fb819f34365474922b1c738a09fb346faa64a1363341889c08889cd66df4d7d2d9ffbd99687a7f1ab17a0403f5d9c64751f460edf1f3a64a0b806ffe91e4dca647d07da9f9c03dd51306360fd8755385cbb0c6e502921c6e96884c542908a75d163a0cbf46831817d28259d313cee6a3736672c9dda55fcc70dcf506be8e8b09", + "disclosure": "575744310190b72587ed37038aa7d3e07747d1c9faf8c9791c3f38882f4ec2149bd7067bed6abc05ea764183518560731925ec2d262afccc34df86799f8f75467475d1889cb756cec606497456d09f0dd4da3ee436ddea3bd6d513a2289d90b86314b6aa32781094311386b95b02af8fb1437ec9717b5c4df4e74382abe09d71bd38930d722cb2d52b2fa17fa5bbcaff468f1f31a41d0b0c28f9ce1c1fbb0eeef78ea50cf11a0366f49cf95f46f7c76d456638ac260ca830af7b27b5df8eca7d25d3eb35b900afd66a00000000accffb8f775003fa765b5fd863190a811732ce85119af5431d3136d3e121499f906df40c0c4b817ad8b2b333a3057dff40c2581e2b2cf0056b0b1ddd0413e20e", + "outcome": "refuse", + "reason": "wrong_room", + "at": { + "place": "disclosure" + }, + "facts": null + }, + { + "name": "disclosure_truncated", + "description": "the disclosure without its last byte", + "room": "broadcast", + "record": { + "record": "575753310113475f3e0f8a3a34c5acf8f3abb87af5840eef8e35870a8942f3de9b3e16873bcf329a11544d57c236135f220f7b77b7ce5c2bcfe97015b7d83e075b81b789f3055f6d61696e02025981acfea37ea8230ebe1eefade34de5a45d8ff9e6fb8223f8c66b401c1d83c100879303000000000059dd6e00000000cde4c7b9d37b39beb7b35fccacd6a7b2513b98f3ba97ff983c31fe8cb28c5fcb07014000ffffffffffffffffb5389319475bd3562c01000002000000931b38de9f7a751915773f5671327171983165ca667cef73b346d2a59c278ae54445449fde6a0000000030d693a14bbfd363e4f0e90d9bac8b588b623414ee8d3d50cc233fcb53c0ba8276924e7dd3ba5bf3f9ce06e639d265aa59ce351e2eb59b61cbff12f10c5e6809", + "token": null + }, + "sequence": "575751310113475f3e0f8a3a34c5acf8f3abb87af5840eef8e35870a8942f3de9b3e16873b055f6d61696e020740000046c3230000000000d2496b00000000fbffbfffbfff0000b1389319075b0000fb819f34365474922b1c738a09fb346faa64a1363341889c08889cd66df4d7d2d9ffbd99687a7f1ab17a0403f5d9c64751f460edf1f3a64a0b806ffe91e4dca647d07da9f9c03dd51306360fd8755385cbb0c6e502921c6e96884c542908a75d163a0cbf46831817d28259d313cee6a3736672c9dda55fcc70dcf506be8e8b09", + "disclosure": "575744310113475f3e0f8a3a34c5acf8f3abb87af5840eef8e35870a8942f3de9b3e16873b6abc05ea764183518560731925ec2d262afccc34df86799f8f75467475d1889cb756cec606497456d09f0dd4da3ee436ddea3bd6d513a2289d90b86314b6aa32781094311386b95b02af8fb1437ec9717b5c4df4e74382abe09d71bd38930d722cb2d52b2fa17fa5bbcaff468f1f31a41d0b0c28f9ce1c1fbb0eeef78ea50cf11a0366f49cf95f46f7c76d456638ac260ca830af7b27b5df8eca7d25d3eb35b900afd66a0000000031df0057cc9c77144be909ba583a88672e9b584b61fcf13c652d515abca689771865f6798d2478b34b9c445ca9139d66ddcb7e8c8d7afecac44df011fd1916", + "outcome": "refuse", + "reason": "truncated", + "at": { + "place": "disclosure" + }, + "facts": null + }, + { + "name": "disclosure_trailing", + "description": "the disclosure followed by one byte", + "room": "broadcast", + "record": { + "record": "575753310113475f3e0f8a3a34c5acf8f3abb87af5840eef8e35870a8942f3de9b3e16873bcf329a11544d57c236135f220f7b77b7ce5c2bcfe97015b7d83e075b81b789f3055f6d61696e02025981acfea37ea8230ebe1eefade34de5a45d8ff9e6fb8223f8c66b401c1d83c100879303000000000059dd6e00000000cde4c7b9d37b39beb7b35fccacd6a7b2513b98f3ba97ff983c31fe8cb28c5fcb07014000ffffffffffffffffb5389319475bd3562c01000002000000931b38de9f7a751915773f5671327171983165ca667cef73b346d2a59c278ae54445449fde6a0000000030d693a14bbfd363e4f0e90d9bac8b588b623414ee8d3d50cc233fcb53c0ba8276924e7dd3ba5bf3f9ce06e639d265aa59ce351e2eb59b61cbff12f10c5e6809", + "token": null + }, + "sequence": "575751310113475f3e0f8a3a34c5acf8f3abb87af5840eef8e35870a8942f3de9b3e16873b055f6d61696e020740000046c3230000000000d2496b00000000fbffbfffbfff0000b1389319075b0000fb819f34365474922b1c738a09fb346faa64a1363341889c08889cd66df4d7d2d9ffbd99687a7f1ab17a0403f5d9c64751f460edf1f3a64a0b806ffe91e4dca647d07da9f9c03dd51306360fd8755385cbb0c6e502921c6e96884c542908a75d163a0cbf46831817d28259d313cee6a3736672c9dda55fcc70dcf506be8e8b09", + "disclosure": "575744310113475f3e0f8a3a34c5acf8f3abb87af5840eef8e35870a8942f3de9b3e16873b6abc05ea764183518560731925ec2d262afccc34df86799f8f75467475d1889cb756cec606497456d09f0dd4da3ee436ddea3bd6d513a2289d90b86314b6aa32781094311386b95b02af8fb1437ec9717b5c4df4e74382abe09d71bd38930d722cb2d52b2fa17fa5bbcaff468f1f31a41d0b0c28f9ce1c1fbb0eeef78ea50cf11a0366f49cf95f46f7c76d456638ac260ca830af7b27b5df8eca7d25d3eb35b900afd66a0000000031df0057cc9c77144be909ba583a88672e9b584b61fcf13c652d515abca689771865f6798d2478b34b9c445ca9139d66ddcb7e8c8d7afecac44df011fd19160000", + "outcome": "refuse", + "reason": "malformed", + "at": { + "place": "disclosure" + }, + "facts": null + } + ] +} diff --git a/wai/wai-rs/Cargo.toml b/wai/wai-rs/Cargo.toml index 4f7b8b82a..b810cf1fb 100644 --- a/wai/wai-rs/Cargo.toml +++ b/wai/wai-rs/Cargo.toml @@ -239,6 +239,14 @@ energy_steering = ["live_metering"] # restricting decision as a statement of reasons in the DSA Transparency Database's format, # written by hand. SHA-256, Ed25519 and the key log only; no new dependency. live_moderation = ["live_events", "live_room", "live_record"] +# Forensic watermark sessions (extensions/forensic-watermark): `wai.live.watermark` — the edge's +# signed session record (WWS1: the positions of an ETSI TS 104 002 pattern it embedded, the variant +# at each, the token by its digest, the content keys, and a salted commitment to the session's grant +# and principal), the claimant's recovered sequence (WWQ1) and the exact rule that matches it, and +# the room authority's disclosure (WWD1) that opens one commitment. The TS's token is read with a +# deterministic-CBOR reader of its own and RFC 9052's COSE_Mac0 / COSE_Sign1 structure. SHA-256, +# Ed25519 and the key log only; no new dependency. +live_watermark = ["live_room"] # Timed text and live captions (extensions/timed-text): `wai.text.timed` — the cue model, # live caption revisions (`caption` events of wai.live.events with the WTX1 annotation), # and their exact mappings onto WebVTT documents and segments, the IMSC1 Text Profile, @@ -713,6 +721,11 @@ name = "wai_live_moderation_vectors" path = "src/bin/wai_live_moderation_vectors.rs" required-features = ["live_moderation"] +[[bin]] +name = "wai_live_watermark_vectors" +path = "src/bin/wai_live_watermark_vectors.rs" +required-features = ["live_watermark"] + [[bin]] name = "wai_timed_text_vectors" path = "src/bin/wai_timed_text_vectors.rs" @@ -1394,6 +1407,11 @@ name = "live_moderation" path = "tests/live_moderation.rs" required-features = ["live_moderation"] +[[test]] +name = "live_watermark" +path = "tests/live_watermark.rs" +required-features = ["live_watermark"] + [[test]] name = "timed_text" path = "tests/timed_text.rs" diff --git a/wai/wai-rs/src/bin/wai_live_watermark_vectors.rs b/wai/wai-rs/src/bin/wai_live_watermark_vectors.rs new file mode 100644 index 000000000..67b2ef107 --- /dev/null +++ b/wai/wai-rs/src/bin/wai_live_watermark_vectors.rs @@ -0,0 +1,74 @@ +//! Conformance corpus for forensic watermark sessions (`live-conformance/watermark/`, extensions/forensic-watermark.md §12). +//! +//! wai_live_watermark_vectors gen write vectors.json, from fixed seeds +//! wai_live_watermark_vectors verify every file regenerates byte for byte, no other +//! file but README.md is there, and every case +//! gets the outcome it states +//! +//! The generator and this verifier are one implementation. The corpus's second +//! implementation is `live-conformance/live_watermark_verify.py`, written from the extension's +//! text and sharing no code with this binary. + +use std::collections::BTreeMap; +use std::path::Path; +use wai::live_watermark::corpus; + +fn main() { + let a: Vec = std::env::args().collect(); + match (a.get(1).map(String::as_str), a.get(2)) { + (Some("gen"), Some(d)) => { + if let Err(e) = std::fs::create_dir_all(d) { + eprintln!("create {d}: {e}"); + std::process::exit(1); + } + for (name, body) in corpus::generate() { + let p = Path::new(d).join(&name); + if let Err(e) = std::fs::write(&p, body) { + eprintln!("write {}: {e}", p.display()); + std::process::exit(1); + } + } + println!("wrote {d}"); + } + (Some("verify"), Some(d)) => std::process::exit(verify(Path::new(d))), + _ => { + eprintln!("usage: wai_live_watermark_vectors gen|verify "); + std::process::exit(2); + } + } +} + +fn verify(dir: &Path) -> i32 { + let mut files = BTreeMap::new(); + let entries = match std::fs::read_dir(dir) { + Ok(e) => e, + Err(e) => { + eprintln!("read {}: {e}", dir.display()); + return 1; + } + }; + for e in entries.flatten() { + let name = e.file_name().to_string_lossy().into_owned(); + if name == "README.md" { + continue; + } + match std::fs::read_to_string(e.path()) { + Ok(s) => { + files.insert(name, s); + } + Err(err) => { + eprintln!("read {name}: {err}"); + return 1; + } + } + } + let generated: BTreeMap = corpus::generate().into_iter().collect(); + let same = files == generated; + println!("{} every file regenerates byte for byte from its seeds, and no other file is there", if same { "PASS" } else { "FAIL" }); + let fails = corpus::verify(&files); + for f in &fails { + println!("FAIL {f}"); + } + println!("{} every case gets the outcome the corpus states", if fails.is_empty() { "PASS" } else { "FAIL" }); + (!same || !fails.is_empty()) as i32 +} diff --git a/wai/wai-rs/src/codecs/registry.rs b/wai/wai-rs/src/codecs/registry.rs index 0917eeeab..d0d3362ec 100644 --- a/wai/wai-rs/src/codecs/registry.rs +++ b/wai/wai-rs/src/codecs/registry.rs @@ -219,6 +219,7 @@ pub const CAP_LIVE_STEERING: &str = "wai.live.steering"; pub const CAP_TEXT_TIMED: &str = "wai.text.timed"; pub const CAP_LIVE_MODERATION: &str = "wai.live.moderation"; pub const CAP_LIVE_E2EE: &str = "wai.live.e2ee"; +pub const CAP_LIVE_WATERMARK: &str = "wai.live.watermark"; /// One row per string SPEC §5 registers that is a payload, companion or record: /// `(string, SPEC section, edition, registered)`. @@ -306,6 +307,7 @@ const ROWS: &[(&str, &str, Option<&str>, &str)] = &[ (CAP_TEXT_TIMED, S_INTERACTIVE, None, "2026-10-17"), (CAP_LIVE_MODERATION, S_INTERACTIVE, None, "2026-10-19"), (CAP_LIVE_E2EE, S_INTERACTIVE, None, "2026-10-19"), + (CAP_LIVE_WATERMARK, S_INTERACTIVE, None, "2026-10-20"), (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"), @@ -356,6 +358,7 @@ const RECORDS: &[&str] = &[ CAP_VIDEO_MANIFEST, CAP_VIDEO_SSAI, CAP_VIDEO_KEYS, CAP_VIDEO_PACKAGE, CAP_VIDEO_CHANNEL, CAP_LIVE_EVENTS, CAP_LIVE_ROOM, CAP_CAPTURE_TAKE, CAP_LIVE_COMPOSE, CAP_LIVE_RECORDING, CAP_LIVE_METERING, CAP_LIVE_STEERING, CAP_TEXT_TIMED, CAP_LIVE_MODERATION, CAP_LIVE_E2EE, + CAP_LIVE_WATERMARK, ]; /// A named identifier SPEC uses that has the shape of a capability string but is @@ -510,6 +513,7 @@ const LIVE_METERING: &str = "extensions/live-metering.md"; const ENERGY_STEERING: &str = "extensions/energy-steering.md"; const LIVE_MODERATION: &str = "extensions/live-moderation.md"; const CONFIDENTIALITY: &str = "extensions/confidentiality.md"; +const FORENSIC_WATERMARK: &str = "extensions/forensic-watermark.md"; const fn wire(name: &'static str, kind: WireKind, document: &'static str, section: &'static str, status: WireStatus) -> WireIdentifier { @@ -519,8 +523,9 @@ 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), of packaging, of live events, of live rooms, of capture binding, of -/// live composition, of live recording, of timed text, of live metering, of energy steering -/// and of live moderation, in document order. The export lists them sorted by name and kind. +/// live composition, of live recording, of timed text, of live metering, of energy steering, +/// of live moderation, of confidentiality and of forensic watermark sessions, 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), @@ -601,6 +606,9 @@ pub const WIRE_IDENTIFIERS: &[WireIdentifier] = &[ wire("WRE1", WireKind::Magic, CONFIDENTIALITY, "§3.1", WireStatus::Defined), wire("SECURE_OBJECT_KEY_ID", WireKind::Catalog, CONFIDENTIALITY, "§4.3", WireStatus::Defined), wire("WAI_INNER_DIGEST", WireKind::Catalog, CONFIDENTIALITY, "§4.4", WireStatus::Defined), + wire("WWS1", WireKind::Magic, FORENSIC_WATERMARK, "§5.1", WireStatus::Defined), + wire("WWQ1", WireKind::Magic, FORENSIC_WATERMARK, "§7.1", WireStatus::Defined), + wire("WWD1", WireKind::Magic, FORENSIC_WATERMARK, "§8.1", WireStatus::Defined), ]; /// A string with the shape of a capability that the reference implementation uses diff --git a/wai/wai-rs/src/lib.rs b/wai/wai-rs/src/lib.rs index 0ddd880ed..17e3ce4cd 100644 --- a/wai/wai-rs/src/lib.rs +++ b/wai/wai-rs/src/lib.rs @@ -460,6 +460,14 @@ pub mod energy_steering; /// cbindgen:ignore #[cfg(feature = "live_moderation")] pub mod live_moderation; +// Forensic watermark sessions (extensions/forensic-watermark.md): `wai.live.watermark` — the edge's +// session record of the ETSI TS 104 002 A/B variants it embedded, bound to the session's grant by a +// salted commitment, the recovered sequence and the rule that matches it, and the room authority's +// disclosure that opens one session. Rust-only, like the other live modules: its constants must not +// become macros in include/wai.h. +/// cbindgen:ignore +#[cfg(feature = "live_watermark")] +pub mod live_watermark; // Timed text and live captions (extensions/timed-text.md): `wai.text.timed` — the cue model // both WebVTT and the IMSC1 Text Profile express, live caption revisions as `caption` // events of `wai.live.events` with the WTX1 annotation, and the exact mappings onto WebVTT diff --git a/wai/wai-rs/src/live_watermark.rs b/wai/wai-rs/src/live_watermark.rs new file mode 100644 index 000000000..03a3d90fd --- /dev/null +++ b/wai/wai-rs/src/live_watermark.rs @@ -0,0 +1,1564 @@ +//! Forensic watermark sessions — `wai.live.watermark` ([extensions/forensic-watermark.md]). +//! +//! An edge that chooses forensic A/B variants for a viewer of a room, as ETSI TS 104 002 has an +//! edge choose them, signs a **session record** (`WWS1`) when its part in the session ends: the +//! room and the content, the span, the content keys the variants were encrypted under, the +//! watermark token by its digest, the positions of the pattern it embedded with the variant at +//! each, and a salted commitment to the session's grant and principal that only the room +//! authority can open. A claimant states a variant sequence it recovered from a copy in a +//! signed **recovered sequence** (`WWQ1`), and a deterministic rule says which records it agrees +//! with. For one matched session the room authority signs a **disclosure** (`WWD1`) that opens +//! the commitment, which a third party checks against that one record and the grant it names. +//! +//! What this module implements, by section of the extension: +//! +//! - §3: Lane A only — a record names its content keys (`not_protected`), and a room with a +//! confidentiality record has none (`e2ee_room`); +//! - §4: the TS's token, read with a deterministic-CBOR reader of its own ([`cbor`]) and the +//! COSE_Mac0 / COSE_Sign1 structure of RFC 9052, and what a token gives; +//! - §5: the session record, its positions and bits, the binding, its signature, who may sign +//! it, and its token beside it; +//! - §6: the region pin and the retention bound; +//! - §7: the recovered sequence, candidates, agreement and the outcome of a match; +//! - §8, §9: the disclosure and verification. +//! +//! The corpus `live-conformance/watermark/` is written by [`corpus::generate`] and checked by +//! [`corpus::verify`], and by `live-conformance/live_watermark_verify.py`, which shares no code +//! with this module. +//! +//! [extensions/forensic-watermark.md]: ../../extensions/forensic-watermark.md + +use ed25519_dalek::{Signer, SigningKey}; +use sha2::{Digest, Sha256}; + +use crate::key_log::{EntryStatus, Revision}; +use crate::live_events as events; +use crate::live_room::{self as room, Descriptor, HeldGrant}; + +pub mod corpus; + +/// The capability string this extension registers. +pub const CAPABILITY: &str = "wai.live.watermark"; +/// The magic of a session record (§5.1). +pub const SESSION_MAGIC: [u8; 4] = *b"WWS1"; +/// The magic of a recovered sequence (§7.1). +pub const SEQUENCE_MAGIC: [u8; 4] = *b"WWQ1"; +/// The magic of a disclosure (§8.1). +pub const DISCLOSURE_MAGIC: [u8; 4] = *b"WWD1"; +/// The version of every record this revision defines. +pub const RECORD_VERSION: u8 = 1; +/// The domain a session record's signature is made under (§5.4). +pub const SESSION_DOMAIN: &[u8] = b"wai:live-watermark-session\x01"; +/// The domain a recovered sequence's signature is made under (§7.1). +pub const SEQUENCE_DOMAIN: &[u8] = b"wai:live-watermark-sequence\x01"; +/// The domain a disclosure's signature is made under (§8.2). +pub const DISCLOSURE_DOMAIN: &[u8] = b"wai:live-watermark-disclosure\x01"; +/// The domain of the binding commitment (§5.3). +pub const BINDING_DOMAIN: &[u8] = b"wai:live-watermark-binding\x01"; +/// The key-log authority an edge's key holds (§5.6). +pub const EDGE_AUTHORITY: &str = "live/watermark"; +/// The most content keys a record names (§5.1). +pub const MAX_KIDS: usize = 8; +/// The length of a disclosure (§8.1). +pub const DISCLOSURE_LEN: usize = 269; +/// The confidentiality record of confidentiality §3.1, which §9.1 step 2 reads. +pub const CONFIDENTIALITY_MAGIC: [u8; 4] = *b"WRE1"; +/// Its signature's domain (confidentiality §3.2). +pub const CONFIDENTIALITY_DOMAIN: &[u8] = b"wai:live-room-e2ee\x01"; +/// Its length (confidentiality §3.3 step 1). +pub const CONFIDENTIALITY_LEN: usize = 105; + +/// How the edge has the pattern (§4.3, §5.1). +pub mod source { + /// The token's `wmpattern`, in the clear. + pub const CLEAR: u8 = 1; + /// The token's `wmpattern`, decrypted. + pub const DECRYPTED: u8 = 2; + /// Derived by the vendor's core from `wmid`, `wmopid` and `wmkeyver` (indirect mode). + pub const DERIVED: u8 = 3; +} + +/// The claim keys the TS registers (its §5.4, Table 1) and the RFC 8392 claims §4.2 reads. +pub mod claim { + pub const ISS: u64 = 1; + pub const SUB: u64 = 2; + pub const AUD: u64 = 3; + pub const EXP: u64 = 4; + pub const NBF: u64 = 5; + pub const IAT: u64 = 6; + pub const CTI: u64 = 7; + pub const WMVER: u64 = 300; + pub const WMVND: u64 = 301; + pub const WMPATLEN: u64 = 302; + pub const WMSEGDURATION: u64 = 303; + pub const WMPATTERN: u64 = 304; + pub const WMID: u64 = 305; + pub const WMOPID: u64 = 306; + pub const WMKEYVER: u64 = 307; +} + +/// The CWT tag (RFC 8392 §6) and the COSE tags (RFC 9052 §2). +pub mod tag { + pub const CWT: u64 = 61; + pub const ENCRYPT0: u64 = 16; + pub const MAC0: u64 = 17; + pub const SIGN1: u64 = 18; + pub const ENCRYPT: u64 = 96; + pub const MAC: u64 = 97; + pub const SIGN: u64 = 98; + /// Every COSE tag. + pub const COSE: [u64; 6] = [ENCRYPT0, MAC0, SIGN1, ENCRYPT, MAC, SIGN]; +} + +/// The `alg` values §4.2 step 5 reads (RFC 9053 §3.1, §2.1). +pub mod alg { + pub const HMAC_256_256: i64 = 5; + pub const ES256: i64 = -7; +} + +/// Why a check refuses (§11). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Reason { + /// A code of live-rooms §11, or through it of live-events §9. + Room(room::Reason), + RecordInvalid, + NotProtected, + E2eeRoom, + EdgeNotAuthorised, + EdgeRevoked, + ContentUnknown, + RegionNotPinned, + RetentionExceeded, + TokenReused, + TokenMalformed, + TokenUnsupported, + TokenMismatch, + OutsideTokenWindow, + SequenceInvalid, + NoMatch, + AmbiguousMatch, + DisclosureMismatch, + SequenceDisagrees, + OpeningMismatch, + DisclosureExpired, +} + +impl Reason { + /// The codes this extension adds, in §11's order. + pub const ALL: [Reason; 20] = [ + Reason::RecordInvalid, + Reason::NotProtected, + Reason::E2eeRoom, + Reason::EdgeNotAuthorised, + Reason::EdgeRevoked, + Reason::ContentUnknown, + Reason::RegionNotPinned, + Reason::RetentionExceeded, + Reason::TokenReused, + Reason::TokenMalformed, + Reason::TokenUnsupported, + Reason::TokenMismatch, + Reason::OutsideTokenWindow, + Reason::SequenceInvalid, + Reason::NoMatch, + Reason::AmbiguousMatch, + Reason::DisclosureMismatch, + Reason::SequenceDisagrees, + Reason::OpeningMismatch, + Reason::DisclosureExpired, + ]; + + /// The reason's code. + pub fn code(self) -> &'static str { + match self { + Reason::Room(r) => r.code(), + Reason::RecordInvalid => "record_invalid", + Reason::NotProtected => "not_protected", + Reason::E2eeRoom => "e2ee_room", + Reason::EdgeNotAuthorised => "edge_not_authorised", + Reason::EdgeRevoked => "edge_revoked", + Reason::ContentUnknown => "content_unknown", + Reason::RegionNotPinned => "region_not_pinned", + Reason::RetentionExceeded => "retention_exceeded", + Reason::TokenReused => "token_reused", + Reason::TokenMalformed => "token_malformed", + Reason::TokenUnsupported => "token_unsupported", + Reason::TokenMismatch => "token_mismatch", + Reason::OutsideTokenWindow => "outside_token_window", + Reason::SequenceInvalid => "sequence_invalid", + Reason::NoMatch => "no_match", + Reason::AmbiguousMatch => "ambiguous_match", + Reason::DisclosureMismatch => "disclosure_mismatch", + Reason::SequenceDisagrees => "sequence_disagrees", + Reason::OpeningMismatch => "opening_mismatch", + Reason::DisclosureExpired => "disclosure_expired", + } + } +} + +impl std::fmt::Display for Reason { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(self.code()) + } +} + +impl std::error::Error for Reason {} + +fn ev(r: events::Reason) -> Reason { + Reason::Room(room::Reason::Events(r)) +} + +fn sig_reason(e: crate::sig::SigError) -> Reason { + match e { + crate::sig::SigError::BadKey => ev(events::Reason::BadKey), + crate::sig::SigError::BadSignature => ev(events::Reason::BadSignature), + } +} + +fn sha256(parts: &[&[u8]]) -> [u8; 32] { + let mut h = Sha256::new(); + for p in parts { + h.update(p); + } + h.finalize().into() +} + +fn hex(b: &[u8]) -> String { + b.iter().map(|x| format!("{x:02x}")).collect() +} + +fn verify(key: &[u8; 32], domain: &[u8], body: &[u8], sig: &[u8; 64]) -> Result<(), Reason> { + let mut m = domain.to_vec(); + m.extend_from_slice(body); + crate::sig::verify_strict(key, &m, sig).map_err(sig_reason) +} + +fn sign(key: &SigningKey, domain: &[u8], body: &[u8]) -> [u8; 64] { + let mut m = domain.to_vec(); + m.extend_from_slice(body); + key.sign(&m).to_bytes() +} + +// --------------------------------------------------------------------------------------- +// Bitmaps (§2) +// --------------------------------------------------------------------------------------- + +/// The bytes of a bitmap of a pattern of `patlen` bits. +pub fn bitmap_len(patlen: u16) -> usize { + (patlen as usize).div_ceil(8) +} + +/// Bit `p`, big-endian: bit 7 − (p mod 8) of byte ⌊p / 8⌋ (the TS's §5.4). +pub fn bit(map: &[u8], p: usize) -> bool { + map.get(p / 8).is_some_and(|b| b >> (7 - p % 8) & 1 == 1) +} + +/// Set bit `p` of `map` to `v`. +pub fn set_bit(map: &mut [u8], p: usize, v: bool) { + let m = 1u8 << (7 - p % 8); + if v { + map[p / 8] |= m; + } else { + map[p / 8] &= !m; + } +} + +/// Whether `map` is a bitmap of `patlen` bits: its length, and its bits from `patlen` on 0. +pub fn is_bitmap(map: &[u8], patlen: u16) -> bool { + map.len() == bitmap_len(patlen) && (patlen as usize..map.len() * 8).all(|p| !bit(map, p)) +} + +/// Whether `a` sets no bit `b` does not. +pub fn within(a: &[u8], b: &[u8]) -> bool { + a.len() == b.len() && a.iter().zip(b).all(|(x, y)| x & !y == 0) +} + +/// The positions `map` sets. +pub fn count(map: &[u8]) -> u32 { + map.iter().map(|b| b.count_ones()).sum() +} + +// --------------------------------------------------------------------------------------- +// §4.2 step 1: deterministically encoded CBOR +// --------------------------------------------------------------------------------------- + +/// A reader of deterministically encoded CBOR (RFC 8949 §4.2.1), as §4.2 step 1 has it. +pub mod cbor { + /// The deepest an item may be nested (§4.2 step 1). + pub const MAX_DEPTH: usize = 16; + + /// A data item. + #[derive(Clone, Debug, PartialEq, Eq)] + pub enum Item { + Uint(u64), + /// −1 − n. + Nint(u64), + Bytes(Vec), + Text(String), + Array(Vec), + Map(Vec<(Item, Item)>), + Tag(u64, Box), + False, + True, + Null, + } + + impl Item { + /// The item as an integer, when it is one that fits an i64 (labels and `alg`). + pub fn int(&self) -> Option { + match self { + Item::Uint(v) => i64::try_from(*v).ok(), + Item::Nint(v) => i64::try_from(*v).ok().map(|n| -1 - n), + _ => None, + } + } + + /// The value of map `self` under the unsigned key `k`. + pub fn get(&self, k: u64) -> Option<&Item> { + match self { + Item::Map(m) => m.iter().find(|(key, _)| *key == Item::Uint(k)).map(|(_, v)| v), + _ => None, + } + } + } + + /// Why an item does not read: a form the RFCs forbid, or one this revision does not read. + #[derive(Clone, Copy, Debug, PartialEq, Eq)] + pub enum Error { + Malformed, + Unsupported, + } + + /// Read `b` as one data item with nothing after it. + pub fn read_one(b: &[u8]) -> Result { + let mut r = Reader { b, p: 0 }; + let item = r.item(1)?; + if r.p != b.len() { + return Err(Error::Malformed); + } + Ok(item) + } + + struct Reader<'a> { + b: &'a [u8], + p: usize, + } + + impl Reader<'_> { + fn take(&mut self, n: usize) -> Result<&[u8], Error> { + if self.b.len() - self.p < n { + return Err(Error::Malformed); + } + let s = &self.b[self.p..self.p + n]; + self.p += n; + Ok(s) + } + + fn byte(&mut self) -> Result { + Ok(self.take(1)?[0]) + } + + /// The argument of a head, written in its shortest form. + fn arg(&mut self, ai: u8) -> Result { + let (n, least) = match ai { + 0..=23 => return Ok(ai as u64), + 24 => (1, 24), + 25 => (2, 256), + 26 => (4, 65_536), + _ => (8, 1 << 32), + }; + let v = self.take(n)?.iter().fold(0u64, |a, &x| a << 8 | x as u64); + if v < least { + return Err(Error::Malformed); + } + Ok(v) + } + + fn item(&mut self, depth: usize) -> Result { + if depth > MAX_DEPTH { + return Err(Error::Unsupported); + } + let ib = self.byte()?; + let (major, ai) = (ib >> 5, ib & 31); + if ai >= 28 { + return Err(Error::Malformed); + } + if major == 7 { + return match ai { + 20 => Ok(Item::False), + 21 => Ok(Item::True), + 22 => Ok(Item::Null), + 24 => Err(if self.byte()? < 32 { Error::Malformed } else { Error::Unsupported }), + _ => Err(Error::Unsupported), + }; + } + let arg = self.arg(ai)?; + match major { + 0 => Ok(Item::Uint(arg)), + 1 => Ok(Item::Nint(arg)), + 2 | 3 => { + let n = usize::try_from(arg).map_err(|_| Error::Malformed)?; + let s = self.take(n)?.to_vec(); + if major == 2 { + Ok(Item::Bytes(s)) + } else { + String::from_utf8(s).map(Item::Text).map_err(|_| Error::Malformed) + } + } + 4 => { + let mut v = Vec::new(); + for _ in 0..arg { + v.push(self.item(depth + 1)?); + } + Ok(Item::Array(v)) + } + 5 => { + let mut v: Vec<(Item, Item)> = Vec::new(); + let mut prev: Option<(usize, usize)> = None; + for _ in 0..arg { + let s = self.p; + let k = self.item(depth + 1)?; + let e = self.p; + if prev.is_some_and(|(a, b)| self.b[a..b] >= self.b[s..e]) { + return Err(Error::Malformed); + } + prev = Some((s, e)); + let val = self.item(depth + 1)?; + v.push((k, val)); + } + Ok(Item::Map(v)) + } + _ => Ok(Item::Tag(arg, Box::new(self.item(depth + 1)?))), + } + } + } + + /// Write `item` in deterministic encoding (for the corpus's tokens and the tests). + pub fn write(item: &Item, o: &mut Vec) { + fn head(o: &mut Vec, major: u8, v: u64) { + let m = major << 5; + if v < 24 { + o.push(m | v as u8); + } else if v < 256 { + o.extend_from_slice(&[m | 24, v as u8]); + } else if v < 65_536 { + o.push(m | 25); + o.extend_from_slice(&(v as u16).to_be_bytes()); + } else if v < 1 << 32 { + o.push(m | 26); + o.extend_from_slice(&(v as u32).to_be_bytes()); + } else { + o.push(m | 27); + o.extend_from_slice(&v.to_be_bytes()); + } + } + match item { + Item::Uint(v) => head(o, 0, *v), + Item::Nint(v) => head(o, 1, *v), + Item::Bytes(b) => { + head(o, 2, b.len() as u64); + o.extend_from_slice(b); + } + Item::Text(t) => { + head(o, 3, t.len() as u64); + o.extend_from_slice(t.as_bytes()); + } + Item::Array(a) => { + head(o, 4, a.len() as u64); + for x in a { + write(x, o); + } + } + Item::Map(m) => { + let mut entries: Vec<(Vec, Vec)> = m + .iter() + .map(|(k, v)| { + let (mut kb, mut vb) = (Vec::new(), Vec::new()); + write(k, &mut kb); + write(v, &mut vb); + (kb, vb) + }) + .collect(); + entries.sort(); + head(o, 5, entries.len() as u64); + for (k, v) in entries { + o.extend_from_slice(&k); + o.extend_from_slice(&v); + } + } + Item::Tag(t, x) => { + head(o, 6, *t); + write(x, o); + } + Item::False => o.push(0xf4), + Item::True => o.push(0xf5), + Item::Null => o.push(0xf6), + } + } + + /// `item`'s deterministic encoding. + pub fn encode(item: &Item) -> Vec { + let mut o = Vec::new(); + write(item, &mut o); + o + } +} + +use cbor::Item; + +// --------------------------------------------------------------------------------------- +// §4 The watermark token +// --------------------------------------------------------------------------------------- + +/// What a token gives (§4.3). +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Token { + /// Whether it was wrapped in the CWT tag. + pub cwt_tag: bool, + pub alg: i64, + pub vendor: u8, + pub patlen: u16, + /// 1 in the clear, 2 encrypted, 3 derived ([`source`]). + pub source: u8, + /// The pattern, where it is in the clear. + pub pattern: Option>, + pub iat: u64, + pub exp: u64, + pub nbf: Option, +} + +impl Token { + /// The token's window holds microsecond `u` (§4.3). + pub fn good_at(&self, u: u128) -> bool { + let from = self.iat.max(self.nbf.unwrap_or(0)) as u128 * 1_000_000; + from <= u && u < self.exp as u128 * 1_000_000 + } +} + +fn tok(e: cbor::Error) -> Reason { + match e { + cbor::Error::Malformed => Reason::TokenMalformed, + cbor::Error::Unsupported => Reason::TokenUnsupported, + } +} + +fn uint(i: Option<&Item>) -> Option { + match i { + Some(Item::Uint(v)) => Some(*v), + _ => None, + } +} + +/// Read a token's bytes (§4.2). +pub fn read_token(b: &[u8]) -> Result { + use Reason::{TokenMalformed as Mal, TokenUnsupported as Uns}; + // Step 1. + let mut item = cbor::read_one(b).map_err(tok)?; + // Step 2. + let mut cwt_tag = false; + if let Item::Tag(tag::CWT, inner) = item { + if !matches!(*inner, Item::Tag(t, _) if tag::COSE.contains(&t)) { + return Err(Mal); + } + cwt_tag = true; + item = *inner; + } + // Step 3. + let (t, body) = match item { + Item::Tag(t @ (tag::MAC0 | tag::SIGN1), body) => (t, *body), + _ => return Err(Uns), + }; + // Step 4. + let Item::Array(parts) = body else { return Err(Mal) }; + let [Item::Bytes(protected), Item::Map(unprotected), payload, Item::Bytes(mac)] = parts.as_slice() else { return Err(Mal) }; + let payload = match payload { + Item::Bytes(p) => p, + Item::Null => return Err(Uns), + _ => return Err(Mal), + }; + // Step 5. + let protected: Vec<(Item, Item)> = if protected.is_empty() { + Vec::new() + } else { + match cbor::read_one(protected).map_err(tok)? { + Item::Map(m) => m, + _ => return Err(Mal), + } + }; + let label = |k: &Item| k.int().filter(|l| *l == 1 || *l == 4); + if protected.iter().chain(unprotected).any(|(k, _)| label(k).is_none()) { + return Err(Uns); + } + if protected.iter().any(|(k, _)| unprotected.iter().any(|(u, _)| u == k)) { + return Err(Mal); + } + let Some((_, a)) = protected.iter().find(|(k, _)| k.int() == Some(1)) else { return Err(Mal) }; + let alg = match a.int() { + Some(a @ (alg::HMAC_256_256 | alg::ES256)) => a, + _ => return Err(Uns), + }; + let fits = (alg == alg::HMAC_256_256 && t == tag::MAC0 && mac.len() == 32) || (alg == alg::ES256 && t == tag::SIGN1 && mac.len() == 64); + if !fits { + return Err(Mal); + } + if protected.iter().chain(unprotected).any(|(k, v)| k.int() == Some(4) && !matches!(v, Item::Bytes(_))) { + return Err(Mal); + } + // Step 6. + let claims = cbor::read_one(payload).map_err(tok)?; + if matches!(claims, Item::Tag(t, _) if t == tag::CWT || tag::COSE.contains(&t)) { + return Err(Uns); + } + let Item::Map(entries) = &claims else { return Err(Mal) }; + if entries.iter().any(|(k, _)| !matches!(k, Item::Uint(_) | Item::Nint(_))) { + return Err(Mal); + } + // Step 7. + let get = |k: u64| claims.get(k); + let present_uint = |k: u64| -> Result, Reason> { + match get(k) { + None => Ok(None), + Some(Item::Uint(v)) => Ok(Some(*v)), + Some(_) => Err(Mal), + } + }; + // 7.1 + let exp = uint(get(claim::EXP)).ok_or(Mal)?; + let iat = uint(get(claim::IAT)).ok_or(Mal)?; + let nbf = present_uint(claim::NBF)?; + // 7.2 + for k in [claim::ISS, claim::SUB, claim::AUD] { + if get(k).is_some_and(|v| !matches!(v, Item::Text(_))) { + return Err(Mal); + } + } + if get(claim::CTI).is_some_and(|v| !matches!(v, Item::Bytes(_))) { + return Err(Mal); + } + // 7.3 + if uint(get(claim::WMVER)).ok_or(Mal)? != 1 { + return Err(Uns); + } + // 7.4 + let vendor = uint(get(claim::WMVND)).filter(|v| *v <= 255).ok_or(Mal)? as u8; + let patlen = uint(get(claim::WMPATLEN)).filter(|v| *v <= 65_535).ok_or(Mal)? as u16; + // 7.5 + if let Some(d) = get(claim::WMSEGDURATION) { + if !matches!(d, Item::Array(a) if a.len() == 2 && a.iter().all(|x| matches!(x, Item::Uint(_)))) { + return Err(Mal); + } + } + if get(claim::WMID).is_some_and(|v| !matches!(v, Item::Text(_))) { + return Err(Mal); + } + present_uint(claim::WMOPID)?; + present_uint(claim::WMKEYVER)?; + // 7.6 + let (source, pattern) = match get(claim::WMPATTERN) { + Some(Item::Bytes(p)) if p.len() == bitmap_len(patlen) => (source::CLEAR, Some(p.clone())), + Some(Item::Array(a)) if encrypted_shape(a) => (source::DECRYPTED, None), + Some(_) => return Err(Mal), + None => { + if get(claim::WMID).is_none() || get(claim::WMOPID).is_none() || get(claim::WMKEYVER).is_none() { + return Err(Mal); + } + (source::DERIVED, None) + } + }; + Ok(Token { cwt_tag, alg, vendor, patlen, source, pattern, iat, exp, nbf }) +} + +/// COSE_Encrypt0 (RFC 9052 §5.2) or COSE_Encrypt (§5.1), untagged (§4.2 step 7.6). +fn encrypted_shape(a: &[Item]) -> bool { + let head = a.len() >= 3 && matches!(a[0], Item::Bytes(_)) && matches!(a[1], Item::Map(_)) && matches!(a[2], Item::Bytes(_) | Item::Null); + match a.len() { + 3 => head, + 4 => head && matches!(&a[3], Item::Array(r) if !r.is_empty()), + _ => false, + } +} + +// --------------------------------------------------------------------------------------- +// §5 The session record +// --------------------------------------------------------------------------------------- + +/// A session record (§5.1). +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Session { + pub room: [u8; 32], + pub edge: [u8; 32], + /// The owner field, as bytes (§5.5 checks its form). + pub owner: Vec, + pub media: u8, + pub kids: Vec<[u8; 16]>, + pub start: u64, + pub end: u64, + pub token: [u8; 32], + pub vendor: u8, + pub source: u8, + pub patlen: u16, + pub served: Vec, + pub bits: Vec, + pub segments: u32, + pub unmarked: u32, + pub binding: [u8; 32], + pub region: [u8; 2], + pub expires: u64, + pub sig: [u8; 64], +} + +/// A cursor that reports running out as `truncated`. +struct Rd<'a> { + b: &'a [u8], + p: usize, +} + +impl<'a> Rd<'a> { + fn take(&mut self, n: usize) -> Result<&'a [u8], Reason> { + if self.b.len() - self.p < n { + return Err(ev(events::Reason::Truncated)); + } + let s = &self.b[self.p..self.p + n]; + self.p += n; + Ok(s) + } + fn arr(&mut self) -> Result<[u8; N], Reason> { + Ok(self.take(N)?.try_into().expect("N bytes")) + } + fn u8(&mut self) -> Result { + Ok(self.take(1)?[0]) + } + fn u16(&mut self) -> Result { + Ok(u16::from_le_bytes(self.arr()?)) + } + fn u32(&mut self) -> Result { + Ok(u32::from_le_bytes(self.arr()?)) + } + fn u64(&mut self) -> Result { + Ok(u64::from_le_bytes(self.arr()?)) + } + fn short(&mut self) -> Result<&'a [u8], Reason> { + let n = self.u8()? as usize; + self.take(n) + } +} + +/// §5.5 steps 1 and 3's head and tail: the magic and the version. +fn read_head(b: &[u8], magic: [u8; 4]) -> Result<(), Reason> { + if b.len() < 4 { + return Err(ev(events::Reason::Truncated)); + } + if b[..4] != magic { + return Err(ev(events::Reason::Malformed)); + } + match b.get(4) { + None => Err(ev(events::Reason::Truncated)), + Some(&RECORD_VERSION) => Ok(()), + Some(_) => Err(ev(events::Reason::UnknownVersion)), + } +} + +/// `owner` is 64 lowercase hexadecimal digits or `_` and a slot name (§5.5); `media` 1 to 3. +fn content_form_ok(owner: &[u8], media: u8) -> bool { + let hex_key = owner.len() == 64 && owner.iter().all(|c| c.is_ascii_digit() || (b'a'..=b'f').contains(c)); + let slot = owner.first() == Some(&b'_') && room::slot_name_ok(&owner[1..]); + (hex_key || slot) && (1..=3).contains(&media) +} + +impl Session { + /// The bytes before `sig`. + pub fn body(&self) -> Vec { + let mut o = Vec::new(); + o.extend_from_slice(&SESSION_MAGIC); + o.push(RECORD_VERSION); + o.extend_from_slice(&self.room); + o.extend_from_slice(&self.edge); + o.push(self.owner.len() as u8); + o.extend_from_slice(&self.owner); + o.push(self.media); + o.push(self.kids.len() as u8); + for k in &self.kids { + o.extend_from_slice(k); + } + o.extend_from_slice(&self.start.to_le_bytes()); + o.extend_from_slice(&self.end.to_le_bytes()); + o.extend_from_slice(&self.token); + o.push(self.vendor); + o.push(self.source); + o.extend_from_slice(&self.patlen.to_le_bytes()); + o.extend_from_slice(&self.served); + o.extend_from_slice(&self.bits); + o.extend_from_slice(&self.segments.to_le_bytes()); + o.extend_from_slice(&self.unmarked.to_le_bytes()); + o.extend_from_slice(&self.binding); + o.extend_from_slice(&self.region); + o.extend_from_slice(&self.expires.to_le_bytes()); + o + } + + /// The record's bytes. + pub fn to_bytes(&self) -> Vec { + let mut o = self.body(); + o.extend_from_slice(&self.sig); + o + } + + /// The record's digest (§5.4). + pub fn digest(&self) -> [u8; 32] { + sha256(&[&self.body()]) + } + + /// Sign with `edge` (§5.4). It checks nothing: the corpus signs records that break rules. + pub fn sign(mut self, edge: &SigningKey) -> Session { + self.sig = sign(edge, SESSION_DOMAIN, &self.body()); + self + } + + /// Read a record (§5.5): its layout, then its rules. + pub fn read(b: &[u8]) -> Result { + read_head(b, SESSION_MAGIC)?; + let mut r = Rd { b, p: 5 }; + let room = r.arr()?; + let edge = r.arr()?; + let owner = r.short()?.to_vec(); + let media = r.u8()?; + let n = r.u8()? as usize; + let mut kids = Vec::with_capacity(n); + for _ in 0..n { + kids.push(r.arr()?); + } + let start = r.u64()?; + let end = r.u64()?; + let token = r.arr()?; + let vendor = r.u8()?; + let source = r.u8()?; + let patlen = r.u16()?; + let served = r.take(bitmap_len(patlen))?.to_vec(); + let bits = r.take(bitmap_len(patlen))?.to_vec(); + let segments = r.u32()?; + let unmarked = r.u32()?; + let binding = r.arr()?; + let region = r.arr()?; + let expires = r.u64()?; + let sig = r.arr()?; + if r.p != b.len() { + return Err(ev(events::Reason::Malformed)); + } + let s = Session { room, edge, owner, media, kids, start, end, token, vendor, source, patlen, served, bits, segments, unmarked, binding, region, expires, sig }; + s.rules()?; + Ok(s) + } + + /// §5.5's rules. + fn rules(&self) -> Result<(), Reason> { + let kids_ok = self.kids.len() <= MAX_KIDS && self.kids.windows(2).all(|w| w[0] < w[1]) && self.kids.iter().all(|k| *k != [0; 16]); + let ok = content_form_ok(&self.owner, self.media) + && kids_ok + && self.start < self.end + && (1..=3).contains(&self.source) + && self.patlen >= 1 + && is_bitmap(&self.served, self.patlen) + && is_bitmap(&self.bits, self.patlen) + && within(&self.bits, &self.served) + && self.segments >= count(&self.served) + && self.region.iter().all(u8::is_ascii_uppercase); + if ok { + Ok(()) + } else { + Err(Reason::RecordInvalid) + } + } + + /// The owner as text (it is ASCII once §5.5 passes). + pub fn owner_text(&self) -> String { + String::from_utf8_lossy(&self.owner).into_owned() + } +} + +/// The binding (§5.3). +pub fn binding(salt: &[u8; 32], grant: &[u8; 32], principal: &[u8; 32]) -> [u8; 32] { + sha256(&[BINDING_DOMAIN, salt, grant, principal]) +} + +// --------------------------------------------------------------------------------------- +// §7 The recovered sequence +// --------------------------------------------------------------------------------------- + +/// A recovered sequence (§7.1). +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Sequence { + pub room: [u8; 32], + pub owner: Vec, + pub media: u8, + pub vendor: u8, + pub patlen: u16, + pub start: u64, + pub end: u64, + pub known: Vec, + pub bits: Vec, + pub copy: [u8; 32], + pub claimant: [u8; 32], + pub sig: [u8; 64], +} + +impl Sequence { + /// The bytes before `sig`. + pub fn body(&self) -> Vec { + let mut o = Vec::new(); + o.extend_from_slice(&SEQUENCE_MAGIC); + o.push(RECORD_VERSION); + o.extend_from_slice(&self.room); + o.push(self.owner.len() as u8); + o.extend_from_slice(&self.owner); + o.push(self.media); + o.push(self.vendor); + o.extend_from_slice(&self.patlen.to_le_bytes()); + o.extend_from_slice(&self.start.to_le_bytes()); + o.extend_from_slice(&self.end.to_le_bytes()); + o.extend_from_slice(&self.known); + o.extend_from_slice(&self.bits); + o.extend_from_slice(&self.copy); + o.extend_from_slice(&self.claimant); + o + } + + pub fn to_bytes(&self) -> Vec { + let mut o = self.body(); + o.extend_from_slice(&self.sig); + o + } + + /// Its digest (§7.1). + pub fn digest(&self) -> [u8; 32] { + sha256(&[&self.body()]) + } + + /// Sign with `claimant`, which checks nothing. + pub fn sign(mut self, claimant: &SigningKey) -> Sequence { + self.sig = sign(claimant, SEQUENCE_DOMAIN, &self.body()); + self + } + + /// Read a sequence, its layout and rules (§7.2), not yet its room or signature. + pub fn read(b: &[u8]) -> Result { + read_head(b, SEQUENCE_MAGIC)?; + let mut r = Rd { b, p: 5 }; + let room = r.arr()?; + let owner = r.short()?.to_vec(); + let media = r.u8()?; + let vendor = r.u8()?; + let patlen = r.u16()?; + let start = r.u64()?; + let end = r.u64()?; + let known = r.take(bitmap_len(patlen))?.to_vec(); + let bits = r.take(bitmap_len(patlen))?.to_vec(); + let copy = r.arr()?; + let claimant = r.arr()?; + let sig = r.arr()?; + if r.p != b.len() { + return Err(ev(events::Reason::Malformed)); + } + let q = Sequence { room, owner, media, vendor, patlen, start, end, known, bits, copy, claimant, sig }; + let ok = content_form_ok(&q.owner, q.media) + && q.patlen >= 1 + && q.start < q.end + && is_bitmap(&q.known, q.patlen) + && is_bitmap(&q.bits, q.patlen) + && within(&q.bits, &q.known) + && count(&q.known) > 0; + if !ok { + return Err(Reason::SequenceInvalid); + } + Ok(q) + } +} + +/// How a candidate first disagrees (§7.3). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Disagreement { + /// The session did not sequence the position. + Unserved(u32), + /// It did, and embedded the other variant. + Differs(u32), +} + +impl Disagreement { + pub fn position(self) -> u32 { + match self { + Disagreement::Unserved(p) | Disagreement::Differs(p) => p, + } + } + pub fn how(self) -> &'static str { + match self { + Disagreement::Unserved(_) => "unserved", + Disagreement::Differs(_) => "differs", + } + } +} + +/// Whether `s` is a candidate for `q` (§7.3). +pub fn is_candidate(s: &Session, q: &Sequence) -> bool { + s.owner == q.owner && s.media == q.media && s.vendor == q.vendor && s.patlen == q.patlen && s.start < q.end && q.start < s.end +} + +/// A candidate's agreement with `q` (§7.3): `None` when it agrees, otherwise its first +/// disagreement. +pub fn disagreement(s: &Session, q: &Sequence) -> Option { + (0..q.patlen as usize).filter(|&p| bit(&q.known, p)).find_map(|p| { + if !bit(&s.served, p) { + Some(Disagreement::Unserved(p as u32)) + } else if bit(&s.bits, p) != bit(&q.bits, p) { + Some(Disagreement::Differs(p as u32)) + } else { + None + } + }) +} + +// --------------------------------------------------------------------------------------- +// §8 The disclosure +// --------------------------------------------------------------------------------------- + +/// A disclosure (§8.1). +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Disclosure { + pub room: [u8; 32], + pub session: [u8; 32], + pub sequence: [u8; 32], + pub grant: [u8; 32], + pub principal: [u8; 32], + pub salt: [u8; 32], + pub t: u64, + pub sig: [u8; 64], +} + +impl Disclosure { + pub fn body(&self) -> Vec { + let mut o = Vec::with_capacity(DISCLOSURE_LEN); + o.extend_from_slice(&DISCLOSURE_MAGIC); + o.push(RECORD_VERSION); + for f in [&self.room, &self.session, &self.sequence, &self.grant, &self.principal, &self.salt] { + o.extend_from_slice(f); + } + o.extend_from_slice(&self.t.to_le_bytes()); + o + } + + pub fn to_bytes(&self) -> Vec { + let mut o = self.body(); + o.extend_from_slice(&self.sig); + o + } + + /// Its digest (§8.2). + pub fn digest(&self) -> [u8; 32] { + sha256(&[&self.body()]) + } + + /// Sign with the room authority's key, which checks nothing. + pub fn sign(mut self, authority: &SigningKey) -> Disclosure { + self.sig = sign(authority, DISCLOSURE_DOMAIN, &self.body()); + self + } + + /// Read a disclosure (§8.2). + pub fn read(b: &[u8]) -> Result { + read_head(b, DISCLOSURE_MAGIC)?; + let mut r = Rd { b, p: 5 }; + let d = Disclosure { room: r.arr()?, session: r.arr()?, sequence: r.arr()?, grant: r.arr()?, principal: r.arr()?, salt: r.arr()?, t: r.u64()?, sig: r.arr()? }; + if r.p != b.len() { + return Err(ev(events::Reason::Malformed)); + } + Ok(d) + } +} + +// --------------------------------------------------------------------------------------- +// §9 Verification +// --------------------------------------------------------------------------------------- + +/// Where a refusal is (§9). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum At { + Descriptor, + KeyLog, + Grant(usize), + Confidentiality, + Session(usize), + Sequence, + Disclosure, +} + +impl At { + /// The place as the corpus writes it. + pub fn json(self) -> serde_json::Value { + use serde_json::json; + match self { + At::Descriptor => json!({"place": "descriptor"}), + At::KeyLog => json!({"place": "key_log"}), + At::Grant(i) => json!({"place": "grant", "index": i}), + At::Confidentiality => json!({"place": "confidentiality"}), + At::Session(i) => json!({"place": "session", "index": i}), + At::Sequence => json!({"place": "sequence"}), + At::Disclosure => json!({"place": "disclosure"}), + } + } +} + +/// A refusal and where. +pub type Refusal = (At, Reason); + +/// What every verification is given about the room (§9.1). +#[derive(Clone, Copy, Debug)] +pub struct RoomInput<'a> { + pub descriptor: &'a [u8], + pub grants: &'a [Vec], + pub head: Option<&'a str>, + pub confidentiality: Option<&'a [u8]>, +} + +/// A session record, with its token or none. +#[derive(Clone, Copy, Debug)] +pub struct Given<'a> { + pub record: &'a [u8], + pub token: Option<&'a [u8]>, +} + +/// The room as the steps after §9.1 step 2 read it. +pub struct Room { + pub d: Descriptor, + pub id: [u8; 32], + pub log: Option, + pub grants: Vec, +} + +/// §9.1 steps 1 and 2. +pub fn check_room(input: &RoomInput) -> Result { + let d = Descriptor::parse(input.descriptor).map_err(|r| (At::Descriptor, Reason::Room(r)))?; + let id = d.room_id(); + room::check_key_log(&d, input.head).map_err(|r| (At::KeyLog, Reason::Room(r)))?; + let log = if d.origin.is_empty() { None } else { input.head.and_then(|h| Revision::from_json(h).ok()) }; + let grants = room::check_grants(&d, &id, input.grants).map_err(|(i, r)| (At::Grant(i), Reason::Room(r)))?; + if let Some(c) = input.confidentiality { + check_confidentiality(c, &id, &d.authority).map_err(|r| (At::Confidentiality, r))?; + } + Ok(Room { d, id, log, grants }) +} + +/// §9.1 step 2: a confidentiality record that reads, names the room and is signed by its +/// authority states that the room is end-to-end encrypted: `e2ee_room`. +fn check_confidentiality(b: &[u8], id: &[u8; 32], authority: &[u8; 32]) -> Result<(), Reason> { + if b.len() < 4 { + return Err(ev(events::Reason::Truncated)); + } + if b[..4] != CONFIDENTIALITY_MAGIC { + return Err(ev(events::Reason::Malformed)); + } + match b.get(4) { + None => return Err(ev(events::Reason::Truncated)), + Some(1) => {} + Some(_) => return Err(ev(events::Reason::UnknownVersion)), + } + if b.len() < CONFIDENTIALITY_LEN { + return Err(ev(events::Reason::Truncated)); + } + if b.len() > CONFIDENTIALITY_LEN { + return Err(ev(events::Reason::Malformed)); + } + if b[5..37] != id[..] { + return Err(ev(events::Reason::WrongRoom)); + } + let sig: [u8; 64] = b[41..].try_into().expect("64 bytes"); + verify(authority, CONFIDENTIALITY_DOMAIN, &b[..41], &sig)?; + Err(Reason::E2eeRoom) +} + +/// What an accepted record states (§9.1). +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct SessionFacts { + pub digest: [u8; 32], + pub record: Session, + /// The token, when it was given. + pub token: Option, +} + +/// §9.1 step 3 for one record, given the digests of the tokens of the records before it. +fn check_session(room: &Room, g: &Given, earlier: &[[u8; 32]]) -> Result { + // 1–3. + let s = Session::read(g.record)?; + if s.room != room.id { + return Err(ev(events::Reason::WrongRoom)); + } + verify(&s.edge, SESSION_DOMAIN, &s.body(), &s.sig)?; + // 4: who may sign (§5.6). + match &room.log { + None => { + if !room.d.is_room_key(&s.edge) { + return Err(Reason::EdgeNotAuthorised); + } + } + Some(log) => { + let e = log.keys.iter().find(|e| e.key == s.edge && e.may_sign.iter().any(|a| a == EDGE_AUTHORITY)).ok_or(Reason::EdgeNotAuthorised)?; + if e.status == EntryStatus::Revoked { + return Err(Reason::EdgeRevoked); + } + } + } + // 5: the content. + if s.owner.first() == Some(&b'_') { + let name = std::str::from_utf8(&s.owner[1..]).expect("a slot name is ASCII"); + if !room.d.slots.iter().any(|sl| sl.name == name && sl.media == s.media) { + return Err(Reason::ContentUnknown); + } + } + // 6: Lane A. + if s.kids.is_empty() { + return Err(Reason::NotProtected); + } + // 7: region and retention (§6.1). + let pin = &room.d.recording.regions; + if !pin.is_empty() && !pin.contains(&s.region) { + return Err(Reason::RegionNotPinned); + } + let end_us = room.d.t0_us as u128 + s.end as u128; + let expires_us = s.expires as u128 * 1_000_000; + if expires_us < end_us { + return Err(Reason::RecordInvalid); + } + let days = room.d.recording.retention_days as u128; + if days > 0 && expires_us > end_us + days * 86_400 * 1_000_000 { + return Err(Reason::RetentionExceeded); + } + // 8: one token, one session. + if earlier.contains(&s.token) { + return Err(Reason::TokenReused); + } + // 9: the token beside it (§5.7). + let token = match g.token { + None => None, + Some(tb) => Some(check_token(room, &s, tb)?), + }; + Ok(SessionFacts { digest: s.digest(), record: s, token }) +} + +/// §5.7: the token beside a record. +fn check_token(room: &Room, s: &Session, tb: &[u8]) -> Result { + if sha256(&[tb]) != s.token { + return Err(Reason::TokenMismatch); + } + let t = read_token(tb)?; + if t.vendor != s.vendor || t.patlen != s.patlen || t.source != s.source { + return Err(Reason::TokenMismatch); + } + if let Some(p) = &t.pattern { + let embedded: Vec = p.iter().zip(&s.served).map(|(a, b)| a & b).collect(); + if embedded != s.bits { + return Err(Reason::TokenMismatch); + } + } + let t0 = room.d.t0_us as u128; + if !(t.good_at(t0 + s.start as u128) && t.good_at(t0 + s.end as u128 - 1)) { + return Err(Reason::OutsideTokenWindow); + } + Ok(t) +} + +/// §9.1: the room and every record. +pub fn verify_sessions(input: &RoomInput, records: &[Given]) -> Result<(Room, Vec), Refusal> { + let room = check_room(input)?; + let mut out: Vec = Vec::new(); + for (i, g) in records.iter().enumerate() { + let earlier: Vec<[u8; 32]> = out.iter().map(|f| f.record.token).collect(); + out.push(check_session(&room, g, &earlier).map_err(|r| (At::Session(i), r))?); + } + Ok((room, out)) +} + +/// Read a sequence and check its room and signature (§7.2). +pub fn check_sequence(room: &Room, b: &[u8]) -> Result { + let q = Sequence::read(b)?; + if q.room != room.id { + return Err(ev(events::Reason::WrongRoom)); + } + verify(&q.claimant, SEQUENCE_DOMAIN, &q.body(), &q.sig)?; + Ok(q) +} + +/// One candidate as §7.4 states it. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Candidate { + /// Its index among the records given. + pub index: usize, + pub digest: [u8; 32], + pub disagreement: Option, +} + +/// A match's outcome (§7.4). +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Match { + pub sequence: [u8; 32], + /// The positions the sequence states. + pub stated: u32, + pub candidates: Vec, + /// The agreeing session's digest, or `None` when unmatched. + pub matched: Option<[u8; 32]>, + /// `no_match` or `ambiguous_match` when unmatched. + pub reason: Option, +} + +/// §9.2: the records, the sequence, and §7.4's outcome. +pub fn verify_match(input: &RoomInput, records: &[Given], sequence: &[u8]) -> Result { + let (room, facts) = verify_sessions(input, records)?; + let q = check_sequence(&room, sequence).map_err(|r| (At::Sequence, r))?; + let candidates: Vec = facts + .iter() + .enumerate() + .filter(|(_, f)| is_candidate(&f.record, &q)) + .map(|(index, f)| Candidate { index, digest: f.digest, disagreement: disagreement(&f.record, &q) }) + .collect(); + let agreeing: Vec<[u8; 32]> = candidates.iter().filter(|c| c.disagreement.is_none()).map(|c| c.digest).collect(); + let (matched, reason) = match agreeing.len() { + 1 => (Some(agreeing[0]), None), + 0 => (None, Some(Reason::NoMatch)), + _ => (None, Some(Reason::AmbiguousMatch)), + }; + Ok(Match { sequence: q.digest(), stated: count(&q.known), candidates, matched, reason }) +} + +/// What an accepted disclosure states (§9.3). +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct DisclosureFacts { + pub digest: [u8; 32], + pub session: [u8; 32], + pub sequence: [u8; 32], + pub grant: [u8; 32], + pub principal: [u8; 32], + pub stated: u32, + pub token_given: bool, +} + +/// §9.3: a disclosure against one record and one sequence. +pub fn verify_disclosure(input: &RoomInput, record: Given, sequence: &[u8], disclosure: &[u8]) -> Result { + let (room, facts) = verify_sessions(input, &[record])?; + let f = &facts[0]; + let s = &f.record; + let q = check_sequence(&room, sequence).map_err(|r| (At::Sequence, r))?; + let at = |r: Reason| (At::Disclosure, r); + // 3. + let d = Disclosure::read(disclosure).map_err(at)?; + if d.room != room.id { + return Err(at(ev(events::Reason::WrongRoom))); + } + verify(&room.d.authority, DISCLOSURE_DOMAIN, &d.body(), &d.sig).map_err(at)?; + // 4. + if d.session != f.digest || d.sequence != q.digest() { + return Err(at(Reason::DisclosureMismatch)); + } + // 5. + if !is_candidate(s, &q) || disagreement(s, &q).is_some() { + return Err(at(Reason::SequenceDisagrees)); + } + // 6. + if binding(&d.salt, &d.grant, &d.principal) != s.binding { + return Err(at(Reason::OpeningMismatch)); + } + // 7. + let g = room.grants.iter().find(|h| h.digest == d.grant).ok_or(at(Reason::Room(room::Reason::GrantUnknown)))?; + if g.grant.principal != d.principal { + return Err(at(Reason::Room(room::Reason::GrantWrongPrincipal))); + } + // 8. + let t0 = room.d.t0_us as u128; + if (g.grant.nbf as u128) * 1_000_000 > t0 + s.start as u128 || t0 + s.end as u128 > (g.grant.exp as u128) * 1_000_000 { + return Err(at(Reason::Room(room::Reason::OutsideGrantWindow))); + } + // 9. + if d.t > s.expires { + return Err(at(Reason::DisclosureExpired)); + } + Ok(DisclosureFacts { digest: d.digest(), session: f.digest, sequence: q.digest(), grant: d.grant, principal: d.principal, stated: count(&q.known), token_given: f.token.is_some() }) +} + +/// The facts of a token, as the corpus writes them. +pub fn token_json(t: &Token) -> serde_json::Value { + serde_json::json!({ + "cwt_tag": t.cwt_tag, + "alg": t.alg, + "vendor": t.vendor, + "patlen": t.patlen, + "source": t.source, + "pattern": t.pattern.as_deref().map(hex), + "iat": t.iat, + "exp": t.exp, + "nbf": t.nbf, + }) +} + +/// The facts of an accepted record, as the corpus writes them. +pub fn session_json(f: &SessionFacts) -> serde_json::Value { + let s = &f.record; + serde_json::json!({ + "digest": hex(&f.digest), + "edge": hex(&s.edge), + "owner": s.owner_text(), + "media": s.media, + "kids": s.kids.iter().map(|k| hex(k)).collect::>(), + "start": s.start, + "end": s.end, + "vendor": s.vendor, + "source": s.source, + "patlen": s.patlen, + "served": count(&s.served), + "segments": s.segments, + "unmarked": s.unmarked, + "region": String::from_utf8_lossy(&s.region), + "expires": s.expires, + "token": f.token.as_ref().map(token_json), + }) +} + +#[cfg(test)] +mod tests { + use super::cbor::{encode, read_one, Error, Item}; + use super::*; + + #[test] + fn bits_are_big_endian() { + // The TS's §5.4 example: the pattern 0x0A0B0C0D, position 3 is 0, position 4 is 1. + let p = [0x0a, 0x0b, 0x0c, 0x0d]; + assert!(!bit(&p, 3)); + assert!(bit(&p, 4)); + assert!(bit(&p, 6)); + assert!(!bit(&p, 7)); + let mut m = vec![0u8; 2]; + set_bit(&mut m, 0, true); + set_bit(&mut m, 9, true); + assert_eq!(m, [0x80, 0x40]); + assert!(is_bitmap(&[0xff, 0xc0], 10)); + assert!(!is_bitmap(&[0xff, 0xe0], 10), "a bit past patlen"); + assert!(!is_bitmap(&[0xff], 10), "the wrong length"); + } + + #[test] + fn cbor_reads_deterministic_items_and_refuses_the_rest() { + let m = Item::Map(vec![(Item::Uint(1), Item::Nint(6)), (Item::Uint(300), Item::Text("x".into()))]); + assert_eq!(read_one(&encode(&m)), Ok(m)); + let refuse = |b: &[u8], e: Error| assert_eq!(read_one(b), Err(e), "{}", hex(b)); + refuse(&[0x18, 0x05], Error::Malformed); // 5 not in its shortest form + refuse(&[0x19, 0x00, 0xff], Error::Malformed); + refuse(&[0x5f], Error::Malformed); // an indefinite byte string + refuse(&[0x1c], Error::Malformed); // reserved + refuse(&[0xf9, 0x3c, 0x00], Error::Unsupported); // a half float + refuse(&[0xf7], Error::Unsupported); // undefined + refuse(&[0xf8, 0x10], Error::Malformed); // a one-byte simple value below 32 + refuse(&[0xf8, 0x20], Error::Unsupported); + refuse(&[0xa2, 0x02, 0x00, 0x01, 0x00], Error::Malformed); // keys out of order + refuse(&[0xa2, 0x01, 0x00, 0x01, 0x00], Error::Malformed); // a key repeated + refuse(&[0x62, 0xc3, 0x28], Error::Malformed); // not UTF-8 + refuse(&[0x01, 0x00], Error::Malformed); // a byte after the item + refuse(&[0x42, 0x00], Error::Malformed); // runs past the bytes + let mut deep = vec![0x81; 16]; + deep.push(0x00); + refuse(&deep, Error::Unsupported); // 17 deep + let mut ok = vec![0x81; 15]; + ok.push(0x00); + assert!(read_one(&ok).is_ok(), "16 deep"); + } + + fn token(alg: i64, t: u64, mac_len: usize, claims: Vec<(u64, Item)>) -> Vec { + let a = if alg < 0 { Item::Nint((-1 - alg) as u64) } else { Item::Uint(alg as u64) }; + let protected = encode(&Item::Map(vec![(Item::Uint(1), a)])); + let payload = encode(&Item::Map(claims.into_iter().map(|(k, v)| (Item::Uint(k), v)).collect())); + encode(&Item::Tag(t, Box::new(Item::Array(vec![Item::Bytes(protected), Item::Map(vec![]), Item::Bytes(payload), Item::Bytes(vec![7; mac_len])])))) + } + + fn claims(pattern: Option) -> Vec<(u64, Item)> { + let mut c = vec![(claim::EXP, Item::Uint(2_000_000_000)), (claim::IAT, Item::Uint(1_700_000_000)), (claim::WMVER, Item::Uint(1)), (claim::WMVND, Item::Uint(7)), (claim::WMPATLEN, Item::Uint(12))]; + match pattern { + Some(p) => c.push((claim::WMPATTERN, p)), + None => c.extend([(claim::WMID, Item::Text("id".into())), (claim::WMOPID, Item::Uint(3)), (claim::WMKEYVER, Item::Uint(1))]), + } + c + } + + #[test] + fn tokens_of_both_structures_and_every_mode_read() { + let clear = read_token(&token(alg::HMAC_256_256, tag::MAC0, 32, claims(Some(Item::Bytes(vec![0xab, 0xc0]))))).expect("a clear token"); + assert_eq!((clear.source, clear.vendor, clear.patlen, clear.pattern.as_deref()), (source::CLEAR, 7, 12, Some(&[0xab, 0xc0][..]))); + // ES256 in a COSE_Sign1: its 64 bytes are placeholders, which no WAI verifier checks (§4.4). + let signed = read_token(&token(alg::ES256, tag::SIGN1, 64, claims(None))).expect("an indirect token"); + assert_eq!((signed.alg, signed.source), (alg::ES256, source::DERIVED)); + let enc0 = Item::Array(vec![Item::Bytes(vec![]), Item::Map(vec![]), Item::Bytes(vec![1, 2])]); + assert_eq!(read_token(&token(5, tag::MAC0, 32, claims(Some(enc0)))).map(|t| t.source), Ok(source::DECRYPTED)); + assert_eq!(read_token(&token(-7, tag::MAC0, 32, claims(None))), Err(Reason::TokenMalformed), "a signature algorithm on a MAC"); + assert_eq!(read_token(&token(5, tag::MAC0, 31, claims(None))), Err(Reason::TokenMalformed)); + assert_eq!(read_token(&token(6, tag::MAC0, 32, claims(None))), Err(Reason::TokenUnsupported)); + assert_eq!(read_token(&token(5, tag::MAC0, 32, claims(Some(Item::Bytes(vec![0xab]))))), Err(Reason::TokenMalformed), "a pattern one byte short"); + let mut no_iat = claims(None); + no_iat.retain(|(k, _)| *k != claim::IAT); + assert_eq!(read_token(&token(5, tag::MAC0, 32, no_iat)), Err(Reason::TokenMalformed)); + let mut v2 = claims(None); + v2[2].1 = Item::Uint(2); + assert_eq!(read_token(&token(5, tag::MAC0, 32, v2)), Err(Reason::TokenUnsupported)); + } + + #[test] + fn the_rules_of_a_record() { + let s = Session { + room: [1; 32], + edge: [2; 32], + owner: b"_main".to_vec(), + media: 2, + kids: vec![[3; 16]], + start: 0, + end: 10, + token: [4; 32], + vendor: 1, + source: 1, + patlen: 10, + served: vec![0xff, 0xc0], + bits: vec![0x0f, 0x40], + segments: 10, + unmarked: 0, + binding: [5; 32], + region: *b"DE", + expires: 1, + sig: [0; 64], + }; + assert_eq!(Session::read(&s.to_bytes()), Ok(s.clone())); + let refused = |f: &dyn Fn(&mut Session)| { + let mut t = s.clone(); + f(&mut t); + Session::read(&t.to_bytes()) + }; + assert_eq!(refused(&|t| t.bits = vec![0x0f, 0x60]), Err(Reason::RecordInvalid), "bits past patlen"); + assert_eq!(refused(&|t| t.served = vec![0x7f, 0xc0]), Err(Reason::RecordInvalid), "a bit not served"); + assert_eq!(refused(&|t| t.segments = 9), Err(Reason::RecordInvalid)); + assert_eq!(refused(&|t| t.owner = b"_Main".to_vec()), Err(Reason::RecordInvalid)); + assert_eq!(refused(&|t| t.kids = vec![[0; 16]]), Err(Reason::RecordInvalid)); + assert_eq!(refused(&|t| t.region = *b"de"), Err(Reason::RecordInvalid)); + let b = s.to_bytes(); + assert_eq!(Session::read(&b[..b.len() - 1]), Err(ev(events::Reason::Truncated))); + } + + #[test] + fn a_match_is_exact_over_the_stated_positions() { + let mut s = Session { room: [0; 32], edge: [0; 32], owner: b"_main".to_vec(), media: 2, kids: vec![], start: 0, end: 100, token: [0; 32], vendor: 1, source: 1, patlen: 8, served: vec![0xf0], bits: vec![0xa0], segments: 4, unmarked: 0, binding: [0; 32], region: *b"DE", expires: 0, sig: [0; 64] }; + let q = Sequence { room: [0; 32], owner: b"_main".to_vec(), media: 2, vendor: 1, patlen: 8, start: 50, end: 60, known: vec![0xc0], bits: vec![0x80], copy: [0; 32], claimant: [0; 32], sig: [0; 64] }; + assert!(is_candidate(&s, &q)); + assert_eq!(disagreement(&s, &q), None); + s.bits = vec![0x60]; + assert_eq!(disagreement(&s, &q), Some(Disagreement::Differs(0))); + s.served = vec![0x70]; + assert_eq!(disagreement(&s, &q), Some(Disagreement::Unserved(0))); + s.start = 60; + assert!(!is_candidate(&s, &q), "the spans do not meet"); + } + + #[test] + fn the_binding_hides_the_grant() { + let a = binding(&[1; 32], &[2; 32], &[3; 32]); + assert_ne!(a, binding(&[0; 32], &[2; 32], &[3; 32])); + assert_eq!(a, sha256(&[b"wai:live-watermark-binding\x01", &[1; 32], &[2; 32], &[3; 32]])); + } +} diff --git a/wai/wai-rs/src/live_watermark/corpus.rs b/wai/wai-rs/src/live_watermark/corpus.rs new file mode 100644 index 000000000..52ca69611 --- /dev/null +++ b/wai/wai-rs/src/live_watermark/corpus.rs @@ -0,0 +1,1090 @@ +//! The conformance corpus of extensions/forensic-watermark.md §12: `live-conformance/watermark/vectors.json`. +//! +//! [`generate`] writes it from fixed seeds: every key is SHA-256 of this corpus's label and an +//! index, every pattern, salt and KID is SHA-256 of a label and an index, every time is a +//! constant, and Ed25519 signing is deterministic (RFC 8032). The tokens are COSE_Mac0 under +//! HMAC 256/256 keyed by [`wmt_key`], which the corpus states; no verifier checks their MACs +//! (§4.4). Each vector states the outcome it was written to have, and the generator asserts +//! that this module reaches it before writing it. +//! +//! [`verify`] checks a corpus file against this module. `live-conformance/live_watermark_verify.py` +//! checks the same file from the extension's text, sharing no code with this module. + +use std::collections::{BTreeMap, BTreeSet}; + +use serde_json::{json, Map, Value}; + +use super::cbor::{encode, Item}; +use super::*; +use crate::key_log::{commitment, KeyEntry}; +use crate::live_events::Policy; +use crate::live_room::{media, Caps, Grant, Limits, Recording, Review, Role, Slot}; + +const S: u64 = 1_000_000; +/// The rooms' `t0`: 2026-10-18T00:00:00Z. +const T0_S: u64 = 1_792_368_000; +const T0_US: u64 = T0_S * S; +const ORIGIN: &str = "https://tv.example"; +const VENDOR: u8 = 7; +const PATLEN: u16 = 64; +const DAY: u64 = 86_400; + +const AUTHORITY: u8 = 0; +const CLOCK: u8 = 1; +const EDGE: u8 = 2; +const EDGE2: u8 = 3; +const RELAY: u8 = 4; +const EDGE_OLD: u8 = 5; +const BROADCASTER: u8 = 6; +const V1: u8 = 7; +const V2: u8 = 8; +const V3: u8 = 9; +const V4: u8 = 10; +const CLAIMANT: u8 = 11; +const LOG_ROOT: u8 = 12; +const LOG_NEXT: u8 = 13; + +const PRINCIPALS: [(&str, u8); 14] = [ + ("authority", AUTHORITY), + ("clock", CLOCK), + ("edge", EDGE), + ("edge2", EDGE2), + ("relay", RELAY), + ("edge_old", EDGE_OLD), + ("broadcaster", BROADCASTER), + ("viewer1", V1), + ("viewer2", V2), + ("viewer3", V3), + ("viewer4", V4), + ("claimant", CLAIMANT), + ("log_root", LOG_ROOT), + ("log_next", LOG_NEXT), +]; + +fn seed(label: &str, i: u8) -> [u8; 32] { + sha256(&[b"wai:live-watermark/corpus/", label.as_bytes(), b"\x00", &[i]]) +} + +fn key(i: u8) -> SigningKey { + SigningKey::from_bytes(&seed("key", i)) +} + +fn pk(i: u8) -> [u8; 32] { + key(i).verifying_key().to_bytes() +} + +fn pattern(i: u8) -> Vec { + seed("pattern", i)[..bitmap_len(PATLEN)].to_vec() +} + +fn kid(i: u8) -> [u8; 16] { + seed("kid", i)[..16].try_into().expect("16 bytes") +} + +/// The content keys every corpus session names, ascending. +fn kids() -> Vec<[u8; 16]> { + let mut k = vec![kid(1), kid(2)]; + k.sort(); + k +} + +fn salt(i: u8) -> [u8; 32] { + seed("salt", i) +} + +/// The HMAC key the corpus's tokens are MACed under, as their issuer and edge share it. +pub fn wmt_key() -> [u8; 32] { + seed("wmt-key", 0) +} + +fn hmac_sha256(k: &[u8], m: &[u8]) -> [u8; 32] { + let mut key = [0u8; 64]; + key[..k.len()].copy_from_slice(k); + let pad = |x: u8| key.iter().map(|b| b ^ x).collect::>(); + let inner = sha256(&[&pad(0x36), m]); + sha256(&[&pad(0x5c), &inner]) +} + +pub(crate) fn unhex(s: &str) -> Option> { + if s.len() % 2 != 0 { + return None; + } + (0..s.len()).step_by(2).map(|i| u8::from_str_radix(&s[i..i + 2], 16).ok()).collect() +} + +// ---- the rooms ---- + +#[derive(Clone)] +struct RoomBuild { + descriptor: Vec, + grants: Vec>, + head: Option, + confidentiality: Option>, + id: [u8; 32], + grant_of: BTreeMap, +} + +impl RoomBuild { + fn input(&self) -> RoomInput<'_> { + RoomInput { descriptor: &self.descriptor, grants: &self.grants, head: self.head.as_deref(), confidentiality: self.confidentiality.as_deref() } + } + + fn json(&self) -> Value { + json!({ + "room_id": hex(&self.id), + "descriptor": hex(&self.descriptor), + "grants": self.grants.iter().map(|g| hex(g)).collect::>(), + "key_log": self.head, + "confidentiality": self.confidentiality.as_deref().map(hex), + }) + } +} + +fn roles() -> Vec { + vec![ + Role { name: "broadcaster".into(), caps: Caps { publish: 1 << (media::VIDEO - 1), subscribe: true, max_layer: 2, powers: 0 }, events: [0; 32] }, + Role { name: "viewer".into(), caps: Caps { publish: 0, subscribe: true, max_layer: 2, powers: 0 }, events: [0; 32] }, + ] +} + +fn descriptor(clock: u8, origin: &str, recording: Recording) -> Descriptor { + Descriptor { + authority: pk(AUTHORITY), + clock: pk(clock), + t0_us: T0_US, + tick_us: S, + origin: origin.into(), + limits: Limits { max_present: 64, max_publishers: 2, reval_s: 300, ceiling: Policy::default() }, + roles: roles(), + slots: vec![Slot { name: "main".into(), media: media::VIDEO, redundancy: 1, failover_ms: 0 }], + profiles: vec![], + review: Review { kinds: [0; 32], deadline_ms: 0, fallback: 0 }, + recording, + sig: [0; 64], + } +} + +fn grant(room: [u8; 32], who: u8, role: u8, exp: u64) -> Grant { + let caps = roles()[role as usize].caps; + Grant { + room, + principal: pk(who), + prev: [0; 32], + effective: 0, + nbf: T0_S - 3_600, + exp, + role, + caps, + precedence: 0, + once: [0; 16], + viewer: seed("viewer", who)[..16].try_into().expect("16 bytes"), + policy: Policy::with_kinds(&[]), + joules_ceiling: room::NO_CEILING, + funds_ceiling: room::NO_CEILING, + currency: [0; 3], + profiles: vec![], + origins: vec![ORIGIN.into()], + attrs: vec![], + sig: [0; 64], + } +} + +fn key_log() -> String { + let revoked = KeyEntry { status: EntryStatus::Revoked, compromised_since: Some("2026-10-14T00:00:00Z".into()), ..KeyEntry::active("edge-old", pk(EDGE_OLD), &[EDGE_AUTHORITY]) }; + 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: vec![ + KeyEntry::active("room-authority", pk(AUTHORITY), &[room::KEY_LOG_AUTHORITY]), + KeyEntry::active("edge-1", pk(EDGE), &[EDGE_AUTHORITY, "live/relay"]), + KeyEntry::active("edge-2", pk(EDGE2), &[EDGE_AUTHORITY]), + KeyEntry::active("relay", pk(RELAY), &["live/relay"]), + revoked, + ], + revoked_modules: vec![], + grant_max_s: 86_400, + status_max_age_s: 3_600, + sig: [0; 64], + }; + Revision::seal(&key(LOG_ROOT), revision).expect("a well-formed revision").to_json() +} + +fn build(d: Descriptor, head: Option) -> RoomBuild { + let d = d.sign(&key(AUTHORITY)); + let id = d.room_id(); + let mut grants = Vec::new(); + let mut grant_of = BTreeMap::new(); + for (who, role, exp) in [(BROADCASTER, 0, T0_S + DAY), (V1, 1, T0_S + DAY), (V2, 1, T0_S + DAY), (V3, 1, T0_S + DAY), (V4, 1, T0_S + 1_200)] { + let g = grant(id, who, role, exp).sign(&key(AUTHORITY)); + grant_of.insert(who, g.digest()); + grants.push(g.to_bytes()); + } + RoomBuild { descriptor: d.to_bytes(), grants, head, confidentiality: None, id, grant_of } +} + +/// The broadcast room: an origin whose key log lists the edges. +fn broadcast() -> RoomBuild { + let rec = Recording { mode: 1, retention_days: 30, commit_interval_ms: 2_000, jitter_ms: 500, regions: vec![*b"DE", *b"FR"] }; + build(descriptor(CLOCK, ORIGIN, rec), Some(key_log())) +} + +/// A room with no origin, whose clock is its edge. +fn keyless() -> RoomBuild { + let rec = Recording { mode: 0, retention_days: 0, commit_interval_ms: 0, jitter_ms: 0, regions: vec![] }; + build(descriptor(EDGE, "", rec), None) +} + +/// The confidentiality record of confidentiality §3.1 for room `id`, signed by `who`. +fn confidentiality_record(id: &[u8; 32], who: u8) -> Vec { + let mut b = CONFIDENTIALITY_MAGIC.to_vec(); + b.push(1); + b.extend_from_slice(id); + b.extend_from_slice(&0x0004u16.to_le_bytes()); + b.extend_from_slice(&0x0001u16.to_le_bytes()); + let sig = sign(&key(who), CONFIDENTIALITY_DOMAIN, &b); + b.extend_from_slice(&sig); + b +} + +/// An end-to-end encrypted room (confidentiality §3), never recorded. +fn e2ee() -> RoomBuild { + let rec = Recording { mode: 0, retention_days: 0, commit_interval_ms: 0, jitter_ms: 0, regions: vec![] }; + let mut r = build(descriptor(CLOCK, "", rec), None); + r.confidentiality = Some(confidentiality_record(&r.id, AUTHORITY)); + r +} + +// ---- tokens ---- + +fn int(v: i64) -> Item { + if v < 0 { + Item::Nint((-1 - v) as u64) + } else { + Item::Uint(v as u64) + } +} + +fn claims_map(c: &[(u64, Item)]) -> Item { + Item::Map(c.iter().map(|(k, v)| (Item::Uint(*k), v.clone())).collect()) +} + +/// A COSE_Mac0 token (tag 17), its tag HMAC-SHA-256 over the MAC_structure (RFC 9052 §6.3) +/// when the payload is a byte string and the tag is 32 bytes long. +fn mac0_item(protected: Vec, unprotected: Item, payload: Option>, tag_len: usize) -> Item { + let tag = match &payload { + Some(p) if tag_len == 32 => { + let structure = encode(&Item::Array(vec![Item::Text("MAC0".into()), Item::Bytes(protected.clone()), Item::Bytes(vec![]), Item::Bytes(p.clone())])); + hmac_sha256(&wmt_key(), &structure).to_vec() + } + _ => vec![0x5a; tag_len], + }; + let payload = payload.map_or(Item::Null, Item::Bytes); + Item::Tag(tag::MAC0, Box::new(Item::Array(vec![Item::Bytes(protected), unprotected, payload, Item::Bytes(tag)]))) +} + +fn alg_header(a: i64) -> Vec { + encode(&Item::Map(vec![(Item::Uint(1), int(a))])) +} + +fn mac0(payload: Vec) -> Vec { + encode(&mac0_item(alg_header(alg::HMAC_256_256), Item::Map(vec![]), Some(payload), 32)) +} + +fn base_claims(exp: u64) -> Vec<(u64, Item)> { + vec![ + (claim::EXP, Item::Uint(exp)), + (claim::IAT, Item::Uint(T0_S - 600)), + (claim::WMVER, Item::Uint(1)), + (claim::WMVND, Item::Uint(VENDOR as u64)), + (claim::WMPATLEN, Item::Uint(PATLEN as u64)), + ] +} + +fn clear_claims(p: &[u8]) -> Vec<(u64, Item)> { + let mut c = base_claims(T0_S + DAY); + c.push((claim::WMPATTERN, Item::Bytes(p.to_vec()))); + c +} + +fn encrypt0_pattern(i: u8) -> Item { + // COSE_Encrypt0's shape: its bytes stand for a ciphertext no verifier opens (§4.4). + Item::Array(vec![Item::Bytes(alg_header(1)), Item::Map(vec![(Item::Uint(5), Item::Bytes(seed("iv", i)[..12].to_vec()))]), Item::Bytes(seed("ciphertext", i)[..24].to_vec())]) +} + +fn encrypt_pattern(i: u8) -> Item { + let recipient = Item::Array(vec![Item::Bytes(alg_header(-32)), Item::Map(vec![(Item::Uint(4), Item::Bytes(b"vendor-7".to_vec()))]), Item::Bytes(seed("wrapped", i)[..24].to_vec())]); + Item::Array(vec![Item::Bytes(alg_header(1)), Item::Map(vec![(Item::Uint(5), Item::Bytes(seed("iv", i)[..12].to_vec()))]), Item::Bytes(seed("ciphertext", i)[..24].to_vec()), Item::Array(vec![recipient])]) +} + +fn derived_claims(i: u8) -> Vec<(u64, Item)> { + let mut c = base_claims(T0_S + DAY); + c.extend([(claim::WMID, Item::Text(format!("session-{i}"))), (claim::WMOPID, Item::Uint(12)), (claim::WMKEYVER, Item::Uint(2))]); + c +} + +/// The token of session `i`, in mode `src`. +fn session_token(i: u8, src: u8) -> Vec { + let c = match src { + source::CLEAR => clear_claims(&pattern(i)), + source::DECRYPTED => { + let mut c = base_claims(T0_S + DAY); + c.push((claim::WMPATTERN, encrypt0_pattern(i))); + c + } + _ => derived_claims(i), + }; + mac0(encode(&claims_map(&c))) +} + +// ---- session records ---- + +/// What a corpus session is. +#[derive(Clone)] +struct Plan { + /// Which pattern, salt and token. + n: u8, + edge: u8, + viewer: u8, + src: u8, + start: u64, + end: u64, + /// The positions it served. + served: Vec, + segments: u32, + unmarked: u32, + region: [u8; 2], +} + +fn served_bitmap(ps: &[usize]) -> Vec { + let mut m = vec![0u8; bitmap_len(PATLEN)]; + for &p in ps { + set_bit(&mut m, p, true); + } + m +} + +fn expires_for(end: u64, days: u64) -> u64 { + (T0_US + end).div_ceil(S) + days * DAY +} + +impl Plan { + fn token(&self) -> Vec { + session_token(self.n, self.src) + } + + fn record(&self, r: &RoomBuild) -> Session { + let served = served_bitmap(&self.served); + let bits: Vec = pattern(self.n).iter().zip(&served).map(|(a, b)| a & b).collect(); + Session { + room: r.id, + edge: pk(self.edge), + owner: b"_main".to_vec(), + media: media::VIDEO, + kids: kids(), + start: self.start, + end: self.end, + token: sha256(&[&self.token()]), + vendor: VENDOR, + source: self.src, + patlen: PATLEN, + served, + bits, + segments: self.segments, + unmarked: self.unmarked, + binding: binding(&salt(self.n), &r.grant_of[&self.viewer], &pk(self.viewer)), + region: self.region, + expires: expires_for(self.end, 7), + sig: [0; 64], + } + } + + fn signed(&self, r: &RoomBuild) -> Session { + self.record(r).sign(&key(self.edge)) + } +} + +fn all_positions() -> Vec { + (0..PATLEN as usize).collect() +} + +/// The four sessions of the broadcast room, and the fifth, of the viewer whose grant ends +/// early. +fn plans() -> Vec { + vec![ + Plan { n: 1, edge: EDGE, viewer: V1, src: source::CLEAR, start: 60 * S, end: 1_860 * S, served: all_positions(), segments: 300, unmarked: 2, region: *b"DE" }, + Plan { n: 2, edge: EDGE, viewer: V2, src: source::DECRYPTED, start: 300 * S, end: 420 * S, served: (50..64).chain(0..6).collect(), segments: 20, unmarked: 0, region: *b"DE" }, + Plan { n: 3, edge: EDGE2, viewer: V3, src: source::DERIVED, start: 600 * S, end: 2_400 * S, served: all_positions(), segments: 300, unmarked: 0, region: *b"FR" }, + Plan { n: 4, edge: EDGE2, viewer: V1, src: source::CLEAR, start: 2_400 * S, end: 3_000 * S, served: all_positions(), segments: 100, unmarked: 0, region: *b"FR" }, + Plan { n: 5, edge: EDGE, viewer: V4, src: source::CLEAR, start: 600 * S, end: 1_860 * S, served: all_positions(), segments: 210, unmarked: 0, region: *b"DE" }, + ] +} + +// ---- recovered sequences ---- + +fn sequence(r: &RoomBuild, n: u8, known: &[usize], start: u64, end: u64) -> Sequence { + let k = served_bitmap(known); + let bits: Vec = pattern(n).iter().zip(&k).map(|(a, b)| a & b).collect(); + Sequence { + room: r.id, + owner: b"_main".to_vec(), + media: media::VIDEO, + vendor: VENDOR, + patlen: PATLEN, + start, + end, + known: k, + bits, + copy: seed("copy", n), + claimant: pk(CLAIMANT), + sig: [0; 64], + } +} + +/// The sequence recovered from a copy of session 1: positions 0 to 47 but three erasures. +fn q1(r: &RoomBuild) -> Sequence { + let known: Vec = (0..48).filter(|p| ![5, 17, 33].contains(p)).collect(); + sequence(r, 1, &known, 600 * S, 1_800 * S) +} + +// ---- vectors ---- + +fn refuse_json(at: At, reason: Reason) -> (Value, Value, Value) { + (json!("refuse"), json!(reason.code()), at.json()) +} + +struct Corpus { + rooms: BTreeMap<&'static str, RoomBuild>, + tokens: Vec, + sessions: Vec, + matches: Vec, + disclosures: Vec, + reasons: BTreeSet<&'static str>, +} + +/// A room's input with the vector's overrides. +fn with_override(base: &RoomBuild, ov: &Map) -> RoomBuild { + let mut r = base.clone(); + if let Some(v) = ov.get("descriptor") { + r.descriptor = unhex(v.as_str().expect("hex")).expect("hex"); + } + if let Some(v) = ov.get("grants") { + r.grants = v.as_array().expect("grants").iter().map(|g| unhex(g.as_str().expect("hex")).expect("hex")).collect(); + } + if let Some(v) = ov.get("key_log") { + r.head = v.as_str().map(String::from); + } + if let Some(v) = ov.get("confidentiality") { + r.confidentiality = v.as_str().map(|s| unhex(s).expect("hex")); + } + r +} + +fn given_json(record: &[u8], token: Option<&[u8]>) -> Value { + json!({"record": hex(record), "token": token.map(hex)}) +} + +impl Corpus { + fn note(&mut self, outcome: &Value, reason: &Value) { + if let Some(r) = reason.as_str() { + let r = Reason::ALL.iter().find(|x| x.code() == r).map(|x| x.code()).unwrap_or("other"); + if outcome != "accept" && outcome != "matched" && outcome != "read" { + self.reasons.insert(r); + } + } + } + + fn token(&mut self, name: &str, description: &str, bytes: &[u8], want: Option) { + let got = read_token(bytes); + assert_eq!(got.as_ref().err().copied(), want, "token vector {name}"); + let (outcome, reason, facts) = match &got { + Ok(t) => (json!("read"), Value::Null, token_json(t)), + Err(r) => (json!("refuse"), json!(r.code()), Value::Null), + }; + self.note(&outcome, &reason); + self.tokens.push(json!({"name": name, "description": description, "token": hex(bytes), "outcome": outcome, "reason": reason, "facts": facts})); + } + + fn sessions(&mut self, name: &str, description: &str, room: &str, ov: Map, given: &[(Vec, Option>)], want: Option<(At, Reason)>) { + let r = with_override(&self.rooms[room], &ov); + let g: Vec = given.iter().map(|(rec, t)| Given { record: rec, token: t.as_deref() }).collect(); + let got = verify_sessions(&r.input(), &g); + assert_eq!(got.as_ref().err().copied(), want, "sessions vector {name}"); + let mut v = json!({"name": name, "description": description, "room": room}); + if !ov.is_empty() { + v["room_override"] = Value::Object(ov); + } + v["records"] = Value::Array(given.iter().map(|(rec, t)| given_json(rec, t.as_deref())).collect()); + match got { + Ok((_, facts)) => { + v["outcome"] = json!("accept"); + v["reason"] = Value::Null; + v["at"] = Value::Null; + v["sessions"] = Value::Array(facts.iter().map(session_json).collect()); + } + Err((at, reason)) => { + let (o, rc, a) = refuse_json(at, reason); + v["outcome"] = o; + v["reason"] = rc; + v["at"] = a; + v["sessions"] = Value::Null; + } + } + let (o, rc) = (v["outcome"].clone(), v["reason"].clone()); + self.note(&o, &rc); + self.sessions.push(v); + } + + fn matched(&mut self, name: &str, description: &str, given: &[(Vec, Option>)], seq: &[u8], want: Result, (At, Reason)>) { + let r = self.rooms["broadcast"].clone(); + let g: Vec = given.iter().map(|(rec, t)| Given { record: rec, token: t.as_deref() }).collect(); + let got = verify_match(&r.input(), &g, seq); + let mut v = json!({"name": name, "description": description, "room": "broadcast"}); + v["records"] = Value::Array(given.iter().map(|(rec, t)| given_json(rec, t.as_deref())).collect()); + v["sequence"] = json!(hex(seq)); + match got { + Ok(m) => { + assert_eq!(Ok(m.reason), want, "match vector {name}"); + v["outcome"] = json!(if m.matched.is_some() { "matched" } else { "unmatched" }); + v["reason"] = m.reason.map_or(Value::Null, |r| json!(r.code())); + v["at"] = Value::Null; + v["match"] = json!({ + "sequence": hex(&m.sequence), + "stated": m.stated, + "candidates": m.candidates.iter().map(|c| json!({ + "index": c.index, + "digest": hex(&c.digest), + "agrees": c.disagreement.is_none(), + "first": c.disagreement.map(|d| json!({"position": d.position(), "how": d.how()})), + })).collect::>(), + "session": m.matched.map(|d| hex(&d)), + }); + } + Err((at, reason)) => { + assert_eq!(Err((at, reason)), want, "match vector {name}"); + let (o, rc, a) = refuse_json(at, reason); + v["outcome"] = o; + v["reason"] = rc; + v["at"] = a; + v["match"] = Value::Null; + } + } + let (o, rc) = (v["outcome"].clone(), v["reason"].clone()); + self.note(&o, &rc); + self.matches.push(v); + } + + #[allow(clippy::too_many_arguments)] + fn disclosure(&mut self, name: &str, description: &str, ov: Map, record: &[u8], token: Option<&[u8]>, seq: &[u8], d: &[u8], want: Option<(At, Reason)>) { + let r = with_override(&self.rooms["broadcast"], &ov); + let got = verify_disclosure(&r.input(), Given { record, token }, seq, d); + assert_eq!(got.as_ref().err().copied(), want, "disclosure vector {name}"); + let mut v = json!({"name": name, "description": description, "room": "broadcast"}); + if !ov.is_empty() { + v["room_override"] = Value::Object(ov); + } + v["record"] = given_json(record, token); + v["sequence"] = json!(hex(seq)); + v["disclosure"] = json!(hex(d)); + match got { + Ok(f) => { + v["outcome"] = json!("accept"); + v["reason"] = Value::Null; + v["at"] = Value::Null; + v["facts"] = json!({ + "digest": hex(&f.digest), + "session": hex(&f.session), + "sequence": hex(&f.sequence), + "grant": hex(&f.grant), + "principal": hex(&f.principal), + "stated": f.stated, + "token_given": f.token_given, + }); + } + Err((at, reason)) => { + let (o, rc, a) = refuse_json(at, reason); + v["outcome"] = o; + v["reason"] = rc; + v["at"] = a; + v["facts"] = Value::Null; + } + } + let (o, rc) = (v["outcome"].clone(), v["reason"].clone()); + self.note(&o, &rc); + self.disclosures.push(v); + } +} + +fn no_ov() -> Map { + Map::new() +} + +fn ov(k: &str, v: Value) -> Map { + let mut m = Map::new(); + m.insert(k.into(), v); + m +} + +fn resign(mut s: Session, who: u8) -> Vec { + s = s.sign(&key(who)); + s.to_bytes() +} + +fn token_vectors(c: &mut Corpus) { + use Reason::{TokenMalformed as Mal, TokenUnsupported as Uns}; + let t1 = session_token(1, source::CLEAR); + c.token("mac0_clear", "a COSE_Mac0 under HMAC 256/256 whose wmpattern is the pattern in the clear (direct mode)", &t1, None); + c.token("mac0_encrypt0", "direct mode, the pattern encrypted as a COSE_Encrypt0", &session_token(2, source::DECRYPTED), None); + let mut ce = base_claims(T0_S + DAY); + ce.push((claim::WMPATTERN, encrypt_pattern(6))); + c.token("mac0_encrypt", "direct mode, the pattern encrypted as a COSE_Encrypt with one recipient (ECDH-SS + A128KW)", &mac0(encode(&claims_map(&ce))), None); + c.token("mac0_derived", "indirect mode: wmid, wmopid and wmkeyver, no wmpattern", &session_token(3, source::DERIVED), None); + let inner = cbor::read_one(&t1).expect("the clear token"); + c.token("cwt_tagged", "the clear token in the CWT tag (61)", &encode(&Item::Tag(tag::CWT, Box::new(inner.clone()))), None); + let mut full = clear_claims(&pattern(1)); + full.extend([ + (claim::ISS, Item::Text("https://tv.example/wmt".into())), + (claim::AUD, Item::Text("edge".into())), + (claim::NBF, Item::Uint(T0_S - 600)), + (claim::CTI, Item::Bytes(vec![1, 2, 3, 4])), + (claim::WMSEGDURATION, Item::Array(vec![Item::Uint(60_000), Item::Uint(10_000)])), + (900, Item::Text("an extension claim".into())), + ]); + c.token("registered_claims", "the clear token with iss, aud, nbf, cti, wmsegduration and an extension claim", &mac0(encode(&claims_map(&full))), None); + + let mut cut = t1.clone(); + cut.pop(); + c.token("truncated", "the clear token without its last byte", &cut, Some(Mal)); + let mut trail = t1.clone(); + trail.push(0); + c.token("trailing_byte", "the clear token followed by one byte", &trail, Some(Mal)); + let mut long_tag = vec![0xd8, 0x11]; + long_tag.extend_from_slice(&t1[1..]); + c.token("tag_not_shortest", "tag 17 written with one extra byte (RFC 8949 §4.2.1)", &long_tag, Some(Mal)); + let mut indefinite = vec![0xd1, 0x9f]; + indefinite.extend_from_slice(&t1[2..]); + indefinite.push(0xff); + c.token("indefinite_array", "the COSE array of indefinite length", &indefinite, Some(Mal)); + let untagged = match &inner { + Item::Tag(_, x) => encode(x), + _ => unreachable!("a tagged token"), + }; + c.token("untagged", "the COSE_Mac0 array with no COSE tag (RFC 8392 §7.2 step 3)", &untagged, Some(Uns)); + c.token("cwt_tag_not_cose", "the CWT tag around an untagged array (RFC 8392 §7.2 step 2)", &encode(&Item::Tag(tag::CWT, Box::new(cbor::read_one(&untagged).expect("an array")))), Some(Mal)); + let sign_tag = match &inner { + Item::Tag(_, x) => encode(&Item::Tag(tag::SIGN, x.clone())), + _ => unreachable!("a tagged token"), + }; + c.token("cose_sign", "a COSE_Sign (tag 98), which this revision does not read", &sign_tag, Some(Uns)); + let payload = encode(&claims_map(&clear_claims(&pattern(1)))); + c.token("detached_payload", "a null payload", &encode(&mac0_item(alg_header(5), Item::Map(vec![]), None, 32)), Some(Uns)); + c.token("alg_unknown", "alg 6 (HMAC 384/384)", &encode(&mac0_item(alg_header(6), Item::Map(vec![]), Some(payload.clone()), 32)), Some(Uns)); + c.token("alg_on_wrong_structure", "ES256 on a COSE_Mac0", &encode(&mac0_item(alg_header(alg::ES256), Item::Map(vec![]), Some(payload.clone()), 32)), Some(Mal)); + c.token("tag_short", "a MAC of 16 bytes", &encode(&mac0_item(alg_header(5), Item::Map(vec![]), Some(payload.clone()), 16)), Some(Mal)); + c.token("header_label_unknown", "a content-type header (label 3), not understood (RFC 8392 §7.2 step 4)", &encode(&mac0_item(alg_header(5), Item::Map(vec![(Item::Uint(3), Item::Text("application/cwt".into()))]), Some(payload.clone()), 32)), Some(Uns)); + c.token("alg_unprotected", "alg in the unprotected header, the protected header empty", &encode(&mac0_item(vec![], Item::Map(vec![(Item::Uint(1), Item::Uint(5))]), Some(payload.clone()), 32)), Some(Mal)); + c.token("kid_not_bytes", "a kid that is a text string", &encode(&mac0_item(alg_header(5), Item::Map(vec![(Item::Uint(4), Item::Text("k1".into()))]), Some(payload.clone()), 32)), Some(Mal)); + c.token("nested_cwt", "a payload that is itself a COSE_Mac0 (RFC 8392 §7.2 step 6)", &mac0(t1.clone()), Some(Uns)); + c.token("claims_not_map", "a payload that is an array", &mac0(encode(&Item::Array(vec![Item::Uint(1)]))), Some(Mal)); + let mut text_key = clear_claims(&pattern(1)); + let mut m: Vec<(Item, Item)> = text_key.drain(..).map(|(k, v)| (Item::Uint(k), v)).collect(); + m.push((Item::Text("wmver".into()), Item::Uint(1))); + c.token("text_claim_key", "a claim keyed by a text string (the TS's §5.4 has integer keys)", &mac0(encode(&Item::Map(m))), Some(Mal)); + // Keys out of order, written by hand: encode() would sort them. + let mut unsorted = vec![0xa6]; + for (k, v) in [(claim::IAT, Item::Uint(T0_S - 600)), (claim::EXP, Item::Uint(T0_S + DAY))] { + unsorted.extend(encode(&Item::Uint(k))); + unsorted.extend(encode(&v)); + } + for (k, v) in clear_claims(&pattern(1)).into_iter().skip(2) { + unsorted.extend(encode(&Item::Uint(k))); + unsorted.extend(encode(&v)); + } + c.token("claims_unsorted", "the claims set with iat before exp, not in deterministic order", &mac0(unsorted), Some(Mal)); + let mut float = vec![0xa6, 0x04, 0xfb]; + float.extend_from_slice(&((T0_S + DAY) as f64).to_be_bytes()); + for (k, v) in clear_claims(&pattern(1)).into_iter().skip(1) { + float.extend(encode(&Item::Uint(k))); + float.extend(encode(&v)); + } + c.token("exp_float", "exp written as a floating-point NumericDate", &mac0(float), Some(Uns)); + let mut deep = Item::Uint(0); + for _ in 0..16 { + deep = Item::Array(vec![deep]); + } + let mut dc = clear_claims(&pattern(1)); + dc.push((901, deep)); + c.token("extension_too_deep", "an extension claim whose arrays reach a depth of 17 in the claims set", &mac0(encode(&claims_map(&dc))), Some(Uns)); + let mut bad_utf8 = vec![0xa7]; + bad_utf8.extend(encode(&Item::Uint(claim::ISS))); + bad_utf8.extend_from_slice(&[0x62, 0xc3, 0x28]); + for (k, v) in clear_claims(&pattern(1)) { + bad_utf8.extend(encode(&Item::Uint(k))); + bad_utf8.extend(encode(&v)); + } + c.token("iss_not_utf8", "an iss whose bytes are not UTF-8", &mac0(bad_utf8), Some(Mal)); + let drop = |k: u64| -> Vec { + let mut x = clear_claims(&pattern(1)); + x.retain(|(key, _)| *key != k); + mac0(encode(&claims_map(&x))) + }; + c.token("no_exp", "no exp claim (the TS's §5.4 requires it)", &drop(claim::EXP), Some(Mal)); + c.token("no_iat", "no iat claim", &drop(claim::IAT), Some(Mal)); + let set = |k: u64, v: Item| -> Vec { + let mut x = clear_claims(&pattern(1)); + x.retain(|(key, _)| *key != k); + x.push((k, v)); + mac0(encode(&claims_map(&x))) + }; + c.token("wmver_2", "wmver 2: the TS describes version 1", &set(claim::WMVER, Item::Uint(2)), Some(Uns)); + c.token("wmvnd_too_large", "a wmvnd of 256, past uint .size 1", &set(claim::WMVND, Item::Uint(256)), Some(Mal)); + c.token("pattern_short", "a clear pattern of 7 bytes for a wmpatlen of 64", &set(claim::WMPATTERN, Item::Bytes(pattern(1)[..7].to_vec())), Some(Mal)); + c.token("segduration_three", "a wmsegduration of three integers", &set(claim::WMSEGDURATION, Item::Array(vec![Item::Uint(6), Item::Uint(1), Item::Uint(1)])), Some(Mal)); + c.token("sub_not_text", "a sub that is a byte string", &set(claim::SUB, Item::Bytes(vec![1])), Some(Mal)); + let mut no_key = derived_claims(3); + no_key.retain(|(k, _)| *k != claim::WMKEYVER); + c.token("indirect_without_wmkeyver", "indirect mode with no wmkeyver", &mac0(encode(&claims_map(&no_key))), Some(Mal)); +} + +fn session_vectors(c: &mut Corpus) { + let b = c.rooms["broadcast"].clone(); + let ps = plans(); + let rec = |i: usize| ps[i].signed(&b).to_bytes(); + let tok = |i: usize| Some(ps[i].token()); + let four: Vec<(Vec, Option>)> = (0..4).map(|i| (rec(i), tok(i))).collect(); + c.sessions("lane_a_sessions", "two edges' records of four sessions of the broadcast room, one token in each mode, each token given", "broadcast", no_ov(), &four, None); + let bare: Vec<(Vec, Option>)> = (0..4).map(|i| (rec(i), None)).collect(); + c.sessions("records_without_tokens", "the same records as a matching party holds them, no token given", "broadcast", no_ov(), &bare, None); + let k = c.rooms["keyless"].clone(); + let kp = Plan { n: 1, edge: EDGE, ..ps[0].clone() }; + c.sessions("keyless_clock_is_edge", "a room with no origin whose clock is the edge that signs", "keyless", no_ov(), &[(kp.signed(&k).to_bytes(), Some(kp.token()))], None); + + let one = |c: &mut Corpus, name: &str, d: &str, record: Vec, token: Option>, at: At, r: Reason| { + c.sessions(name, d, "broadcast", no_ov(), &[(record, token)], Some((at, r))); + }; + let s0 = || ps[0].record(&b); + use events::Reason as E; + let full = rec(0); + one(c, "record_truncated", "session 1's record without its last byte", full[..full.len() - 1].to_vec(), None, At::Session(0), ev(E::Truncated)); + let mut t = full.clone(); + t.push(0); + one(c, "record_trailing", "session 1's record followed by one byte", t, None, At::Session(0), ev(E::Malformed)); + let mut v = full.clone(); + v[4] = 2; + one(c, "record_version_2", "a record of version 2", v, None, At::Session(0), ev(E::UnknownVersion)); + let mut x = s0(); + let one_bit = (0..PATLEN as usize).find(|&p| bit(&x.bits, p)).expect("a pattern with a 1"); + set_bit(&mut x.served, one_bit, false); + one(c, "bits_not_served", "a record whose bits set a position its served does not (§5.5)", resign(x, EDGE), None, At::Session(0), Reason::RecordInvalid); + one(c, "wrong_edge_key", "session 1's record, naming the first edge, signed by the second edge's key", s0().sign(&key(EDGE2)).to_bytes(), None, At::Session(0), ev(E::BadSignature)); + let mut x = s0(); + x.room = c.rooms["keyless"].id; + one(c, "record_of_another_room", "a record naming another room", resign(x, EDGE), None, At::Session(0), ev(E::WrongRoom)); + let mut x = s0(); + x.edge = pk(RELAY); + one(c, "edge_without_authority", "a record signed by a key the log lists with live/relay and not live/watermark", resign(x, RELAY), None, At::Session(0), Reason::EdgeNotAuthorised); + let mut x = s0(); + x.edge = pk(BROADCASTER); + one(c, "edge_not_listed", "a record signed by a key the log does not list", resign(x, BROADCASTER), None, At::Session(0), Reason::EdgeNotAuthorised); + let mut x = s0(); + x.edge = pk(EDGE_OLD); + one(c, "edge_revoked", "a record signed by an edge the log revokes", resign(x, EDGE_OLD), None, At::Session(0), Reason::EdgeRevoked); + let kx = Plan { n: 1, edge: EDGE2, ..ps[0].clone() }; + c.sessions("keyless_edge_not_room_key", "the room with no origin, its record signed by a key that is not a room key", "keyless", no_ov(), &[(kx.signed(&k).to_bytes(), None)], Some((At::Session(0), Reason::EdgeNotAuthorised))); + let mut x = s0(); + x.owner = b"_backup".to_vec(); + one(c, "content_unknown_slot", "a record whose content is a slot the descriptor does not declare", resign(x, EDGE), None, At::Session(0), Reason::ContentUnknown); + let mut x = s0(); + x.media = media::AUDIO; + one(c, "content_wrong_media", "a record naming the video slot as audio", resign(x, EDGE), None, At::Session(0), Reason::ContentUnknown); + let mut x = s0(); + x.kids.clear(); + one(c, "not_protected", "a record that names no content key: not a Lane A delivery (§3.1)", resign(x, EDGE), None, At::Session(0), Reason::NotProtected); + let mut x = s0(); + x.region = *b"US"; + one(c, "region_not_pinned", "a record kept in a region outside the room's pin (DE, FR)", resign(x, EDGE), None, At::Session(0), Reason::RegionNotPinned); + let mut x = s0(); + x.expires = expires_for(x.end, 31); + one(c, "retention_exceeded", "a record kept 31 days past the session, the room's bound 30", resign(x, EDGE), None, At::Session(0), Reason::RetentionExceeded); + let mut x = s0(); + x.expires = T0_S; + one(c, "expires_before_end", "a record that expires before its session ends (§6.1)", resign(x, EDGE), None, At::Session(0), Reason::RecordInvalid); + let mut x = ps[3].record(&b); + x.token = s0().token; + c.sessions("token_reused", "two records naming one token", "broadcast", no_ov(), &[(rec(0), None), (resign(x, EDGE2), None)], Some((At::Session(1), Reason::TokenReused))); + let garbage = vec![0xd1, 0x84, 0x43, 0xa1, 0x01]; + let mut x = s0(); + x.token = sha256(&[&garbage]); + one(c, "token_does_not_decode", "a record whose token is cut short: it is not one CBOR item", resign(x, EDGE), Some(garbage), At::Session(0), Reason::TokenMalformed); + let mut v2 = clear_claims(&pattern(1)); + v2.retain(|(k, _)| *k != claim::WMVER); + v2.push((claim::WMVER, Item::Uint(2))); + let v2 = mac0(encode(&claims_map(&v2))); + let mut x = s0(); + x.token = sha256(&[&v2]); + one(c, "token_version_2", "a record whose token is of wmver 2", resign(x, EDGE), Some(v2), At::Session(0), Reason::TokenUnsupported); + one(c, "token_not_the_records", "session 1's record given session 3's token", rec(0), tok(2), At::Session(0), Reason::TokenMismatch); + let mut x = s0(); + let flip = !bit(&x.bits, 9); + set_bit(&mut x.bits, 9, flip); + one(c, "bits_not_the_pattern", "a record whose bits differ from its clear token's pattern at one served position", resign(x, EDGE), tok(0), At::Session(0), Reason::TokenMismatch); + let mut x = s0(); + x.source = source::DECRYPTED; + one(c, "source_not_the_tokens", "a record that states a decrypted pattern for a token in the clear", resign(x, EDGE), tok(0), At::Session(0), Reason::TokenMismatch); + let mut early = clear_claims(&pattern(1)); + early.retain(|(k, _)| *k != claim::EXP); + early.push((claim::EXP, Item::Uint(T0_S + 1_000))); + let early = mac0(encode(&claims_map(&early))); + let mut x = s0(); + x.token = sha256(&[&early]); + one(c, "token_expires_in_session", "a token whose exp falls before the session's end", resign(x, EDGE), Some(early), At::Session(0), Reason::OutsideTokenWindow); + let e = c.rooms["e2ee"].clone(); + let ep = Plan { n: 1, edge: CLOCK, ..ps[0].clone() }; + c.sessions("e2ee_room", "a record for a room whose authority signed a confidentiality record (Lane B, §3.2)", "e2ee", no_ov(), &[(ep.signed(&e).to_bytes(), None)], Some((At::Confidentiality, Reason::E2eeRoom))); + let forged = confidentiality_record(&b.id, CLOCK); + c.sessions("confidentiality_bad_signature", "the broadcast room given a confidentiality record its clock signed, not its authority", "broadcast", ov("confidentiality", json!(hex(&forged))), &[(rec(0), None)], Some((At::Confidentiality, ev(E::BadSignature)))); + c.sessions("key_log_missing", "the broadcast room, which names an origin, given no key-log head", "broadcast", ov("key_log", Value::Null), &[(rec(0), None)], Some((At::KeyLog, Reason::Room(room::Reason::KeyLogMissing)))); + let mut desc = b.descriptor.clone(); + let n = desc.len(); + desc[n - 7] ^= 0x01; + c.sessions("descriptor_bad_signature", "the descriptor with one bit of its signature flipped", "broadcast", ov("descriptor", json!(hex(&desc))), &[(rec(0), None)], Some((At::Descriptor, ev(E::BadSignature)))); + let mut grants = b.grants.clone(); + let n = grants[1].len(); + grants[1][n - 7] ^= 0x01; + c.sessions("grant_bad_signature", "a grant of the room with one bit of its signature flipped", "broadcast", ov("grants", json!(grants.iter().map(|g| hex(g)).collect::>())), &[(rec(0), None)], Some((At::Grant(1), ev(E::BadSignature)))); +} + +fn match_vectors(c: &mut Corpus) { + let b = c.rooms["broadcast"].clone(); + let ps = plans(); + let records: Vec<(Vec, Option>)> = (0..4).map(|i| (ps[i].signed(&b).to_bytes(), None)).collect(); + let claim = key(CLAIMANT); + let q = q1(&b).sign(&claim); + c.matched("recovered_from_session_1", "a copy of session 1, positions 0 to 47 recovered but three erasures: it agrees with session 1 alone", &records, &q.to_bytes(), Ok(None)); + // Positions session 2 never served at which sessions 1 and 3 hold the same bit, until + // session 4 differs at one of them. + let (p1, p3, p4) = (pattern(1), pattern(3), pattern(4)); + let mut known = Vec::new(); + for p in 6..50usize { + if bit(&p1, p) == bit(&p3, p) { + known.push(p); + if known.len() >= 3 && known.iter().any(|&k| bit(&p4, k) != bit(&p1, k)) { + break; + } + } + } + let two = sequence(&b, 1, &known, 0, u64::MAX).sign(&claim); + c.matched("agrees_with_two", "a few positions at which sessions 1 and 3 embedded the same variants, the span unknown: two sessions agree", &records, &two.to_bytes(), Ok(Some(Reason::AmbiguousMatch))); + let mut d = q1(&b); + let flip = !bit(&d.bits, 0); + set_bit(&mut d.bits, 0, flip); + c.matched("bits_disagree", "session 1's copy with the bit at position 0 the other variant: no session agrees", &records, &d.sign(&claim).to_bytes(), Ok(Some(Reason::NoMatch))); + let mut d = q1(&b); + d.start = 5_000 * S; + d.end = 6_000 * S; + c.matched("span_meets_none", "session 1's copy stated as delivered after every session ended: no candidate", &records, &d.sign(&claim).to_bytes(), Ok(Some(Reason::NoMatch))); + let mut d = q1(&b); + d.vendor = 9; + c.matched("another_vendor", "session 1's bits as another vendor's detector recovered them: no candidate", &records, &d.sign(&claim).to_bytes(), Ok(Some(Reason::NoMatch))); + use events::Reason as E; + let mut d = q1(&b); + d.known = vec![0; 8]; + d.bits = vec![0; 8]; + c.matched("sequence_states_nothing", "a sequence that recovered no position", &records, &d.sign(&claim).to_bytes(), Err((At::Sequence, Reason::SequenceInvalid))); + let mut d = q1(&b); + set_bit(&mut d.bits, 5, true); + c.matched("bits_not_known", "a sequence with a bit at an erasure", &records, &d.sign(&claim).to_bytes(), Err((At::Sequence, Reason::SequenceInvalid))); + c.matched("sequence_bad_signature", "session 1's sequence signed by a key other than its claimant's", &records, &q1(&b).sign(&key(EDGE)).to_bytes(), Err((At::Sequence, ev(E::BadSignature)))); + let mut d = q1(&b); + d.room = c.rooms["keyless"].id; + c.matched("sequence_of_another_room", "a sequence naming another room", &records, &d.sign(&claim).to_bytes(), Err((At::Sequence, ev(E::WrongRoom)))); + let qb = q.to_bytes(); + c.matched("sequence_truncated", "session 1's sequence without its last byte", &records, &qb[..qb.len() - 1], Err((At::Sequence, ev(E::Truncated)))); +} + +fn disclosure_vectors(c: &mut Corpus) { + let b = c.rooms["broadcast"].clone(); + let ps = plans(); + let auth = key(AUTHORITY); + let claim = key(CLAIMANT); + let s1 = ps[0].signed(&b); + let s3 = ps[2].signed(&b); + let q = q1(&b).sign(&claim); + let open = |s: &Session, q: &Sequence, n: u8, viewer: u8, t: u64| Disclosure { + room: b.id, + session: s.digest(), + sequence: q.digest(), + grant: b.grant_of[&viewer], + principal: pk(viewer), + salt: salt(n), + t, + sig: [0; 64], + }; + let t1 = T0_S + DAY; + let d1 = open(&s1, &q, 1, V1, t1).sign(&auth); + let tok1 = ps[0].token(); + c.disclosure("disclosure_of_session_1", "the authority opens session 1 for the sequence that matched it; its token given", no_ov(), &s1.to_bytes(), Some(&tok1), &q.to_bytes(), &d1.to_bytes(), None); + c.disclosure("disclosure_without_token", "the same disclosure, checked without the token", no_ov(), &s1.to_bytes(), None, &q.to_bytes(), &d1.to_bytes(), None); + use events::Reason as E; + let at = |r: Reason| Some((At::Disclosure, r)); + let other = open(&s3, &q, 3, V3, t1).sign(&auth); + c.disclosure("disclosure_of_another_session", "a disclosure of session 3, checked against session 1's record", no_ov(), &s1.to_bytes(), None, &q.to_bytes(), &other.to_bytes(), at(Reason::DisclosureMismatch)); + let mut q3 = q1(&b); + let flip = !bit(&q3.bits, 0); + set_bit(&mut q3.bits, 0, flip); + let q3 = q3.sign(&claim); + let wrong_seq = open(&s1, &q3, 1, V1, t1).sign(&auth); + c.disclosure("disclosure_for_another_sequence", "a disclosure answering another sequence than the one given", no_ov(), &s1.to_bytes(), None, &q.to_bytes(), &wrong_seq.to_bytes(), at(Reason::DisclosureMismatch)); + let mut swapped = open(&s3, &q, 3, V3, t1); + swapped.session = s1.digest(); + let swapped = swapped.sign(&auth); + c.disclosure("opening_of_another_session", "session 1 and its sequence named, with session 3's opening", no_ov(), &s1.to_bytes(), None, &q.to_bytes(), &swapped.to_bytes(), at(Reason::OpeningMismatch)); + let s3d = open(&s3, &q, 3, V3, t1).sign(&auth); + c.disclosure("sequence_disagrees", "session 3 opened for session 1's sequence, which does not agree with it", no_ov(), &s3.to_bytes(), None, &q.to_bytes(), &s3d.to_bytes(), at(Reason::SequenceDisagrees)); + let without_v1: Vec = b.grants.iter().filter(|g| room::grant_digest_and_principal(g).map(|(d, _)| d) != Ok(b.grant_of[&V1])).map(|g| hex(g)).collect(); + c.disclosure("grant_not_given", "the disclosure checked with every grant of the room but the one it opens to", ov("grants", json!(without_v1)), &s1.to_bytes(), None, &q.to_bytes(), &d1.to_bytes(), at(Reason::Room(room::Reason::GrantUnknown))); + // A record whose binding commits to the first viewer's grant and the second viewer's key. + let mut mixed = ps[0].record(&b); + mixed.binding = binding(&salt(6), &b.grant_of[&V1], &pk(V2)); + let mixed = mixed.sign(&key(EDGE)); + let mut dm = open(&mixed, &q, 6, V1, t1); + dm.principal = pk(V2); + let dm = dm.sign(&auth); + c.disclosure("grant_wrong_principal", "an edge's binding of one viewer's grant to another viewer's key, opened as committed", no_ov(), &mixed.to_bytes(), None, &q.to_bytes(), &dm.to_bytes(), at(Reason::Room(room::Reason::GrantWrongPrincipal))); + let s5 = ps[4].signed(&b); + let known: Vec = (0..32).collect(); + let q5 = sequence(&b, 5, &known, 600 * S, 1_800 * S).sign(&claim); + let d5 = open(&s5, &q5, 5, V4, t1).sign(&auth); + c.disclosure("outside_grant_window", "a session that ran past the end of its viewer's grant", no_ov(), &s5.to_bytes(), None, &q5.to_bytes(), &d5.to_bytes(), at(Reason::Room(room::Reason::OutsideGrantWindow))); + let late = open(&s1, &q, 1, V1, s1.expires + 1).sign(&auth); + c.disclosure("disclosure_expired", "a disclosure signed one second after the record's expires", no_ov(), &s1.to_bytes(), None, &q.to_bytes(), &late.to_bytes(), at(Reason::DisclosureExpired)); + let by_edge = open(&s1, &q, 1, V1, t1).sign(&key(EDGE)); + c.disclosure("disclosure_bad_signature", "the disclosure signed by the edge, not the room authority", no_ov(), &s1.to_bytes(), None, &q.to_bytes(), &by_edge.to_bytes(), at(ev(E::BadSignature))); + let mut other_room = open(&s1, &q, 1, V1, t1); + other_room.room = c.rooms["keyless"].id; + c.disclosure("disclosure_of_another_room", "a disclosure naming another room", no_ov(), &s1.to_bytes(), None, &q.to_bytes(), &other_room.sign(&auth).to_bytes(), at(ev(E::WrongRoom))); + let db = d1.to_bytes(); + c.disclosure("disclosure_truncated", "the disclosure without its last byte", no_ov(), &s1.to_bytes(), None, &q.to_bytes(), &db[..db.len() - 1], at(ev(E::Truncated))); + let mut t = db.clone(); + t.push(0); + c.disclosure("disclosure_trailing", "the disclosure followed by one byte", no_ov(), &s1.to_bytes(), None, &q.to_bytes(), &t, at(ev(E::Malformed))); +} + +/// The corpus: `vectors.json`, as (file name, contents). +pub fn generate() -> Vec<(String, String)> { + let mut rooms = BTreeMap::new(); + rooms.insert("broadcast", broadcast()); + rooms.insert("keyless", keyless()); + rooms.insert("e2ee", e2ee()); + let mut c = Corpus { rooms, tokens: vec![], sessions: vec![], matches: vec![], disclosures: vec![], reasons: BTreeSet::new() }; + token_vectors(&mut c); + session_vectors(&mut c); + match_vectors(&mut c); + disclosure_vectors(&mut c); + for r in Reason::ALL { + assert!(c.reasons.contains(r.code()), "the corpus gives no vector the reason {}", r.code()); + } + let doc = json!({ + "corpus": CAPABILITY, + "extension": "extensions/forensic-watermark.md", + "description": "Forensic watermark session records (wai.live.watermark): tokens read by §4.2, session records verified by §9.1, recovered sequences matched by §7.3 and §7.4, and disclosures checked by §9.3. Keys, patterns, salts and KIDs are SHA-256 of a label and an index; the tokens' MACs are HMAC-SHA-256 under wmt_key, which no verifier checks (§4.4).", + "principals": PRINCIPALS.iter().map(|(n, i)| (n.to_string(), json!(hex(&pk(*i))))).collect::>(), + "t0_us": T0_US, + "wmt_key": hex(&wmt_key()), + "rooms": c.rooms.iter().map(|(n, r)| (n.to_string(), r.json())).collect::>(), + "tokens": c.tokens, + "sessions": c.sessions, + "matches": c.matches, + "disclosures": c.disclosures, + }); + vec![("vectors.json".to_string(), serde_json::to_string_pretty(&doc).expect("JSON") + "\n")] +} + +fn b(v: &Value) -> Vec { + unhex(v.as_str().unwrap_or("")).unwrap_or_default() +} + +fn room_of(doc: &Value, v: &Value) -> RoomBuild { + let r = &doc["rooms"][v["room"].as_str().unwrap_or("")]; + let mut base = RoomBuild { + descriptor: b(&r["descriptor"]), + grants: r["grants"].as_array().map(|a| a.iter().map(b).collect()).unwrap_or_default(), + head: r["key_log"].as_str().map(String::from), + confidentiality: r["confidentiality"].as_str().map(|s| unhex(s).unwrap_or_default()), + id: [0; 32], + grant_of: BTreeMap::new(), + }; + if let Some(o) = v["room_override"].as_object() { + base = with_override(&base, o); + } + base +} + +fn given_of(v: &Value) -> (Vec, Option>) { + (b(&v["record"]), v["token"].as_str().map(|s| unhex(s).unwrap_or_default())) +} + +/// Check the corpus file against this module: every vector's outcome, reason, place and facts. +pub fn verify(files: &BTreeMap) -> Vec { + let mut fails = Vec::new(); + let Some(text) = files.get("vectors.json") else { return vec!["no vectors.json".into()] }; + let doc: Value = match serde_json::from_str(text) { + Ok(d) => d, + Err(e) => return vec![format!("vectors.json: {e}")], + }; + let refused = |at: At, r: Reason| (json!("refuse"), json!(r.code()), at.json()); + for v in doc["tokens"].as_array().into_iter().flatten() { + let got = match read_token(&b(&v["token"])) { + Ok(t) => (json!("read"), Value::Null, token_json(&t)), + Err(r) => (json!("refuse"), json!(r.code()), Value::Null), + }; + if (&v["outcome"], &v["reason"], &v["facts"]) != (&got.0, &got.1, &got.2) { + fails.push(format!("token {}: got {:?}", v["name"], got)); + } + } + for v in doc["sessions"].as_array().into_iter().flatten() { + let r = room_of(&doc, v); + let given: Vec<(Vec, Option>)> = v["records"].as_array().into_iter().flatten().map(given_of).collect(); + let g: Vec = given.iter().map(|(rec, t)| Given { record: rec, token: t.as_deref() }).collect(); + let got = match verify_sessions(&r.input(), &g) { + Ok((_, f)) => (json!("accept"), Value::Null, Value::Null, Value::Array(f.iter().map(session_json).collect())), + Err((at, re)) => { + let (o, rc, a) = refused(at, re); + (o, rc, a, Value::Null) + } + }; + if (&v["outcome"], &v["reason"], &v["at"], &v["sessions"]) != (&got.0, &got.1, &got.2, &got.3) { + fails.push(format!("sessions {}: got {} {} {}", v["name"], got.0, got.1, got.2)); + } + } + for v in doc["matches"].as_array().into_iter().flatten() { + let r = room_of(&doc, v); + let given: Vec<(Vec, Option>)> = v["records"].as_array().into_iter().flatten().map(given_of).collect(); + let g: Vec = given.iter().map(|(rec, t)| Given { record: rec, token: t.as_deref() }).collect(); + let (outcome, reason, session) = match verify_match(&r.input(), &g, &b(&v["sequence"])) { + Ok(m) => (json!(if m.matched.is_some() { "matched" } else { "unmatched" }), m.reason.map_or(Value::Null, |x| json!(x.code())), m.matched.map_or(Value::Null, |d| json!(hex(&d)))), + Err((_, re)) => (json!("refuse"), json!(re.code()), Value::Null), + }; + if v["outcome"] != outcome || v["reason"] != reason || v["match"]["session"] != session { + fails.push(format!("match {}: got {outcome} {reason}", v["name"])); + } + } + for v in doc["disclosures"].as_array().into_iter().flatten() { + let r = room_of(&doc, v); + let (rec, tok) = given_of(&v["record"]); + let got = match verify_disclosure(&r.input(), Given { record: &rec, token: tok.as_deref() }, &b(&v["sequence"]), &b(&v["disclosure"])) { + Ok(f) => (json!("accept"), Value::Null, json!(hex(&f.digest))), + Err((at, re)) => { + let (o, rc, _) = refused(at, re); + (o, rc, Value::Null) + } + }; + if v["outcome"] != got.0 || v["reason"] != got.1 || v["facts"]["digest"] != got.2 { + fails.push(format!("disclosure {}: got {} {}", v["name"], got.0, got.1)); + } + } + fails +} diff --git a/wai/wai-rs/tests/live_watermark.rs b/wai/wai-rs/tests/live_watermark.rs new file mode 100644 index 000000000..9e3c57823 --- /dev/null +++ b/wai/wai-rs/tests/live_watermark.rs @@ -0,0 +1,53 @@ +//! `wai.live.watermark` (extensions/forensic-watermark.md): the committed corpus +//! `live-conformance/watermark/` regenerates byte for byte from its fixed seeds, holds no +//! other file, every case in it gets the outcome it states, and the extension's §11 lists +//! exactly the reasons the reference reports. The generator itself asserts that the corpus +//! gives every one of them. +#![cfg(feature = "live_watermark")] + +use std::collections::BTreeMap; +use std::path::PathBuf; +use wai::live_watermark::{corpus, Reason}; + +fn wai_dir() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("..") +} + +fn committed() -> BTreeMap { + let dir = wai_dir().join("live-conformance/watermark"); + std::fs::read_dir(&dir) + .expect("the committed corpus") + .flatten() + .map(|e| (e.file_name().to_string_lossy().into_owned(), e.path())) + .filter(|(n, _)| n != "README.md") + .map(|(n, p)| (n, std::fs::read_to_string(p).expect("a text file"))) + .collect() +} + +#[test] +fn the_committed_corpus_regenerates_from_its_seeds() { + let files = committed(); + let generated: BTreeMap = corpus::generate().into_iter().collect(); + let extra: Vec<&String> = files.keys().filter(|k| !generated.contains_key(*k)).collect(); + let missing: Vec<&String> = generated.keys().filter(|k| !files.contains_key(*k)).collect(); + let differ: Vec<&String> = generated.iter().filter(|(k, v)| files.get(*k).is_some_and(|f| f != *v)).map(|(k, _)| k).collect(); + assert!(extra.is_empty() && missing.is_empty() && differ.is_empty(), "extra {extra:?}, missing {missing:?}, differ {differ:?}: run `wai_live_watermark_vectors gen`"); +} + +#[test] +fn every_case_gets_its_stated_outcome() { + let fails = corpus::verify(&committed()); + assert!(fails.is_empty(), "{}", fails.join("\n")); +} + +/// §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/forensic-watermark.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 = + text[at..end].lines().filter(|l| l.starts_with("| `")).filter_map(|l| l.split('`').nth(1).map(String::from)).collect(); + let want: Vec = Reason::ALL.iter().map(|r| r.code().to_string()).collect(); + assert_eq!(listed, want, "§11's table and Reason::ALL differ"); +} -- 2.54.0 (Apple Git-157) From aafbf8e1d6776b684cc666f68d359f3106897d76 Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Tue, 20 Oct 2026 08:40:15 -0400 Subject: [PATCH 2/6] wai.live.watermark: the corpus, its second implementation, the CI step and the changelog live-conformance/watermark/vectors.json from wai_live_watermark_vectors, and live_watermark_verify.py, written from the extension's text, which agrees on every vector, fails every mutated copy and catches a verifier that leaves out any one of its checks. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/wai-conformance.yml | 27 +- wai/CHANGELOG.md | 48 ++ wai/live-conformance/README.md | 11 +- wai/live-conformance/live_watermark_verify.py | 793 ++++++++++++++++++ wai/live-conformance/watermark/README.md | 55 ++ 5 files changed, 929 insertions(+), 5 deletions(-) create mode 100644 wai/live-conformance/live_watermark_verify.py create mode 100644 wai/live-conformance/watermark/README.md diff --git a/.github/workflows/wai-conformance.yml b/.github/workflows/wai-conformance.yml index 291a16793..1cc19a454 100644 --- a/.github/workflows/wai-conformance.yml +++ b/.github/workflows/wai-conformance.yml @@ -95,6 +95,10 @@ on: # reports, live_moderation_verify.py reads its §9.5, §9.6 and §10 tables, and the # registry's wire identifiers test reads its sections. - 'wai/extensions/live-moderation.md' + # tests/live_watermark.rs requires this extension's §11 to list the codes the reference + # reports, live_watermark_verify.py reads that table, and the registry's wire identifiers + # test reads its sections. + - 'wai/extensions/forensic-watermark.md' # e2ee_verify.py reads this extension's §13 table, and the registry's wire identifiers # test reads its sections. - 'wai/extensions/confidentiality.md' @@ -767,7 +771,8 @@ jobs: # live-recording corpus), timed_text_verify.py (the timed-text corpus), # live_metering_verify.py (the live-metering corpus), energy_steering_verify.py (the # energy-steering corpus), and live_moderation_verify.py (the live-moderation corpus), and - # e2ee_verify.py (the end-to-end encrypted rooms corpus). + # e2ee_verify.py (the end-to-end encrypted rooms corpus), and live_watermark_verify.py (the + # forensic-watermark 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: @@ -1073,6 +1078,26 @@ jobs: working-directory: wai shell: bash run: python3 -I live-conformance/e2ee_verify.py live-conformance/e2ee + # wai.live.watermark (extensions/forensic-watermark.md): the reference module's unit tests + # (the CBOR reader's deterministic-encoding rules, tokens of both COSE structures and + # every mode read and their rules refuse, a record's rules, big-endian bit addressing, + # the exact match and the binding) and tests/live_watermark.rs (vectors.json regenerates + # byte for byte from its fixed seeds and no other file is there, wai-rs gives every + # token, session list, match and disclosure its stated outcome, and §11 lists exactly the + # reference's codes, each of which the corpus gives); then the corpus's second + # implementation, live_watermark_verify.py, written from the extension's text and sharing + # no code with wai-rs, which reads rooms with live_rooms_verify.py and tokens with a CBOR + # reader of its own, requires every mutated copy of the corpus to fail, and drops each of + # its own checks in turn and requires the corpus to catch the weakened verifier; standard + # library only, run isolated (-I). + - name: forensic watermark sessions · wai-rs and live_watermark_verify.py + working-directory: wai + shell: bash + run: | + set -euo pipefail + (cd wai-rs && cargo test --no-default-features --features live_watermark --lib live_watermark::) + (cd wai-rs && cargo test --no-default-features --features live_watermark --test live_watermark) + python3 -I live-conformance/live_watermark_verify.py live-conformance/watermark # 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, diff --git a/wai/CHANGELOG.md b/wai/CHANGELOG.md index 5bed6ab66..aa68670b2 100644 --- a/wai/CHANGELOG.md +++ b/wai/CHANGELOG.md @@ -6,6 +6,54 @@ wire format may change and such changes are recorded here. ## v0.4.0 — current +### `wai.live.watermark`: forensic watermark sessions, matched and disclosed one at a time + +- **The extension** ([forensic-watermark](extensions/forensic-watermark.md)) registers the + record `wai.live.watermark`, for a room's Lane A delivery (SPEC §6.5). The edge that chooses + each segment's A or B variant for a viewer, as ETSI TS 104 002 has an edge choose it, signs a + **session record**, `WWS1`, when the session ends: the room and the content (a slot or a + publisher, and its media kind), the span, the KIDs of the content keys the variants were + encrypted under, the TS's watermark token by its SHA-256, the token's vendor and pattern + length, how the edge had the pattern (in the clear, decrypted, or derived by the vendor's + core), a bitmap of the positions it sequenced and the variant at each, its counts of + sequenced and unmarked segments, a salted commitment to the session's grant and principal, + where it keeps the record, and when it is deleted. +- **The token** is the TS's CWT, read as the TS and RFC 8392, RFC 8949 §4.2, RFC 9052 and RFC + 9053 have it: deterministically encoded CBOR, a COSE_Mac0 under HMAC 256/256 or a COSE_Sign1 + under ES256, optionally in the CWT tag, and the claims `wmver`, `wmvnd`, `wmpatlen`, + `wmsegduration`, `wmpattern` (in the clear, or a COSE_Encrypt0 or COSE_Encrypt) or `wmid`, + `wmopid` and `wmkeyver`, with `exp` and `iat`. A form the RFCs forbid is `token_malformed`, + and one they allow that this revision does not read (a floating-point date, an untagged or + nested message, another algorithm) `token_unsupported`. The record names the token by digest + and does not carry it: a token can carry `sub` or a vendor's `wmid`, and can be presented + again until it expires. The token's MAC or signature is the edge's to check. +- **No viewer in the record.** It names no grant: every participant holds a room's grants, and + a grant names its principal. Only the room authority, to which the edge gives each record's + opening, can link a session to a principal. Records, tokens and openings are kept within the + room's region pin and its retention bound. +- **Matching.** A claimant signs the variant sequence it recovered from a copy, `WWQ1`, with + its erasures. A session agrees when it sequenced every stated position with the stated + variant; a match is the one agreeing session among the records given (`no_match`, + `ambiguous_match` otherwise). The TS leaves detection's statistics to its vendors, so the + rule has no tolerance and no threshold, and states how many positions were stated. +- **Disclosure.** For one matched session the room authority signs a `WWD1` that opens its + commitment; a third party checks it against that record, the sequence and the grant, and + learns nothing of other sessions. +- **Lane A only.** A record names at least one content key (`not_protected`), and a room with + a confidentiality record has no edge that can mark it (`e2ee_room`): its objects' AEAD binds + each to its track, group and object. +- **Refusal reasons**, from `record_invalid` to `disclosure_expired`. jwp-receipts §7.5 + registers the key-log authority `live/watermark` for an edge. +- **Reference and corpus.** `wai-rs` feature `live_watermark` (`src/live_watermark.rs`), with a + deterministic-CBOR reader of its own and no new dependency. `live-conformance/watermark/`, + written from fixed seeds by `wai_live_watermark_vectors`: tokens read and refused, an + edge's records of four sessions with a token in each mode, sequences matched, unmatched and + refused, disclosures accepted and refused, one control for each reason. Checked by + `tests/live_watermark.rs` and by `live-conformance/live_watermark_verify.py`, a second + implementation that shares no code with `wai-rs`, requires every mutated copy of the corpus + to fail, and requires the corpus to catch a verifier that leaves out any one of its checks. + The registry lists the record and the magics `WWS1`, `WWQ1` and `WWD1`. + ### wai-quantum 0.6.8: threads only where they pay, and a cut-rank search that recounts only what moved Additive. No result changes a bit: every kernel path gives the same bits at any thread count, diff --git a/wai/live-conformance/README.md b/wai/live-conformance/README.md index 8649c4422..fa15c30de 100644 --- a/wai/live-conformance/README.md +++ b/wai/live-conformance/README.md @@ -15,16 +15,18 @@ and the independent verifier of each, beside it. | [`steering/`](steering/) | [energy-steering](../extensions/energy-steering.md) (`wai.live.steering`) | `energy_steering_verify.py` | | [`moderation/`](moderation/) | [live-moderation](../extensions/live-moderation.md) (`wai.live.moderation`) | `live_moderation_verify.py` | | [`e2ee/`](e2ee/) | [confidentiality](../extensions/confidentiality.md) (`wai.live.e2ee`) | `e2ee_verify.py` | +| [`watermark/`](watermark/) | [forensic-watermark](../extensions/forensic-watermark.md) (`wai.live.watermark`) | `live_watermark_verify.py` | **Provenance.** Generated here: `wai_live_events_vectors gen` writes `events/`, `wai_live_room_vectors gen` writes `rooms/`, `wai_capture_take_vectors gen` writes `takes/`, `wai_live_compose_vectors gen` writes `compose/`, `wai_live_record_vectors gen` writes `recordings/`, `wai_timed_text_vectors gen` writes `captions/` and `wai_live_metering_vectors gen` writes `metering/`, `wai_energy_steering_vectors gen` writes -`steering/` and `wai_live_moderation_vectors gen` writes `moderation/`, each from records -authored in code, from fixed seeds (`events/README.md`, `rooms/README.md`, `takes/README.md`, -`compose/README.md`, `recordings/README.md`, `captions/README.md`, `metering/README.md`, -`steering/README.md`, `moderation/README.md`). `joule_moq_e2ee_vectors gen` (in +`steering/`, `wai_live_moderation_vectors gen` writes `moderation/` and +`wai_live_watermark_vectors gen` writes `watermark/`, each from records authored in code, from +fixed seeds (`events/README.md`, `rooms/README.md`, `takes/README.md`, `compose/README.md`, +`recordings/README.md`, `captions/README.md`, `metering/README.md`, `steering/README.md`, +`moderation/README.md`, `watermark/README.md`). `joule_moq_e2ee_vectors gen` (in `joule-code/joule-code-rs/crates/joule-moq-mls`) writes `e2ee/vectors.json` from fixed seeds, and `e2ee/` also holds the test vectors RFC 9605 and `draft-ietf-moq-secure-objects-01` publish, as printed (`e2ee/README.md`). One recorded record: `metering/relay-record.wmh` and @@ -45,4 +47,5 @@ python3 live_metering_verify.py metering python3 energy_steering_verify.py steering python3 live_moderation_verify.py moderation python3 e2ee_verify.py e2ee +python3 live_watermark_verify.py watermark ``` diff --git a/wai/live-conformance/live_watermark_verify.py b/wai/live-conformance/live_watermark_verify.py new file mode 100644 index 000000000..ff61fac59 --- /dev/null +++ b/wai/live-conformance/live_watermark_verify.py @@ -0,0 +1,793 @@ +#!/usr/bin/env python3 +"""Independent verifier of the forensic-watermark corpus (extensions/forensic-watermark.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 reads +descriptors, grants and key-log heads with the live-rooms corpus's own second implementation, +live_rooms_verify.py (live-rooms §3, §4.3, §5). Everything else is here: + +- the token (§4): a reader of deterministically encoded CBOR written from RFC 8949 §3 and §4.2.1, + the COSE_Mac0 and COSE_Sign1 structures of RFC 9052, and the claims of the TS's §5.4, in the + order of §4.2's steps; +- the session record (§5), its rules, its signature, who may sign it, its content, its keys, + its region and retention (§6.1), and its token beside it (§5.7); +- the recovered sequence (§7.1, §7.2), candidates and agreement (§7.3) and the outcome (§7.4); +- the disclosure (§8) and its checks (§9.3). + +For every vector it requires the outcome, the reason, the place and everything the corpus +states; of the corpus, that every reason of §11 appears in it, which it reads from the +extension's own table. Then it mutates copies of the corpus, one defect each, and requires +every copy to fail; and it drops each of its own checks in turn and requires the corpus to +catch the verifier that lacks it. + + python3 live_watermark_verify.py + +Exit status 0 only if every case verifies and every mutated copy and every weakened verifier +fails. Python 3.8 or later; standard library only. +""" + +import copy +import hashlib +import json +import os +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 +import live_rooms_verify as LRV # noqa: E402 +import _ed25519 as ED # noqa: E402 + +Refuse = LEV.Refuse +EXTENSION = os.path.join(HERE, "..", "extensions", "forensic-watermark.md") + +SESSION_MAGIC, SEQUENCE_MAGIC, DISCLOSURE_MAGIC = b"WWS1", b"WWQ1", b"WWD1" +SESSION_DOMAIN = b"wai:live-watermark-session\x01" +SEQUENCE_DOMAIN = b"wai:live-watermark-sequence\x01" +DISCLOSURE_DOMAIN = b"wai:live-watermark-disclosure\x01" +BINDING_DOMAIN = b"wai:live-watermark-binding\x01" +EDGE_AUTHORITY = "live/watermark" +WRE1, WRE1_DOMAIN, WRE1_LEN = b"WRE1", b"wai:live-room-e2ee\x01", 105 +MAX_DEPTH, MAX_KIDS = 16, 8 +CWT, COSE = 61, (16, 17, 18, 96, 97, 98) +MAC0, SIGN1 = 17, 18 +HMAC, ES256 = 5, -7 +EXP, NBF, IAT, ISS, SUB, AUD, CTI = 4, 5, 6, 1, 2, 3, 7 +WMVER, WMVND, WMPATLEN, WMSEGDURATION, WMPATTERN, WMID, WMOPID, WMKEYVER = range(300, 308) +CLEAR, DECRYPTED, DERIVED = 1, 2, 3 + +# A check named here is left out: the verifier weakened on purpose, to show the corpus catches it. +SKIP = set() + + +def sha(*parts): + h = hashlib.sha256() + for p in parts: + h.update(p) + return h.digest() + + +def reasons_of_extension(): + """§11's codes, read from the text.""" + with open(EXTENSION, encoding="utf-8") as f: + text = f.read() + at = text.index("\n## 11. Reasons") + end = text.index("\n## ", at + 1) + return [line.split("`")[1] for line in text[at:end].splitlines() if line.startswith("| `")] + + +REASONS = reasons_of_extension() + + +# ---------------------------------------------------------------- bitmaps (§2) + +def nbytes(patlen): + return (patlen + 7) // 8 + + +def bit(m, p): + return p // 8 < len(m) and (m[p // 8] >> (7 - p % 8)) & 1 == 1 + + +def is_bitmap(m, patlen): + return len(m) == nbytes(patlen) and not any(bit(m, p) for p in range(patlen, len(m) * 8)) + + +def within(a, b): + return len(a) == len(b) and all(x & ~y & 0xFF == 0 for x, y in zip(a, b)) + + +def count(m): + return sum(bin(x).count("1") for x in m) + + +# ---------------------------------------------------------------- §4.2 step 1: CBOR + +class Bad(Exception): + """A CBOR item that does not read: "malformed" or "unsupported".""" + + def __init__(self, kind): + super().__init__(kind) + self.kind = kind + + +class Cbor: + """RFC 8949 §3, held to §4.2.1's core deterministic encoding, as §4.2 step 1 reads.""" + + def __init__(self, b): + self.b, self.p = b, 0 + + def take(self, n): + if len(self.b) - self.p < n: + raise Bad("malformed") + s = self.b[self.p:self.p + n] + self.p += n + return s + + def arg(self, ai): + if ai < 24: + return ai + n, least = {24: (1, 24), 25: (2, 256), 26: (4, 65536), 27: (8, 1 << 32)}[ai] + v = int.from_bytes(self.take(n), "big") + if v < least: + raise Bad("malformed") + return v + + def item(self, depth): + if depth > MAX_DEPTH and "depth" not in SKIP: + raise Bad("unsupported") + ib = self.take(1)[0] + major, ai = ib >> 5, ib & 31 + if ai >= 28: + raise Bad("malformed") + if major == 7: + if ai == 20: + return ("false",) + if ai == 21: + return ("true",) + if ai == 22: + return ("null",) + if ai == 24: + raise Bad("malformed" if self.take(1)[0] < 32 else "unsupported") + raise Bad("unsupported") + v = self.arg(ai) + if major == 0: + return ("uint", v) + if major == 1: + return ("nint", v) + if major == 2: + return ("bytes", self.take(v)) + if major == 3: + try: + return ("text", self.take(v).decode("utf-8")) + except UnicodeDecodeError: + raise Bad("malformed") + if major == 4: + return ("array", [self.item(depth + 1) for _ in range(v)]) + if major == 5: + out, prev = [], None + for _ in range(v): + s = self.p + k = self.item(depth + 1) + kb = self.b[s:self.p] + if prev is not None and prev >= kb and "map-order" not in SKIP: + raise Bad("malformed") + prev = kb + out.append((k, self.item(depth + 1))) + return ("map", out) + return ("tag", v, self.item(depth + 1)) + + +def read_one(b): + r = Cbor(b) + item = r.item(1) + if r.p != len(b): + raise Bad("malformed") + return item + + +def as_int(i): + if i[0] == "uint": + return i[1] + if i[0] == "nint": + return -1 - i[1] + return None + + +def get(m, k): + for key, v in m[1]: + if key == ("uint", k): + return v + return None + + +# ---------------------------------------------------------------- §4.2 the token + +def token_refuse(kind): + return Refuse("token_" + kind) + + +def cbor(b): + try: + return read_one(b) + except Bad as e: + raise token_refuse(e.kind) + + +def is_cose_tag(i): + return i[0] == "tag" and i[1] in COSE + + +def read_token(b): + """§4.2: what the token gives (§4.3), or Refuse.""" + item = cbor(b) # step 1 + cwt_tag = False + if item[0] == "tag" and item[1] == CWT: # step 2 + if not is_cose_tag(item[2]): + raise token_refuse("malformed") + cwt_tag, item = True, item[2] + if not (item[0] == "tag" and item[1] in (MAC0, SIGN1)): # step 3 + raise token_refuse("unsupported") + tag, body = item[1], item[2] + if not (body[0] == "array" and len(body[1]) == 4): # step 4 + raise token_refuse("malformed") + prot, unprot, payload, mac = body[1] + if prot[0] != "bytes" or unprot[0] != "map" or payload[0] not in ("bytes", "null") or mac[0] != "bytes": + raise token_refuse("malformed") + if payload[0] == "null": + raise token_refuse("unsupported") + if prot[1] == b"": # step 5 + pmap = [] + else: + pm = cbor(prot[1]) + if pm[0] != "map": + raise token_refuse("malformed") + pmap = pm[1] + umap = unprot[1] + if any(as_int(k) not in (1, 4) for k, _ in pmap + umap): + raise token_refuse("unsupported") + if any(k == u for k, _ in pmap for u, _ in umap): + raise token_refuse("malformed") + alg = next((v for k, v in pmap if as_int(k) == 1), None) + if alg is None: + raise token_refuse("malformed") + alg = as_int(alg) + if alg not in (HMAC, ES256): + raise token_refuse("unsupported") + if not ((alg == HMAC and tag == MAC0 and len(mac[1]) == 32) or (alg == ES256 and tag == SIGN1 and len(mac[1]) == 64)): + raise token_refuse("malformed") + if any(as_int(k) == 4 and v[0] != "bytes" for k, v in pmap + umap): + raise token_refuse("malformed") + claims = cbor(payload[1]) # step 6 + if claims[0] == "tag" and (claims[1] == CWT or claims[1] in COSE): + raise token_refuse("unsupported") + if claims[0] != "map" or any(k[0] not in ("uint", "nint") for k, _ in claims[1]): + raise token_refuse("malformed") + + def u(k): + v = get(claims, k) + return v[1] if v is not None and v[0] == "uint" else None + + def typed(k, kind): + v = get(claims, k) + if v is not None and v[0] != kind: + raise token_refuse("malformed") + + exp, iat = u(EXP), u(IAT) # step 7.1 + if exp is None or iat is None: + raise token_refuse("malformed") + typed(NBF, "uint") + for k in (ISS, SUB, AUD): # 7.2 + typed(k, "text") + typed(CTI, "bytes") + if u(WMVER) is None: # 7.3 + raise token_refuse("malformed") + if u(WMVER) != 1: + raise token_refuse("unsupported") + vendor, patlen = u(WMVND), u(WMPATLEN) # 7.4 + if vendor is None or vendor > 255 or patlen is None or patlen > 65535: + raise token_refuse("malformed") + seg = get(claims, WMSEGDURATION) # 7.5 + if seg is not None and not (seg[0] == "array" and len(seg[1]) == 2 and all(x[0] == "uint" for x in seg[1])): + raise token_refuse("malformed") + typed(WMID, "text") + typed(WMOPID, "uint") + typed(WMKEYVER, "uint") + pat = get(claims, WMPATTERN) # 7.6 + pattern = None + if pat is None: + if get(claims, WMID) is None or get(claims, WMOPID) is None or get(claims, WMKEYVER) is None: + raise token_refuse("malformed") + source = DERIVED + elif pat[0] == "bytes" and len(pat[1]) == nbytes(patlen): + source, pattern = CLEAR, pat[1] + elif pat[0] == "array" and encrypted_shape(pat[1]): + source = DECRYPTED + else: + raise token_refuse("malformed") + nbf = get(claims, NBF) + return {"cwt_tag": cwt_tag, "alg": alg, "vendor": vendor, "patlen": patlen, "source": source, + "pattern": pattern.hex() if pattern is not None else None, "iat": iat, "exp": exp, + "nbf": nbf[1] if nbf is not None else None} + + +def encrypted_shape(a): + """COSE_Encrypt0 (RFC 9052 §5.2) or COSE_Encrypt (§5.1), untagged.""" + if len(a) not in (3, 4): + return False + if a[0][0] != "bytes" or a[1][0] != "map" or a[2][0] not in ("bytes", "null"): + return False + return len(a) == 3 or (a[3][0] == "array" and len(a[3][1]) > 0) + + +def good_at(t, u): + """§4.3: max(iat, nbf) × 10^6 ≤ u < exp × 10^6.""" + return max(t["iat"], t["nbf"] or 0) * 1000000 <= u < t["exp"] * 1000000 + + +# ---------------------------------------------------------------- §5 the session record + +def read_head(b, magic): + 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") + + +HEX_KEY = set(b"0123456789abcdef") +SLOT_CHARS = set(b"abcdefghijklmnopqrstuvwxyz0123456789_") + + +def content_form_ok(owner, media): + hex_key = len(owner) == 64 and all(c in HEX_KEY for c in owner) + slot = owner[:1] == b"_" and 1 <= len(owner) - 1 <= 32 and all(c in SLOT_CHARS for c in owner[1:]) + return (hex_key or slot) and media in (1, 2, 3) + + +def read_session(b): + """§5.5: the record, or Refuse.""" + read_head(b, SESSION_MAGIC) + r = LRV.Reader(b, 5) + s = {"room": r.take(32), "edge": r.take(32)} + s["owner"] = r.take(r.u8()) + s["media"] = r.u8() + s["kids"] = [r.take(16) for _ in range(r.u8())] + s["start"], s["end"] = r.u64(), r.u64() + s["token"], s["vendor"], s["source"], s["patlen"] = r.take(32), r.u8(), r.u8(), r.u16() + s["served"], s["bits"] = r.take(nbytes(s["patlen"])), r.take(nbytes(s["patlen"])) + s["segments"], s["unmarked"] = r.u32(), r.u32() + s["binding"], s["region"], s["expires"] = r.take(32), r.take(2), r.u64() + s["body"] = b[:r.p] + s["sig"] = r.take(64) + if r.p != len(b): + raise Refuse("malformed") + kids = s["kids"] + ok = (content_form_ok(s["owner"], s["media"]) + and len(kids) <= MAX_KIDS and all(a < c for a, c in zip(kids, kids[1:])) and bytes(16) not in kids + and s["start"] < s["end"] and s["source"] in (1, 2, 3) and s["patlen"] >= 1 + and is_bitmap(s["served"], s["patlen"]) and is_bitmap(s["bits"], s["patlen"]) + and within(s["bits"], s["served"]) and s["segments"] >= count(s["served"]) + and all(65 <= c <= 90 for c in s["region"])) + if not ok: + raise Refuse("record_invalid") + s["digest"] = sha(s["body"]) + return s + + +def binding(salt, grant, principal): + return sha(BINDING_DOMAIN, salt, grant, principal) + + +# ---------------------------------------------------------------- §7 the recovered sequence + +def read_sequence(b): + read_head(b, SEQUENCE_MAGIC) + r = LRV.Reader(b, 5) + q = {"room": r.take(32)} + q["owner"] = r.take(r.u8()) + q["media"], q["vendor"], q["patlen"] = r.u8(), r.u8(), r.u16() + q["start"], q["end"] = r.u64(), r.u64() + q["known"], q["bits"] = r.take(nbytes(q["patlen"])), r.take(nbytes(q["patlen"])) + q["copy"], q["claimant"] = r.take(32), r.take(32) + q["body"] = b[:r.p] + q["sig"] = r.take(64) + if r.p != len(b): + raise Refuse("malformed") + ok = (content_form_ok(q["owner"], q["media"]) and q["patlen"] >= 1 and q["start"] < q["end"] + and is_bitmap(q["known"], q["patlen"]) and is_bitmap(q["bits"], q["patlen"]) + and within(q["bits"], q["known"]) and count(q["known"]) > 0) + if not ok: + raise Refuse("sequence_invalid") + q["digest"] = sha(q["body"]) + return q + + +def candidate(s, q): + """§7.3.""" + return (s["owner"] == q["owner"] and s["media"] == q["media"] and s["vendor"] == q["vendor"] + and s["patlen"] == q["patlen"] and s["start"] < q["end"] and q["start"] < s["end"]) + + +def disagreement(s, q): + """§7.3: None when s agrees with q, else (position, how).""" + for p in range(q["patlen"]): + if not bit(q["known"], p): + continue + if not bit(s["served"], p): + return (p, "unserved") + if bit(s["bits"], p) != bit(q["bits"], p) and "compare-bits" not in SKIP: + return (p, "differs") + return None + + +# ---------------------------------------------------------------- §9 verification + +class Stop(Exception): + def __init__(self, reason, at): + super().__init__(reason) + self.reason, self.at = reason, at + + +def check_room(room): + """§9.1 steps 1 and 2.""" + try: + d = LRV.read_descriptor(room["descriptor"]) + except Refuse as e: + raise Stop(e.reason, {"place": "descriptor"}) + try: + LRV.check_key_log(d, room["key_log"]) + except Refuse as e: + raise Stop(e.reason, {"place": "key_log"}) + entries = None + if d["origin"] != b"": + entries = json.loads(room["key_log"])["keys"] + held, err = LRV.check_grants(d, room["grants"]) + if err is not None: + raise Stop(err[1], {"place": "grant", "index": err[0]}) + c = room["confidentiality"] + if c is not None: + at = {"place": "confidentiality"} + if len(c) < 4: + raise Stop("truncated", at) + if c[:4] != WRE1: + raise Stop("malformed", at) + if len(c) < 5: + raise Stop("truncated", at) + if c[4] != 1: + raise Stop("unknown_version", at) + if len(c) < WRE1_LEN: + raise Stop("truncated", at) + if len(c) > WRE1_LEN: + raise Stop("malformed", at) + if c[5:37] != d["room"]: + raise Stop("wrong_room", at) + bad = ED.verify(d["authority"], WRE1_DOMAIN + c[:41], c[41:]) + if bad: + raise Stop(bad, at) + if "e2ee" not in SKIP: + raise Stop("e2ee_room", at) + return {"d": d, "entries": entries, "grants": held} + + +def check_session(room, record, token, earlier): + """§9.1 step 3 for one record: its facts, or Refuse.""" + d = room["d"] + s = read_session(record) # 1 + if s["room"] != d["room"]: # 2 + raise Refuse("wrong_room") + bad = ED.verify(s["edge"], SESSION_DOMAIN + s["body"], s["sig"]) # 3 + if bad: + raise Refuse(bad) + if "edge" not in SKIP: # 4 + if room["entries"] is None: + if s["edge"] not in (d["authority"], d["clock"]): + raise Refuse("edge_not_authorised") + else: + e = next((e for e in room["entries"] if e["key"] == s["edge"].hex() and EDGE_AUTHORITY in e["may_sign"]), None) + if e is None: + raise Refuse("edge_not_authorised") + if e["status"] == "revoked": + raise Refuse("edge_revoked") + if s["owner"][:1] == b"_" and "content" not in SKIP: # 5 + if not any(x["name"] == s["owner"][1:] and x["media"] == s["media"] for x in d["slots"]): + raise Refuse("content_unknown") + if not s["kids"] and "kids" not in SKIP: # 6 + raise Refuse("not_protected") + if d["regions"] and s["region"] not in d["regions"] and "region" not in SKIP: # 7 + raise Refuse("region_not_pinned") + end_us = d["t0"] + s["end"] + if s["expires"] * 1000000 < end_us and "expires" not in SKIP: + raise Refuse("record_invalid") + if d["retention"] and s["expires"] * 1000000 > end_us + d["retention"] * 86400 * 1000000 and "retention" not in SKIP: + raise Refuse("retention_exceeded") + if s["token"] in earlier and "reuse" not in SKIP: # 8 + raise Refuse("token_reused") + t = None + if token is not None: # 9, §5.7 + if sha(token) != s["token"]: + raise Refuse("token_mismatch") + t = read_token(token) + if (t["vendor"], t["patlen"], t["source"]) != (s["vendor"], s["patlen"], s["source"]): + raise Refuse("token_mismatch") + if t["pattern"] is not None and "pattern" not in SKIP: + p = bytes.fromhex(t["pattern"]) + if bytes(a & b for a, b in zip(p, s["served"])) != s["bits"]: + raise Refuse("token_mismatch") + if not (good_at(t, d["t0"] + s["start"]) and good_at(t, d["t0"] + s["end"] - 1)) and "window" not in SKIP: + raise Refuse("outside_token_window") + s["token_facts"] = t + return s + + +def session_facts(s): + return {"digest": s["digest"].hex(), "edge": s["edge"].hex(), "owner": s["owner"].decode(), "media": s["media"], + "kids": [k.hex() for k in s["kids"]], "start": s["start"], "end": s["end"], "vendor": s["vendor"], + "source": s["source"], "patlen": s["patlen"], "served": count(s["served"]), "segments": s["segments"], + "unmarked": s["unmarked"], "region": s["region"].decode(), "expires": s["expires"], + "token": s["token_facts"]} + + +def verify_sessions(room_in, records): + room = check_room(room_in) + out = [] + for i, (record, token) in enumerate(records): + try: + out.append(check_session(room, record, token, [s["token"] for s in out])) + except Refuse as e: + raise Stop(e.reason, {"place": "session", "index": i}) + return room, out + + +def check_sequence(room, b): + at = {"place": "sequence"} + try: + q = read_sequence(b) + except Refuse as e: + raise Stop(e.reason, at) + if q["room"] != room["d"]["room"]: + raise Stop("wrong_room", at) + bad = ED.verify(q["claimant"], SEQUENCE_DOMAIN + q["body"], q["sig"]) + if bad: + raise Stop(bad, at) + return q + + +def verify_match(room_in, records, seq): + """§9.2: (outcome, reason, facts).""" + room, sessions = verify_sessions(room_in, records) + q = check_sequence(room, seq) + cands = [] + for i, s in enumerate(sessions): + if candidate(s, q): + dis = disagreement(s, q) + cands.append({"index": i, "digest": s["digest"].hex(), "agrees": dis is None, + "first": None if dis is None else {"position": dis[0], "how": dis[1]}}) + agreeing = [c["digest"] for c in cands if c["agrees"]] + if len(agreeing) == 1: + outcome, reason, matched = "matched", None, agreeing[0] + else: + outcome, matched = "unmatched", None + reason = "no_match" if not agreeing else "ambiguous_match" + return outcome, reason, {"sequence": q["digest"].hex(), "stated": count(q["known"]), "candidates": cands, "session": matched} + + +def verify_disclosure(room_in, record, token, seq, disclosure): + """§9.3: the facts, or Stop.""" + room, sessions = verify_sessions(room_in, [(record, token)]) + s = sessions[0] + q = check_sequence(room, seq) + d = room["d"] + at = {"place": "disclosure"} + + def stop(r): + return Stop(r, at) + + try: + read_head(disclosure, DISCLOSURE_MAGIC) + except Refuse as e: + raise stop(e.reason) + if len(disclosure) < 269: + raise stop("truncated") + if len(disclosure) > 269: + raise stop("malformed") + f = {name: disclosure[5 + 32 * i:37 + 32 * i] for i, name in enumerate(("room", "session", "sequence", "grant", "principal", "salt"))} + t = struct.unpack(" d["t0"] + s["start"] or d["t0"] + s["end"] > g["exp"] * 1000000) and "grant-window" not in SKIP: + raise stop("outside_grant_window") + if t > s["expires"] and "disclosure-expires" not in SKIP: + raise stop("disclosure_expired") + return {"digest": sha(disclosure[:205]).hex(), "session": s["digest"].hex(), "sequence": q["digest"].hex(), + "grant": f["grant"].hex(), "principal": f["principal"].hex(), "stated": count(q["known"]), + "token_given": token is not None} + + +# ---------------------------------------------------------------- the corpus + +def unhex(x): + return None if x is None else bytes.fromhex(x) + + +def room_input(doc, v): + r = dict(doc["rooms"][v["room"]]) + r.update(v.get("room_override", {})) + return {"descriptor": unhex(r["descriptor"]), "grants": [unhex(g) for g in r["grants"]], + "key_log": r["key_log"], "confidentiality": unhex(r["confidentiality"])} + + +def records_of(v): + return [(unhex(x["record"]), unhex(x["token"])) for x in v["records"]] + + +def check(doc, quiet=False): + """Every vector's outcome against the corpus: a list of failures.""" + fails = [] + + def note(kind, v, got, want): + if got != want: + fails.append("%s %s: got %s, the corpus states %s" % (kind, v["name"], got, want)) + if not quiet: + print("FAIL %s %s" % (kind, v["name"])) + elif not quiet: + print("PASS %s %s: %s %s" % (kind, v["name"], want[0], want[1] or "")) + + for v in doc["tokens"]: + try: + got = ("read", None, read_token(unhex(v["token"]))) + except Refuse as e: + got = ("refuse", e.reason, None) + note("token", v, got, (v["outcome"], v["reason"], v["facts"])) + for v in doc["sessions"]: + try: + _, ss = verify_sessions(room_input(doc, v), records_of(v)) + got = ("accept", None, None, [session_facts(s) for s in ss]) + except Stop as e: + got = ("refuse", e.reason, e.at, None) + note("sessions", v, got, (v["outcome"], v["reason"], v["at"], v["sessions"])) + for v in doc["matches"]: + try: + got = verify_match(room_input(doc, v), records_of(v), unhex(v["sequence"])) + (None,) + except Stop as e: + got = ("refuse", e.reason, None, e.at) + note("match", v, got, (v["outcome"], v["reason"], v["match"], v["at"])) + for v in doc["disclosures"]: + rec = v["record"] + try: + got = ("accept", None, None, verify_disclosure(room_input(doc, v), unhex(rec["record"]), unhex(rec["token"]), + unhex(v["sequence"]), unhex(v["disclosure"]))) + except Stop as e: + got = ("refuse", e.reason, e.at, None) + note("disclosure", v, got, (v["outcome"], v["reason"], v["at"], v["facts"])) + return fails + + +# ---------------------------------------------------------------- mutations + +def by_name(vs, name): + return next(v for v in vs if v["name"] == name) + + +def flip(hexstr, at): + b = bytearray(bytes.fromhex(hexstr)) + b[at] ^= 0x01 + return b.hex() + + +def mutations(doc): + """(label, mutated copy) for each defect the corpus must catch.""" + out = [] + + def mutate(label, f): + d = copy.deepcopy(doc) + f(d) + out.append((label, d)) + + lane = lambda d: by_name(d["sessions"], "lane_a_sessions") # noqa: E731 + mutate("a byte of a record's signature flipped", lambda d: lane(d)["records"][0].update(record=flip(lane(d)["records"][0]["record"], -1))) + mutate("a byte of a record's served bitmap flipped", + lambda d: lane(d)["records"][1].update(record=flip(lane(d)["records"][1]["record"], 165))) + mutate("two records' tokens swapped", lambda d: (lambda r: (r[0].update(token=r[2]["token"]), r[2].update(token=lane(doc)["records"][0]["token"])))(lane(d)["records"])) + mutate("a record's stated served count", lambda d: lane(d)["sessions"][0].update(served=63)) + mutate("a record's stated token facts", lambda d: lane(d)["sessions"][2]["token"].update(source=1)) + mutate("a token's stated reason", lambda d: by_name(d["tokens"], "untagged").update(reason="token_malformed")) + mutate("a token's pattern byte flipped", lambda d: by_name(d["tokens"], "mac0_clear").update(token=flip(by_name(d["tokens"], "mac0_clear")["token"], -40))) + mutate("the end-to-end encrypted room's confidentiality record removed", lambda d: d["rooms"]["e2ee"].update(confidentiality=None)) + mutate("the pin's stated outcome", lambda d: by_name(d["sessions"], "region_not_pinned").update(outcome="accept", reason=None, at=None)) + mutate("a byte of the matched sequence flipped", lambda d: by_name(d["matches"], "recovered_from_session_1").update(sequence=flip(by_name(d["matches"], "recovered_from_session_1")["sequence"], 120))) + mutate("the matched session's stated digest", lambda d: by_name(d["matches"], "recovered_from_session_1")["match"].update(session="00" * 32)) + mutate("an ambiguous match stated as matched", lambda d: by_name(d["matches"], "agrees_with_two").update(outcome="matched", reason=None)) + mutate("a candidate's first disagreement", lambda d: by_name(d["matches"], "agrees_with_two")["match"]["candidates"][1]["first"].update(how="differs")) + mutate("the disclosure's principal stated as another's", lambda d: by_name(d["disclosures"], "disclosure_of_session_1")["facts"].update(principal="00" * 32)) + mutate("a byte of the disclosure's salt flipped", lambda d: by_name(d["disclosures"], "disclosure_of_session_1").update(disclosure=flip(by_name(d["disclosures"], "disclosure_of_session_1")["disclosure"], 170))) + mutate("the disclosure checked against another session's record", lambda d: by_name(d["disclosures"], "disclosure_without_token").update(record=by_name(d["disclosures"], "sequence_disagrees")["record"])) + mutate("the expired disclosure stated as accepted", lambda d: by_name(d["disclosures"], "disclosure_expired").update(outcome="accept", reason=None, at=None)) + mutate("a refusal's stated place", lambda d: by_name(d["sessions"], "token_reused")["at"].update(index=0)) + return out + + +# Each of the verifier's own checks, by its SKIP name: the corpus must catch a verifier without it. +CHECKS = { + "depth": "§4.2 step 1: the nesting bound", + "map-order": "§4.2 step 1: map keys in deterministic order", + "edge": "§5.6: who may sign", + "content": "§9.1 step 3.5: the content is the room's", + "kids": "§3.1: a record names a content key", + "region": "§6.1: the region pin", + "expires": "§6.1: expires at or after the session's end", + "retention": "§6.1: the retention bound", + "reuse": "§9.1 step 3.8: one token, one session", + "pattern": "§5.7 step 3: the bits are the clear pattern's", + "window": "§5.7 step 4: the token's window", + "e2ee": "§3.2: an end-to-end encrypted room", + "compare-bits": "§7.3: a served position's variant is compared", + "names": "§9.3 step 4: the disclosure names the record and the sequence", + "opening": "§9.3 step 6: the opening opens the binding", + "grant-window": "§9.3 step 8: the grant's window", + "disclosure-expires": "§9.3 step 9: before expires", +} + + +def main(corpus_dir): + fails = [] + st = ED._self_test() + print(("PASS" if not st else "FAIL") + " Ed25519 strict rule self-test " + ",".join(st)) + fails += ["ed25519 self-test"] if st else [] + print(("PASS" if len(REASONS) == 20 else "FAIL") + " §11 read from the extension: %d reasons" % len(REASONS)) + fails += [] if len(REASONS) == 20 else ["§11 does not read"] + with open(os.path.join(corpus_dir, "vectors.json"), encoding="utf-8") as f: + doc = json.load(f) + fails += check(doc) + extra = sorted(set(os.listdir(corpus_dir)) - {"vectors.json", "README.md"}) + print(("PASS" if not extra else "FAIL") + " no file but vectors.json and README.md") + fails += ["an extra file: %s" % n for n in extra] + seen = {v["reason"] for k in ("tokens", "sessions", "matches", "disclosures") for v in doc[k]} + missing = [r for r in REASONS if r not in seen] + print(("PASS" if not missing else "FAIL") + " every reason of §11 appears in the corpus") + fails += ["no case gives %s" % r for r in missing] + for label, d in mutations(doc): + caught = check(d, quiet=True) + print(("PASS" if caught else "FAIL") + " mutated copy fails: %s%s" % (label, " (%s)" % caught[0][:90] if caught else "")) + if not caught: + fails.append("a mutated copy verifies: %s" % label) + for name, what in CHECKS.items(): + SKIP.clear() + SKIP.add(name) + caught = check(doc, quiet=True) + SKIP.clear() + print(("PASS" if caught else "FAIL") + " a verifier without %s fails the corpus%s" % (what, " (%s)" % caught[0][:70] if caught else "")) + if not caught: + fails.append("the corpus does not catch a verifier without %s" % what) + for f in fails: + print("FAIL " + f) + print(("PASS" if not fails else "FAIL") + " forensic-watermark corpus: %d tokens, %d session lists, %d matches, %d disclosures" + % (len(doc["tokens"]), len(doc["sessions"]), len(doc["matches"]), len(doc["disclosures"]))) + return 1 if fails else 0 + + +if __name__ == "__main__": + if len(sys.argv) != 2: + print("usage: live_watermark_verify.py ", file=sys.stderr) + sys.exit(2) + sys.exit(main(sys.argv[1])) diff --git a/wai/live-conformance/watermark/README.md b/wai/live-conformance/watermark/README.md new file mode 100644 index 000000000..af255e31c --- /dev/null +++ b/wai/live-conformance/watermark/README.md @@ -0,0 +1,55 @@ +# live-conformance/watermark — forensic watermark sessions + +The corpus of [`extensions/forensic-watermark.md`](../../extensions/forensic-watermark.md) §12, +in one file, `vectors.json`: three rooms, ETSI TS 104 002 watermark tokens, lists of `WWS1` +session records with or without their tokens, `WWQ1` recovered sequences matched against +records, and `WWD1` disclosures, each with the outcome the extension gives it. + +- **`rooms`**: each room's `WRD1` descriptor, its `WRG1` grants, its key-log head (or `null`) + and its `WRE1` confidentiality record (or `null`), as hexadecimal; `broadcast` (an origin + whose key log lists two edges with `live/watermark`, a relay with `live/relay` alone and a + revoked edge; region pin `DE`, `FR`; retention 30 days), `keyless` (no origin; its clock is + its edge) and `e2ee` (end-to-end encrypted). A vector names its room and may carry a + `room_override`, whose members replace the room's. +- **`tokens`**: each token's bytes, read by §4.2: `read` with what it gives (§4.3: `cwt_tag`, + `alg`, `vendor`, `patlen`, `source`, the clear `pattern` or `null`, `iat`, `exp`, `nbf`), or + `refuse` with `token_malformed` or `token_unsupported`. +- **`sessions`**: a list of records, each `record` with its `token` or `null`, verified by + §9.1: `accept` with each record's facts (its `digest`, `edge`, `owner`, `media`, `kids`, + span, `vendor`, `source`, `patlen`, the number of positions `served`, `segments`, + `unmarked`, `region`, `expires`, and its `token`'s facts or `null`), or `refuse` with the + `reason` and the place `at`: `descriptor`, `key_log`, `grant` and its `index`, + `confidentiality`, or `session` and its `index`. +- **`matches`**: the records and a `sequence`, by §9.2: `matched` or `unmatched` (with + `no_match` or `ambiguous_match`), and the `match`: the sequence's digest, the positions it + `stated`, each `candidate` (its `index`, `digest`, whether it `agrees`, and its `first` + disagreement, a `position` and `unserved` or `differs`), and the matched `session` or + `null`; or `refuse` at `sequence` (or at a record). +- **`disclosures`**: one `record` with its `token`, a `sequence` and a `disclosure`, by §9.3: + `accept` with its `facts` (its `digest`, the `session`'s, the `sequence`'s, the `grant`, the + `principal`, the positions `stated`, and `token_given`), or `refuse` with the `reason` and + `at`. + +A verifier that reaches another outcome, reason, place or fact does not conform. + +**Provenance.** Generated here: `wai_live_watermark_vectors gen` writes `vectors.json` from +records authored in code, from fixed seeds. Every key is SHA-256 of a fixed label and an +index (`principals` lists them); every pattern, salt, KID and copy digest is SHA-256 of a label +and an index; every time is a constant (the rooms' `t0` is 2026-10-18T00:00:00Z); every origin +is an `.example` name (RFC 2606); and Ed25519 signing is deterministic (RFC 8032), so the file +is the same bytes on every machine. The tokens are COSE_Mac0 under HMAC 256/256 keyed by +`wmt_key`, which no verifier checks (§4.4); an encrypted pattern is a COSE_Encrypt0 or +COSE_Encrypt of the right shape whose bytes no verifier opens. Nothing here was recorded from +a deployment. + +**Checks.** `wai_live_watermark_vectors verify` and `tests/live_watermark.rs` (wai-rs, feature +`live_watermark`) regenerate the file byte for byte and give every vector its outcome; the +generator asserts that every reason of §11 appears. `../live_watermark_verify.py` is the +second implementation, written from the extension's text: it shares no code with wai-rs, reads +rooms with `../live_rooms_verify.py`, reads tokens with a CBOR reader of its own, mutates +copies of the corpus and requires each to fail, and drops each of its own checks in turn and +requires the corpus to catch the weakened verifier. + +``` +python3 -I live_watermark_verify.py watermark +``` -- 2.54.0 (Apple Git-157) From dcb610bc6d66d3cee79ddcb7deb6d93cd6a6aa2b Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Tue, 20 Oct 2026 08:44:01 -0400 Subject: [PATCH 3/6] wai.live.watermark: the registry export, and a record-rules test that clears a set bit Co-Authored-By: Claude Opus 5.5 --- wai/registry/capabilities.json | 35 ++++++++++++++++++++++++++++++++ wai/vocab/v2.ttl | 7 +++++++ wai/wai-rs/src/live_watermark.rs | 2 +- 3 files changed, 43 insertions(+), 1 deletion(-) diff --git a/wai/registry/capabilities.json b/wai/registry/capabilities.json index c60416699..95900f08c 100644 --- a/wai/registry/capabilities.json +++ b/wai/registry/capabilities.json @@ -714,6 +714,20 @@ "edition": null, "registered": "2026-10-18" }, + { + "capability": "wai.live.watermark", + "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-20" + }, { "capability": "wai.mesh.draco", "kind": "payload", @@ -2081,6 +2095,27 @@ "section": "§6.2", "status": "defined" }, + { + "name": "WWD1", + "kind": "magic", + "document": "extensions/forensic-watermark.md", + "section": "§8.1", + "status": "defined" + }, + { + "name": "WWQ1", + "kind": "magic", + "document": "extensions/forensic-watermark.md", + "section": "§7.1", + "status": "defined" + }, + { + "name": "WWS1", + "kind": "magic", + "document": "extensions/forensic-watermark.md", + "section": "§5.1", + "status": "defined" + }, { "name": "X-WAI-EVENT", "kind": "playlist-attribute", diff --git a/wai/vocab/v2.ttl b/wai/vocab/v2.ttl index 50b8119cd..8ff0794a9 100644 --- a/wai/vocab/v2.ttl +++ b/wai/vocab/v2.ttl @@ -590,6 +590,13 @@ wai:wai.live.steering a wai:Capability ; wai:specSection "§5 Interactive, temporal & computed content" ; wai:registeredOn "2026-10-18"^^xsd:date . +wai:wai.live.watermark a wai:Capability ; + rdfs:label "wai.live.watermark" ; + wai:kind wai:kind-record ; + wai:status wai:status-registered ; + wai:specSection "§5 Interactive, temporal & computed content" ; + wai:registeredOn "2026-10-20"^^xsd:date . + wai:wai.mesh.draco a wai:Capability ; rdfs:label "wai.mesh.draco" ; wai:kind wai:kind-payload ; diff --git a/wai/wai-rs/src/live_watermark.rs b/wai/wai-rs/src/live_watermark.rs index 03a3d90fd..3d807f4a4 100644 --- a/wai/wai-rs/src/live_watermark.rs +++ b/wai/wai-rs/src/live_watermark.rs @@ -1532,7 +1532,7 @@ mod tests { Session::read(&t.to_bytes()) }; assert_eq!(refused(&|t| t.bits = vec![0x0f, 0x60]), Err(Reason::RecordInvalid), "bits past patlen"); - assert_eq!(refused(&|t| t.served = vec![0x7f, 0xc0]), Err(Reason::RecordInvalid), "a bit not served"); + assert_eq!(refused(&|t| t.served = vec![0xfe, 0xc0]), Err(Reason::RecordInvalid), "a bit not served"); assert_eq!(refused(&|t| t.segments = 9), Err(Reason::RecordInvalid)); assert_eq!(refused(&|t| t.owner = b"_Main".to_vec()), Err(Reason::RecordInvalid)); assert_eq!(refused(&|t| t.kids = vec![[0; 16]]), Err(Reason::RecordInvalid)); -- 2.54.0 (Apple Git-157) From 1ce07863d6b8137f69c153ab4e9005b4e0b3db5a Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Tue, 20 Oct 2026 08:44:50 -0400 Subject: [PATCH 4/6] forensic-watermark: a session is one grant's under one token, and an edge binds a token to its grant Co-Authored-By: Claude Opus 5.5 --- wai/extensions/forensic-watermark.md | 26 +++++++++++++++----------- 1 file changed, 15 insertions(+), 11 deletions(-) diff --git a/wai/extensions/forensic-watermark.md b/wai/extensions/forensic-watermark.md index 34cb8d1d8..dbc0efec5 100644 --- a/wai/extensions/forensic-watermark.md +++ b/wai/extensions/forensic-watermark.md @@ -73,8 +73,9 @@ parties that verify the receipt (live-metering §11); a session record names non to: one CBOR data item. - **Pattern bit** *p*: bit *p* of the WM pattern, *p* from 0, addressed big-endian as the TS's §5.4 requires: bit 7 − (*p* mod 8), the most significant first, of byte ⌊*p* / 8⌋. -- **Session**: one subscriber session of the edge (a MOQT session, or the HTTP requests of one - playback) under one grant and one token (§3.3). +- **Session**: what an edge serves one viewer under one grant and one token: the subscriber + sessions (MOQT sessions, or the HTTP requests of a playback) in which that token is presented + under that grant (§3.3). - **Claimant**: whoever states a recovered sequence and signs it. - *u64*, *u32*, *u16*, *u8*: little-endian unsigned integers of that many bits. A *short* field is a u8 length and that many bytes. A digest is SHA-256 (FIPS 180-4), 32 bytes. @@ -143,9 +144,11 @@ The edge admits the session under the viewer's grant, with proof of possession o (live-rooms §10.5), so it knows the grant and the principal. A session admitted without a grant (live-rooms §10.4) names no principal, and has no session record. -A session record covers one session under one token. An edge that is given a new token during -a session ends the record of the old one and starts another; the records of one session have -spans that do not overlap. +A session record covers one session. An edge binds a token to the grant under which it is +first presented, and refuses it under any other grant, so one token is one session's (§9.1 +step 3.8); a viewer that reconnects under its grant with its token continues its session. An +edge that is given a new token ends the record of the old one and starts another, and the +records of one viewer's sessions have spans that do not overlap. ## 4. The watermark token @@ -684,7 +687,7 @@ live-rooms §11 lists, for a disclosure's grant (§9.3 steps 7 and 8). 256/256 in each mode (a pattern in the clear, encrypted as COSE_Encrypt0 and as COSE_Encrypt, derived), one in the CWT tag, and one refused for each rule of §4.2 a corpus token breaks. -- **Sessions**: an edge's records of four sessions of the broadcast room, each mode of token +- **Sessions**: two edges' records of four sessions of the broadcast room, each mode of token among them, with their tokens and without; the room with no origin; and one refused list for each reason of §11 a record or its token raises, each a control of one defect. - **Matches**: a sequence recovered from one session's copy with erasures, matched; one that @@ -715,11 +718,12 @@ of its own written from RFC 8949, and requires every mutated copy of the corpus delivered as stated; an edge that lies about its own session is caught only where another record contradicts it. A recovered sequence that agrees with a record is evidence the copy came from that session to the extent the stated positions tell sessions apart (§7.5). -- **Framing.** A token is a capability to obtain segments marked with its pattern. A token - presented by someone other than its viewer marks the copy with the viewer's pattern; the - edge binds each record to the grant it admitted with proof of possession (live-rooms §10.5), - so a record names the session that actually received the segments, and a token used twice is - two records with one token, `token_reused`. Records do not carry tokens (§4.1). +- **Framing.** A token is a capability to obtain segments marked with its pattern, and a copy + of them would match its viewer's session. The edge binds a token to the grant it was first + presented under, admitted with proof of possession of the principal's key (live-rooms + §10.5), and refuses it under any other (§3.3), so a copied token marks no other grant's + session; two records that name one token are refused, `token_reused`. Records do not carry + tokens (§4.1). - **Who learns a viewer.** Only the room authority, through an opening, and only for the sessions it discloses. A party matching sequences holds records that name no grant; a third party checking a disclosure learns one session's principal. -- 2.54.0 (Apple Git-157) From 5dcb8a9cea12142c4873ffb54b477107011b6bf8 Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Tue, 20 Oct 2026 08:45:36 -0400 Subject: [PATCH 5/6] live_watermark: two clippy notes Co-Authored-By: Claude Opus 5.5 --- wai/wai-rs/src/live_watermark.rs | 6 ++---- wai/wai-rs/src/live_watermark/corpus.rs | 2 +- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/wai/wai-rs/src/live_watermark.rs b/wai/wai-rs/src/live_watermark.rs index 3d807f4a4..bb00fb147 100644 --- a/wai/wai-rs/src/live_watermark.rs +++ b/wai/wai-rs/src/live_watermark.rs @@ -643,10 +643,8 @@ pub fn read_token(b: &[u8]) -> Result { let vendor = uint(get(claim::WMVND)).filter(|v| *v <= 255).ok_or(Mal)? as u8; let patlen = uint(get(claim::WMPATLEN)).filter(|v| *v <= 65_535).ok_or(Mal)? as u16; // 7.5 - if let Some(d) = get(claim::WMSEGDURATION) { - if !matches!(d, Item::Array(a) if a.len() == 2 && a.iter().all(|x| matches!(x, Item::Uint(_)))) { - return Err(Mal); - } + if get(claim::WMSEGDURATION).is_some_and(|d| !matches!(d, Item::Array(a) if a.len() == 2 && a.iter().all(|x| matches!(x, Item::Uint(_))))) { + return Err(Mal); } if get(claim::WMID).is_some_and(|v| !matches!(v, Item::Text(_))) { return Err(Mal); diff --git a/wai/wai-rs/src/live_watermark/corpus.rs b/wai/wai-rs/src/live_watermark/corpus.rs index 52ca69611..d629ce747 100644 --- a/wai/wai-rs/src/live_watermark/corpus.rs +++ b/wai/wai-rs/src/live_watermark/corpus.rs @@ -106,7 +106,7 @@ fn hmac_sha256(k: &[u8], m: &[u8]) -> [u8; 32] { } pub(crate) fn unhex(s: &str) -> Option> { - if s.len() % 2 != 0 { + if !s.len().is_multiple_of(2) { return None; } (0..s.len()).step_by(2).map(|i| u8::from_str_radix(&s[i..i + 2], 16).ok()).collect() -- 2.54.0 (Apple Git-157) From 926e2e7fb7070103b8b8746f5a3e500a229a1df5 Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Tue, 20 Oct 2026 08:47:55 -0400 Subject: [PATCH 6/6] int_kernels_vectors: the live-conformance exclusion names the watermark corpus Co-Authored-By: Claude Opus 5.5 --- wai/wai-rs/tests/int_kernels_vectors.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/wai/wai-rs/tests/int_kernels_vectors.rs b/wai/wai-rs/tests/int_kernels_vectors.rs index edae21727..0e633ad57 100644 --- a/wai/wai-rs/tests/int_kernels_vectors.rs +++ b/wai/wai-rs/tests/int_kernels_vectors.rs @@ -223,7 +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, takes, compositions, recordings, captions, metering, steering and moderation: signed records, commitments, packages, clips, cues and their renderings, per-hop records, ledgers and session receipts with their units and joules recomputed, steering policies and decisions with each pathway's evidence and the steering manifests recomputed byte for byte, review policies, decisions, notices and the items' timelines with each statement-of-reasons export written byte for byte, and their verdicts, and layouts and mixes stated in full, rectangle for rectangle and sample for sample; nothing decoded against a parameter set"), + ("live-conformance", "live events, rooms, takes, compositions, recordings, captions, metering, steering, moderation and watermark sessions: signed records, commitments, packages, clips, cues and their renderings, per-hop records, ledgers and session receipts with their units and joules recomputed, steering policies and decisions with each pathway's evidence and the steering manifests recomputed byte for byte, review policies, decisions, notices and the items' timelines with each statement-of-reasons export written byte for byte, watermark tokens, session records, recovered sequences and disclosures, and their verdicts, and layouts and mixes stated in full, rectangle for rectangle and sample for sample; nothing decoded against a parameter set"), ("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"), -- 2.54.0 (Apple Git-157)