wai.live.watermark: forensic watermark session records, matched and disclosed one at a time (L14) #358

Merged
dcharlot merged 7 commits from wai/live-watermark into main 2026-10-20 13:05:39 -04:00
20 changed files with 6448 additions and 13 deletions

View file

@ -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,

View file

@ -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`.
### Live L7b: the DVR window, the seek power, and WMH1 version 2
Additive: no existing record changes its bytes, and every existing corpus vector keeps its

View file

@ -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 |

View file

@ -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
@ -1024,6 +1025,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
@ -1551,8 +1571,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

View file

@ -0,0 +1,742 @@
# 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**: 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.
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. 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
### 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**: 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
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, 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.
- **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.

View file

@ -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/<class>` 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/<class>` 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/<class>` 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,

View file

@ -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
```

View file

@ -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 <live-conformance/watermark>
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("<Q", disclosure[197:205])[0]
if f["room"] != d["room"]:
raise stop("wrong_room")
bad = ED.verify(d["authority"], DISCLOSURE_DOMAIN + disclosure[:205], disclosure[205:])
if bad:
raise stop(bad)
if (f["session"] != s["digest"] or f["sequence"] != q["digest"]) and "names" not in SKIP:
raise stop("disclosure_mismatch")
if not candidate(s, q) or disagreement(s, q) is not None:
raise stop("sequence_disagrees")
if binding(f["salt"], f["grant"], f["principal"]) != s["binding"] and "opening" not in SKIP:
raise stop("opening_mismatch")
g = next((g for g in room["grants"] if g["digest"] == f["grant"]), None)
if g is None:
raise stop("grant_unknown")
if g["principal"] != f["principal"]:
raise stop("grant_wrong_principal")
if (g["nbf"] * 1000000 > 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 <live-conformance/watermark>", file=sys.stderr)
sys.exit(2)
sys.exit(main(sys.argv[1]))

View file

@ -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
```

File diff suppressed because it is too large Load diff

View file

@ -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",

View file

@ -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 ;

View file

@ -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"

View file

@ -0,0 +1,74 @@
//! Conformance corpus for forensic watermark sessions (`live-conformance/watermark/`, extensions/forensic-watermark.md §12).
//!
//! wai_live_watermark_vectors gen <corpus-dir> write vectors.json, from fixed seeds
//! wai_live_watermark_vectors verify <corpus-dir> 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<String> = 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 <corpus-dir>");
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<String, String> = 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
}

View file

@ -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

View file

@ -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

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -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"),

View file

@ -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<String, String> {
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<String, String> = 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<String> =
text[at..end].lines().filter(|l| l.starts_with("| `")).filter_map(|l| l.split('`').nth(1).map(String::from)).collect();
let want: Vec<String> = Reason::ALL.iter().map(|r| r.code().to_string()).collect();
assert_eq!(listed, want, "§11's table and Reason::ALL differ");
}