wai.live.watermark: forensic watermark session records, matched and disclosed one at a time (L14) #358
20 changed files with 6448 additions and 13 deletions
27
.github/workflows/wai-conformance.yml
vendored
27
.github/workflows/wai-conformance.yml
vendored
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
|
|
|||
27
wai/SPEC.md
27
wai/SPEC.md
|
|
@ -593,7 +593,8 @@ kind:
|
|||
delivery records `wai.video.manifest`, `wai.video.ssai`, `wai.video.keys`,
|
||||
`wai.video.package` and `wai.video.channel`; and the live records `wai.live.events`,
|
||||
`wai.live.room`, `wai.capture.take`, `wai.live.compose`, `wai.live.recording`,
|
||||
`wai.live.metering`, `wai.live.steering`, `wai.live.moderation` and `wai.live.e2ee`, and
|
||||
`wai.live.metering`, `wai.live.steering`, `wai.live.moderation`, `wai.live.e2ee` and
|
||||
`wai.live.watermark`, and
|
||||
timed text, `wai.text.timed`), which names no payload format.
|
||||
|
||||
Only payload, companion and refinement capabilities have a media class, a
|
||||
|
|
@ -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
|
||||
|
|
|
|||
742
wai/extensions/forensic-watermark.md
Normal file
742
wai/extensions/forensic-watermark.md
Normal 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.
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
```
|
||||
|
|
|
|||
793
wai/live-conformance/live_watermark_verify.py
Normal file
793
wai/live-conformance/live_watermark_verify.py
Normal 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]))
|
||||
55
wai/live-conformance/watermark/README.md
Normal file
55
wai/live-conformance/watermark/README.md
Normal 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
|
||||
```
|
||||
1884
wai/live-conformance/watermark/vectors.json
Normal file
1884
wai/live-conformance/watermark/vectors.json
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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 ;
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
74
wai/wai-rs/src/bin/wai_live_watermark_vectors.rs
Normal file
74
wai/wai-rs/src/bin/wai_live_watermark_vectors.rs
Normal 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
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
1562
wai/wai-rs/src/live_watermark.rs
Normal file
1562
wai/wai-rs/src/live_watermark.rs
Normal file
File diff suppressed because it is too large
Load diff
1090
wai/wai-rs/src/live_watermark/corpus.rs
Normal file
1090
wai/wai-rs/src/live_watermark/corpus.rs
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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"),
|
||||
|
|
|
|||
53
wai/wai-rs/tests/live_watermark.rs
Normal file
53
wai/wai-rs/tests/live_watermark.rs
Normal 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");
|
||||
}
|
||||
Loading…
Reference in a new issue