Some checks are pending
wai release binaries build on Windows / the seven release binaries · windows (push) Waiting to run
joule-moq-room room service / joule-moq-room · does this change touch it (push) Successful in 18s
joule-moq-remote host agent / joule-moq-remote · does this change touch it (push) Successful in 21s
joule-moq-http live HTTP egress / joule-moq-http · does this change touch it (push) Successful in 22s
joule-moq-web browser client / joule-moq-web · does this change touch it (push) Successful in 18s
wai-sink-swift native sink package / does this change touch it (push) Successful in 18s
WAI Studio / does this change touch it (push) Successful in 16s
wai-quantum studio and desktop / does this change touch it (push) Successful in 1m16s
wai-ledger service / does this change touch it (push) Successful in 1m17s
wai-sink-android native sink library / does this change touch it (push) Successful in 1m18s
WAI operation catalogue / does this change touch it (push) Successful in 1m18s
wai-app and Create / wai-app · does this change touch it (push) Successful in 1m19s
wai-quantum for JavaScript / does this change touch it (push) Successful in 1m18s
wai-quantum studio and desktop / desktop app · windows (push) Waiting to run
wai-audit collector and player integration / does this change touch it (push) Successful in 1m20s
wai-fleet service / does this change touch it (push) Successful in 1m20s
joule-moq-remote host agent / joule-moq-remote, every target (linux-arm64) (push) Successful in 2m27s
joule-moq-http live HTTP egress / joule-moq-http, every target (push) Successful in 3m4s
joule-code conformance gate / joule-moq-relay, every target (push) Successful in 6m19s
WAI breaking changes and the conformance runner / WAI breaking · does this change touch WAI (push) Successful in 12s
WAI Studio / the catalogue's actions, the unit tests and the core · linux (push) Successful in 1m38s
joule-code conformance gate / joule-moq-ingest, every target (push) Successful in 9m20s
wai-app and Create / wai-app · tests, clippy, the line language's verifier (linux) (push) Successful in 51s
wai-sink-android native sink library / the JNI layer and the platform-free half on the host JVM · linux-arm64 (push) Successful in 6m27s
wai-audit collector and player integration / collector · linux (push) Successful in 3m1s
joule-code conformance gate / Build + round-trip C1-C4 vectors (push) Successful in 5m27s
wai-ledger service / service · linux (push) Successful in 2m9s
wai-fleet service / service · linux (push) Successful in 2m57s
joule-moq-room room service / joule-moq-room, every target (push) Successful in 12m2s
WAI breaking changes and the conformance runner / WAI breaking · wire, record and API against main · linux (push) Successful in 2m13s
wai-quantum for JavaScript / javascript · linux (push) Successful in 4m30s
wai-quantum studio and desktop / studio · browser (push) Successful in 7m51s
repository checks / lockfiles, advisories, tests in CI, workflow hygiene, spec and section references, public text (push) Successful in 16m20s
wai-quantum studio and desktop / desktop app · linux (push) Successful in 10m36s
WAI breaking changes and the conformance runner / WAI breaking and conform · their tests, and the independent verifiers through wai conform · linux (push) Successful in 7m7s
WAI operation catalogue / the catalogue and every door · linux (push) Successful in 15m27s
joule-code conformance gate / joule-moq-relay, its own energy on a counter (macos) (push) Successful in 51s
joule-moq-remote host agent / joule-moq-remote, every target (macos) (push) Successful in 54s
WAI Studio / Studio in WebKit · linux (push) Successful in 23m45s
wai-quantum studio and desktop / desktop app · macos (push) Successful in 3m57s
wai-fleet service / service · macos (push) Successful in 43s
wai-audit collector and player integration / collector, player integration and the browser end to end · macos (push) Successful in 3m7s
wai-ledger service / service · macos (push) Successful in 1m3s
wai-app and Create / wai-app · Create, browser end-to-end (macos) (push) Successful in 6m33s
WAI Studio / Studio in Chromium and Firefox · macos (push) Successful in 11m31s
joule-moq-web browser client / joule-moq-web · browser end-to-end (macos) (push) Successful in 16m2s
wai-sink-android native sink library / every ABI, the AAR, and the instrumented tests in a headless emulator · macos (push) Successful in 14m10s
wai-sink-swift native sink package / libwai slices, desktop tests, simulator tests · macos (push) Successful in 27m10s
moq-streaming-format §4.9 (new). A room's CMAF track may be protected as draft-ietf-moq-cmsf-01 §4 has it: the samples inside each object's chunk are encrypted under cbcs or cenc and the catalog states the protection (contentProtections, contentProtectionRefIDs, a protected header). An object carries nothing of it on the wire, so a relay forwards it as any object and its receipts are over the protected bytes. CMSF states one header for a track and no way to change its key. A key period is stated here by a catalog of its own: that period's header, its KID, and wai_key_period on the track (the period's index and the first group it holds for). A packager is answered the periods' keys by a key provider (key-release §15), whose signed list a proxy releases from. - Reference: wai-pack's cmsf module, over the protected floor's writers (protect_track, catalog, check_track). AV1 video, one chunk an object. - Corpus: live-conformance/protected-cmsf/, written by wai-pack's tests/cmsf.rs: a track under both schemes with three key periods, accepted, which is every header written again under its period's key and every object decrypted to its clear chunk and written again byte for byte; and sixteen planted faults, each refused with its reason by the reference and by protected_cmsf_verify.py, the second implementation, which catches each of its twelve checks dropped and fifteen changed copies. Among the faults: objects under the last period's key (protected_cmsf_object), a catalog naming another period's key (protected_cmsf_key_period), a key the CPIX document does not hold (protected_cmsf_key_unknown). - Through a relay: joule-moq-ingest's tests/protected_cmsf.rs publishes the corpus's track with the gateway's publisher, receipts over the protected bytes, through a relay whose door checks each object against its receipt, and reads it back byte for byte. - catalog_check.py holds wai_key_period; the corpus's six catalogs pass --strict, and five more planted faults are caught (107). Decision D18. Not done, and stated: an audio track; a packager in the ingest gateway; a browser playing from MoQ; any content protection system's licence. The live HTTP egress serves a protected track's bytes with playlists that do not state the protection; §4.9 says what it would write, and that is left to its owner. Co-Authored-By: Claude Fable 5.2 <[email protected]>
995 lines
60 KiB
Markdown
995 lines
60 KiB
Markdown
# WAI Extension: Key Release
|
||
|
||
> Status: Draft. Adds a second form to the record `wai.video.keys` (SPEC §5): the
|
||
> **proxy form**, signed by a **signing proxy** that sits in front of a licence server and
|
||
> signs one receipt for every licence request it answers, a release or a refusal. The receipt
|
||
> names the encryption **scheme** of each key (ISO/IEC 23001-7's `cenc`, `cbc1`, `cens` and
|
||
> `cbcs`; the HLS methods `AES-128`, `SAMPLE-AES` and `AES-256-GCM`), the **key delivery**
|
||
> (an HLS key URI, W3C Clear Key, or a content protection system by its system ID) and,
|
||
> optionally, the SHA-256 of the **CPIX document** (DASH-IF CPIX 2.3, ETSI TS 103 799) the
|
||
> keys came in. The key holder signs a **commitment list**, `WKL1`, derived from the CPIX key
|
||
> material: each key's identifier, scheme, key period and commitment, and, where the content
|
||
> has one, its **release time**, before which no key of the list is released whatever the
|
||
> entitlement says. The proxy copies the commitments from that list, so it never holds a key.
|
||
> The viewer's entitlement is a record its **entitlement authority** signs, `WKE1`, bound to
|
||
> a key the viewer's device holds, and every request carries the device's signature over what
|
||
> it asks for. The decision is a pure function of signed inputs that a verifier recomputes:
|
||
> entitlement, freshness, replay, the content's release time, key period (key rotation),
|
||
> offline licences and their renewals. A key the client receives in the clear (an HLS key
|
||
> URI, Clear Key) is never relayed by the proxy: it signs a **release ticket**, `WKT1`, which
|
||
> a key server redeems once. An entitlement may state **rules** (§14): how many sessions of a
|
||
> group may stream at once, counted from the proxy's own signed releases in a stated window,
|
||
> and the least **output level** the device must be shown to have, by a signed statement of a
|
||
> stated class; a request the signed records cannot decide is signed as undecided and is not
|
||
> released. The form-1 receipt is unchanged. No new
|
||
> transport, and WAI still speaks no licence protocol: a licence travels as its system
|
||
> defines it. The reference implementation is the `key_release` module of `wai-rs` (feature
|
||
> `key_release`) and the service `wai/wai-key-proxy`; the corpus is
|
||
> [`live-conformance/keys/`](../live-conformance/keys/), and its second implementation is
|
||
> `live-conformance/key_release_verify.py`.
|
||
> Keywords **MUST**, **MUST NOT**, **SHOULD**, **MAY** are RFC 2119/8174.
|
||
>
|
||
> Texts read for this revision: the WAI texts this one cites; the DASH-IF CPIX
|
||
> Implementation Guidelines in the commit snapshot of 30 September 2024, which states its
|
||
> changes against version 2.3.1, for its §6.4.4, §7.4.2, §7.4.7, §7.4.10, §7.4.13 to
|
||
> §7.4.17, §8.1 and §8.2 (every element and attribute §4.4 reads is in version 2.3, which ETSI
|
||
> publishes as TS 103 799; the per-key `@contentId` that snapshot adds is not read);
|
||
> `draft-pantos-hls-rfc8216bis-22` §4.4.4.4 (`EXT-X-KEY`) and §5 (key files); W3C Encrypted
|
||
> Media Extensions, Working Draft of 13 October 2026, §9.1 (Clear Key) and its note on
|
||
> `encryptionScheme`; the DASH-IF registry of content protection system identifiers; RFC
|
||
> 8032; RFC 4648 §5; RFC 9110 §15; for §14, W3C Encrypted Media Extensions, Working Draft of
|
||
> 8 November 2026, its `getStatusForPolicy()` and `MediaKeysPolicy`, and the W3C Encrypted Media
|
||
> Extensions HDCP Version Registry, Registry Draft of 7 July 2026. The text of ISO/IEC 23001-7 was not available to this
|
||
> revision: its scheme types are named here as CPIX §7.4.7, the EME note and the HLS draft
|
||
> name them. A section number of one of these texts is that revision's.
|
||
|
||
## 1. Scope
|
||
|
||
In scope: the encryption schemes and key deliveries a receipt names (§3); the commitment list
|
||
and its derivation from a CPIX document, and what a packager reads of that document (§4); the entitlement (§5); the licence request and
|
||
the device's proof over it (§6); the release decision, its offline licences, renewals and key
|
||
periods (§7); the proxy form of the `wai.video.keys` receipt (§8); the signing proxy, the
|
||
release ticket and the key server (§9); verification of a proxy's log (§10).
|
||
|
||
Out of scope: a licence protocol, and the licence a content protection system issues, which
|
||
the proxy forwards and never reads (§9.2); how an entitlement authority decides whom to
|
||
entitle (its subscription or purchase records); a CPIX document whose content keys are
|
||
encrypted (CPIX §8.1), which this revision does not read (§4.4); how an operator ties a
|
||
session, a device or a group of sessions to a person or an account, which no record here
|
||
states; and a limit on how many devices an account registers, which is the entitlement
|
||
authority's, since it is the authority that issues entitlements to holder keys. A limit on
|
||
sessions streaming at once, and an output level, are §14.
|
||
|
||
Relationship to the core spec: `wai.video.keys` stays a **record** (SPEC §5). Form 1, the
|
||
receipt SPEC §5 registers, is unchanged: its signed bytes, its identity and every receipt of
|
||
it stay as they were. This extension adds form 2 (§8). Both are records of Lane A (SPEC §6.5):
|
||
they cover the release decision, never a frame the key opens.
|
||
|
||
Relationship to [energy-measurement](energy-measurement.md): a form-2 receipt carries the
|
||
decision's energy as the OTT receipts do, a figure and its acquisition class, and its identity
|
||
is under the identity domain energy-measurement §6.1 names for `wai.video.keys`.
|
||
|
||
## 2. Terms
|
||
|
||
- **Content key**: a key that encrypts media. Its **KID** is its 16-byte key identifier, in a
|
||
CPIX document the UUID of `ContentKey@kid` (CPIX §7.4.7), its 16 bytes in the UUID's order.
|
||
- **Key holder**: the party that holds the content keys: the packager or encryptor, or the key
|
||
server it exchanges CPIX documents with. It signs the commitment list (§4).
|
||
- **Entitlement authority**: the operator's system that decides who may watch what. It signs
|
||
entitlements (§5).
|
||
- **Holder key**: an Ed25519 key a viewer's device holds. An entitlement names it, and the
|
||
device signs each request with it (§6).
|
||
- **Licence server**: any server that issues licences for a content protection system. It
|
||
is not changed by this extension.
|
||
- **Signing proxy**, or **proxy**: the service that decides each licence request, signs its
|
||
receipt, and forwards a granted request to the licence server or issues a release ticket
|
||
(§9). It holds no content key.
|
||
- **Key server**: the service that redeems a release ticket for a key delivered in the clear
|
||
(§9.4). It is run by the key holder.
|
||
- **Log**: every receipt one proxy signed, in the order it signed them (§9.5).
|
||
- **Wrapped delivery**: a delivery whose licence carries the key encrypted for the client's
|
||
decryption module (`drm`). **Clear delivery**: a delivery whose response is the key itself
|
||
(`hls-key-uri`, `clear-key`).
|
||
- Byte fields are big-endian where they are integers; a **string** in a binary layout is its
|
||
length as a u32 and its UTF-8 bytes; **base64url** is RFC 4648 §5 without padding.
|
||
|
||
## 3. Encryption schemes and key deliveries
|
||
|
||
### 3.1 Schemes
|
||
|
||
The scheme is what was applied to the media's bytes:
|
||
|
||
| code | label | defined by | what it encrypts | key octets |
|
||
|---|---|---|---|---|
|
||
| 1 | `cenc` | ISO/IEC 23001-7 | AES-CTR, full samples and video subsamples; under HLS, `SAMPLE-AES-CTR` of fMP4 segments | 16 |
|
||
| 2 | `cbc1` | ISO/IEC 23001-7 | AES-CBC, full samples and video subsamples | 16 |
|
||
| 3 | `cens` | ISO/IEC 23001-7 | AES-CTR, a pattern of video subsamples | 16 |
|
||
| 4 | `cbcs` | ISO/IEC 23001-7 | AES-CBC, a pattern of video subsamples with a constant IV; under HLS, `SAMPLE-AES` of fMP4 segments | 16 |
|
||
| 5 | `AES-128` | `draft-pantos-hls-rfc8216bis-22` §4.4.4.4 | whole segments, AES-128-CBC with PKCS7 padding | 16 |
|
||
| 6 | `SAMPLE-AES` | `draft-pantos-hls-rfc8216bis-22` §4.4.4.4 | samples of segments other than fMP4 (MPEG-2 transport streams, packed audio) | 16 |
|
||
| 7 | `AES-256-GCM` | `draft-pantos-hls-rfc8216bis-22` §4.4.4.4 | whole segments, AES-256-GCM | 32 |
|
||
|
||
The label is the value CPIX's `ContentKey@commonEncryptionScheme` takes for that scheme (CPIX
|
||
§7.4.7: a four-character Common Encryption scheme, or an HLS method). An fMP4 segment that HLS
|
||
encrypts with `SAMPLE-AES` is `cbcs`, and with `SAMPLE-AES-CTR` is `cenc`; `SAMPLE-AES` here
|
||
names only the other segment formats, so one scheme never has two labels.
|
||
|
||
### 3.2 Key deliveries
|
||
|
||
| code | kind | how the client gets the key | system ID |
|
||
|---|---|---|---|
|
||
| 1 | `hls-key-uri` | the key file of an `EXT-X-KEY` URI with `KEYFORMAT` `"identity"` (or none): the key's octets, packed (`draft-pantos-hls-rfc8216bis-22` §5.1). Clear | none |
|
||
| 2 | `clear-key` | the JSON Web Key Set of the `org.w3.clearkey` key system (EME §9.1.4). Clear | none |
|
||
| 3 | `drm` | a licence of the content protection system the system ID names, which carries the key encrypted for the client's decryption module. Wrapped | 16 bytes: a system ID of the DASH-IF registry, not all zero |
|
||
|
||
### 3.3 Which deliveries carry which schemes
|
||
|
||
| delivery | schemes |
|
||
|---|---|
|
||
| `hls-key-uri` | `cenc`, `cbcs`, `AES-128`, `SAMPLE-AES`, `AES-256-GCM`: the methods HLS defines with an identity key file |
|
||
| `clear-key` | `cenc`, `cbc1`, `cens`, `cbcs`: ISO/IEC 23001-7, which is what EME decrypts |
|
||
| `drm` | all seven |
|
||
|
||
A receipt that names a key under a delivery this table does not give its scheme is refused
|
||
`scheme_delivery_mismatch` (§8.4). A proxy MUST NOT serve such a key on such a route (§9.1).
|
||
|
||
## 4. The commitment list `WKL1`
|
||
|
||
### 4.1 Layout
|
||
|
||
A commitment list is one content's keys, as the key holder states them. It is these bytes in
|
||
this order:
|
||
|
||
| field | bytes |
|
||
|---|---|
|
||
| magic | `WKL1` |
|
||
| version | u8, 1 or 2 |
|
||
| `content_id` | string, not empty |
|
||
| `required_entitlement` | string, not empty: the entitlement class the content demands (§7.2 check 6) |
|
||
| `cpix` | u8 0, or u8 1 and 32 bytes: the SHA-256 of the CPIX document the keys came in, its exact bytes |
|
||
| `issued` | u64: when the list was signed, in seconds since 1970-01-01T00:00:00Z |
|
||
| `not_before` | version 2 only: u64, the content's **release time**, in seconds since 1970-01-01T00:00:00Z: no key of the list is released by a decision made before it (§7.2 check 5) |
|
||
| key count | u32, from 1 to 4096 |
|
||
| each key | `kid` (16), `scheme` (u8, §3.1), `key_commit` (32), then its period (§4.2) |
|
||
| `signer_pubkey` | 32: the key holder's Ed25519 key |
|
||
| `signer_id` | string |
|
||
| `sig` | 64 |
|
||
|
||
A list of version 1 has no `not_before`, and its content no release time. A key holder writes
|
||
version 2 exactly for a list that states a release time, so a list that states none keeps
|
||
version 1 and its bytes, and every list of version 1 reads as it did. The release time is the
|
||
key holder's, signed with the keys: a content's release, before which nobody receives its keys
|
||
whatever their entitlement. It is not read from the CPIX document (§4.4).
|
||
|
||
`key_commit` is the commitment of form 1, unchanged: BLAKE3 over `wai:video-keys-commit` 0x01,
|
||
the key's length (u32) and its octets. A key's period is one byte, then:
|
||
|
||
| tag | then | means |
|
||
|---|---|---|
|
||
| 0 | nothing | the key has no key period |
|
||
| 1 | `index` (u32) | a period known by its index only (CPIX §7.4.14) |
|
||
| 2 | `start` (u64), `end` (u64) | a period of wall-clock seconds, from `start` up to but excluding `end` |
|
||
| 3 | `index` (u32), `start` (u64), `end` (u64) | both |
|
||
|
||
### 4.2 Signature and identity
|
||
|
||
`sig` is Ed25519 by `signer_pubkey`, verified as SPEC §7 *Signatures* has it, over
|
||
`wai:video-keys-list` 0x01 and every byte of the list before `sig`. The list's **identity** is
|
||
BLAKE3 over `wai:video-keys-list-id` 0x01 and every byte of the list, `sig` included.
|
||
|
||
### 4.3 Rules
|
||
|
||
A list holds: its magic, and a version of 1 or 2; each string UTF-8, `content_id` and
|
||
`required_entitlement` not empty; the KIDs in strictly ascending byte order; every scheme a
|
||
code of §3.1; no `key_commit` all zero; every period tag of §4.1, and `start` below `end`; no
|
||
byte after `sig`; and its signature verifies. A list that fails any of these is refused
|
||
`list_malformed`, or `list_bad_signature` when only its signature fails.
|
||
|
||
### 4.4 A list derived from a CPIX document
|
||
|
||
A list whose `cpix` is present states that it is derived from that CPIX document. A verifier
|
||
given the document derives a list from it by these steps, refusing the document at the first
|
||
that fails, and then refuses the list `cpix_mismatch` when its `content_id`, its `cpix`, or
|
||
its keys (each `kid`, `scheme`, `key_commit` and period) differ from the derived ones. Element
|
||
names are in the namespace `urn:dashif:org:cpix`, except `Secret`, `PlainValue` and
|
||
`EncryptedValue`, which are in `urn:ietf:params:xml:ns:keyprov:pskc`; attributes have no
|
||
namespace.
|
||
|
||
1. The document is well-formed XML 1.0 in UTF-8, has no document type declaration, and its
|
||
root is `CPIX`; otherwise `cpix_malformed`. `cpix` is the SHA-256 of its bytes, as
|
||
exchanged.
|
||
2. The root has `@contentId`, which is `content_id`; otherwise `cpix_unsupported`.
|
||
3. The keys are the `ContentKey` children of the root's `ContentKeyList` child, in document
|
||
order; none at all is `cpix_malformed`. For each, in turn:
|
||
1. its `@kid` is a UUID in its canonical text form (8-4-4-4-12 hexadecimal digits, either
|
||
case), read as 16 bytes in that order, and no earlier key has it; otherwise
|
||
`cpix_malformed`;
|
||
2. it has neither `@contentId` nor `@dependsOnKey`, and its `@commonEncryptionScheme` is a
|
||
label of §3.1, which is its `scheme`; otherwise `cpix_unsupported`;
|
||
3. its `Data` child holds a `Secret` child holding a `PlainValue` child; otherwise (an
|
||
`EncryptedValue` among them, CPIX §8.1's encrypted key, which this revision does not
|
||
read) `cpix_unsupported`;
|
||
4. that `PlainValue`'s text, without its ASCII whitespace, is base64 (RFC 4648 §4) in its
|
||
canonical form, and decodes to the scheme's key octets, which are the key; otherwise
|
||
`cpix_malformed`.
|
||
4. The periods: each `ContentKeyPeriod` child of the root's `ContentKeyPeriodList` child is a
|
||
period, known by its `@id`. Each `KeyPeriodFilter` child of each `ContentKeyUsageRule` child
|
||
of the root's `ContentKeyUsageRuleList` child, in document order, names a period by
|
||
`@periodId` for the key the rule's `@kid` names. A rule whose `@kid` names no key, or a
|
||
filter whose `@periodId` names no period, is `cpix_malformed`; a key named for two different
|
||
periods is `cpix_unsupported`.
|
||
5. Each key named for a period, in ascending KID order, has that period. A period with
|
||
`@startOffset`, `@endOffset` or `@duration`, with `@start` and no `@end` or the reverse, with
|
||
neither `@index` nor `@start`, with an `@index` that is not a decimal integer from 0 to
|
||
2^32 − 1, or with a time not of the form `YYYY-MM-DDThh:mm:ssZ`, is `cpix_unsupported`. A
|
||
period whose `@start` is not below its `@end` is `cpix_malformed`. Otherwise `index` is its
|
||
`@index` and `start` and `end` its `@start` and `@end` in seconds since
|
||
1970-01-01T00:00:00Z, each where present. A key named for no period has none.
|
||
|
||
The list's keys are the document's, in ascending KID order, each with the `key_commit` of its
|
||
key. `required_entitlement`, `issued`, `not_before` and the signer are the key holder's and are
|
||
not in the document. A `DRMSystem` element (CPIX §7.4.10) is not read: the delivery is the
|
||
route's (§9.1).
|
||
|
||
### 4.5 The packager's side
|
||
|
||
A packager that encrypts a package's floor ([packaging](packaging.md) §7, the protected floor) reads the same
|
||
document by steps 1 to 3 above, for each key's octets, and its policy names the keys it uses by
|
||
KID and the document by its SHA-256, so the package receipt names the document a list derived
|
||
from it names in `cpix`, and a KID the document does not hold is refused before anything is
|
||
encrypted. The protected presentation reaches a key through a route of §9.1 whose `content_id`
|
||
is the document's `@contentId`: its DASH Clear Key descriptor's licence URI is a `clear-key`
|
||
route, whose request is the EME licence request of the KIDs the inits' `pssh` boxes name, and its
|
||
HLS `identity` key URI is an `hls-key-uri` route, one KID a request; both release a ticket the key
|
||
server redeems once (§9.3, §9.4). A content protection system the presentation names by system ID
|
||
is a `drm` route of that ID, whose licence is the system's (§9.2) and is not tested by the
|
||
corpus. The corpus `pack-conformance/protected/` holds such a document of test keys, and
|
||
wai-key-proxy's `tests/protected_floor.rs` releases its keys by tickets, each redeemed once.
|
||
|
||
## 5. The entitlement `WKE1`
|
||
|
||
### 5.1 Layout
|
||
|
||
| field | bytes |
|
||
|---|---|
|
||
| magic | `WKE1` |
|
||
| version | u8, 1 |
|
||
| `session` | 16: the playback session or purchase it entitles |
|
||
| `holder` | 32: the holder key, an Ed25519 key the device holds |
|
||
| `entitlement` | string, not empty: the class it grants, compared byte for byte with a list's `required_entitlement` |
|
||
| `not_before` | u64, seconds |
|
||
| `not_after` | u64, seconds: the entitlement holds from `not_before` to `not_after`, both included |
|
||
| `offline_s` | u64: the longest offline licence it permits, in seconds; 0 for none |
|
||
| `renewals` | u32: how many times an offline licence may be renewed; 0 when `offline_s` is 0 |
|
||
| `authority_pubkey` | 32: the entitlement authority's Ed25519 key |
|
||
| `authority_id` | string |
|
||
| `sig` | 64 |
|
||
|
||
### 5.2 Signature, digest and rules
|
||
|
||
`sig` is Ed25519 by `authority_pubkey` over `wai:video-keys-entitlement` 0x01 and every byte
|
||
before `sig`. An entitlement's **digest** is the SHA-256 of all its bytes. An entitlement
|
||
holds when its magic and version are these, its strings are UTF-8 and `entitlement` is not
|
||
empty, `not_before` is at most `not_after`, `renewals` is 0 when `offline_s` is 0, no byte
|
||
follows `sig`, and its signature verifies. One that does not hold, or whose authority the
|
||
proxy does not trust, entitles nothing (§7.2 check 1). It is not a refusal of the receipt
|
||
that carries it: the receipt is a signed refusal of the request.
|
||
|
||
## 6. The licence request
|
||
|
||
### 6.1 What a request asks for, and its proof
|
||
|
||
A request names: the content (by the proxy's route, §9.1, which gives its `content_id` and its
|
||
delivery); its KIDs, from 1 to 64, distinct; its **kind**, `streaming` (1), `offline` (2) or
|
||
`renewal` (3); for a renewal, `renews`, the identity of the receipt that released the licence
|
||
it renews; the SHA-256 of the request body as the proxy received it (`request_sha256`; of no
|
||
bytes for a request without a body); and `request_t`, the device's clock, in seconds. With it
|
||
come the entitlement and the **proof**: Ed25519 by the entitlement's `holder` over
|
||
|
||
1. `wai:video-keys-request` (ASCII) and 0x01;
|
||
2. the entitlement's digest (32);
|
||
3. `content_id` (string);
|
||
4. the delivery's code (u8), then 1 and its system ID (16), or 0 for none;
|
||
5. the KIDs' count (u32) and each KID, in ascending order;
|
||
6. the kind's code (u8), then 1 and `renews` (32), or 0 for none;
|
||
7. `request_sha256` (32) and `request_t` (u64).
|
||
|
||
The proof makes a copied entitlement useless without the device's key, and binds the request
|
||
body, the KIDs and the kind to it. Every hash here is SHA-256 so that a browser computes the
|
||
proof with the Web Cryptography API.
|
||
|
||
### 6.2 HTTP carriage
|
||
|
||
A request is an HTTP request to the route's URI (§9.6), with these header fields:
|
||
|
||
| field | value |
|
||
|---|---|
|
||
| `WAI-Key-Ids` | the KIDs, each 32 lowercase hexadecimal digits, separated by commas |
|
||
| `WAI-Key-Kind` | `streaming`, `offline` or `renewal` |
|
||
| `WAI-Key-Renews` | for a renewal, `renews` in 64 lowercase hexadecimal digits |
|
||
| `WAI-Key-Time` | `request_t`, a decimal integer |
|
||
| `WAI-Key-Entitlement` | the entitlement's bytes, base64url |
|
||
| `WAI-Key-Proof` | the proof's 64 bytes, base64url |
|
||
|
||
The body is what the client's decryption module or player sends: a licence challenge, an EME
|
||
Clear Key licence request (EME §9.1.3), or nothing for an HLS key file. The proxy answers with
|
||
the receipt's identity in `WAI-Key-Receipt`, 64 lowercase hexadecimal digits, and makes the
|
||
receipt itself readable at its identity (§9.6). A request with no `WAI-Key-Ids`, or one that
|
||
names no KID, more than 64, a KID twice or a KID that is not 32 lowercase hexadecimal digits,
|
||
a `WAI-Key-Kind` that is none of the three, or a route the proxy does not have, names nothing to
|
||
decide: it is answered 400 or 404 and has no receipt. Every other request has one, whatever
|
||
else it lacks, read so:
|
||
|
||
- no `WAI-Key-Kind` is `streaming`;
|
||
- no `WAI-Key-Time`, or one that is not a decimal integer below 2^64, is `request_t` 0;
|
||
- a renewal with no `WAI-Key-Renews` that reads renews 32 zero bytes;
|
||
- an entitlement or a proof that does not read as base64url (a proof of other than 64 bytes
|
||
included), or one without the other, is recorded as neither, so the request is refused
|
||
`DeniedEntitlementInvalid` (§7.2 check 1).
|
||
|
||
## 7. The release decision
|
||
|
||
### 7.1 Inputs
|
||
|
||
The decision of a request is a function of: the entitlement as presented, or none; the
|
||
**authorities**, the entitlement authority keys the proxy trusts; the proof; the request's
|
||
fields (§6.1); the content's commitment list, or none; `issued`, the proxy's clock when it
|
||
decided, in seconds; and the receipts earlier in the proxy's log. Every input is in the
|
||
receipt (§8.2) or is a signed record a verifier is given (§10.1), so a verifier recomputes the
|
||
decision.
|
||
|
||
### 7.2 The checks, in order
|
||
|
||
The decision is the first of these that applies:
|
||
|
||
| check | decision | code | when |
|
||
|---|---|---|---|
|
||
| 1 | `DeniedEntitlementInvalid` | 4 | no entitlement was presented; or it does not hold (§5.2); or its `authority_pubkey` is not one of the authorities; or the proof does not verify under its `holder` over the request (§6.1) |
|
||
| 2 | `DeniedStale` | 5 | `issued` and `request_t` differ by more than 300 |
|
||
| 3 | `DeniedReplayed` | 6 | a receipt earlier in the log carries the same proof |
|
||
| 4 | `DeniedUnknownKey` | 7 | the proxy has no commitment list for the content, or a requested KID is not in it |
|
||
| 5 | `DeniedNotReleased` | 14 | the list has a `not_before`, and `issued` is below it |
|
||
| 6 | `DeniedNoEntitlement` | 2 | the entitlement's `entitlement` is not the list's `required_entitlement` |
|
||
| 7 | `DeniedNotYetValid` | 8 | `issued` is below the entitlement's `not_before` |
|
||
| 8 | `DeniedExpired` | 3 | `issued` is above the entitlement's `not_after` |
|
||
| 9 | `DeniedPeriodOutside` | 9 | a requested key's period has `start` and `end`, and the period does not meet the entitlement: `start` above the entitlement's `not_after`, or `end` at most the entitlement's `not_before` |
|
||
| 10 | `DeniedOfflineNotEntitled` | 10 | the kind is `offline` or `renewal`, and `offline_s` is 0 |
|
||
| 11 | `DeniedRenewalUnknown` | 11 | the kind is `renewal`, and no receipt earlier in the log has the identity `renews`, is released, is of kind `offline` or `renewal`, names the same `content_id` and the same KIDs, and carries an entitlement with the same `session` and `holder` |
|
||
| 12 | `DeniedRenewalsExhausted` | 12 | the kind is `renewal`, and that receipt's `renewal` plus 1 is above `renewals` |
|
||
| 13 | `DeniedRenewalLapsed` | 13 | the kind is `renewal`, and `issued` is above that receipt's `licence_expiry` |
|
||
| — | `Granted` | 1 | none of these applies |
|
||
|
||
Codes 1 to 3 and their labels are form 1's (SPEC §5): `Granted`, `DeniedNoEntitlement` and
|
||
`DeniedExpired` mean in form 2 what they mean there. Check 2 bounds how long a captured request
|
||
is good for; check 3 makes it good once. A client that retries a request signs it again. A
|
||
refused request is refused whole: a request for several keys, one of which fails a check, is
|
||
refused for all of them.
|
||
|
||
Check 5 is the content's release time, the key holder's, and comes before every check that
|
||
reads the entitlement's class, window or allowance, so a request decided before it is refused
|
||
`DeniedNotReleased` whatever its entitlement grants; an entitlement that does not hold, or no
|
||
entitlement, is still refused by check 1 first, and a stale or replayed request by checks 2
|
||
and 3. The release time is read from the list the receipt names by its identity, so a verifier
|
||
holding the list recomputes it (§10.2 step 3.6). From the release time on, `issued` is not
|
||
below it and check 5 refuses nothing. Code 14 follows form 2's other codes, so no earlier
|
||
receipt's code changes.
|
||
|
||
### 7.3 The licence a release permits
|
||
|
||
A `Granted` request of kind `streaming` permits a licence with no stated expiry: its duration
|
||
is the licence server's. A `Granted` request of kind `offline` or `renewal` permits an
|
||
**offline licence** that expires at `licence_expiry`, the lesser of `issued` + `offline_s` and
|
||
`not_after`, and is renewal number `renewal`: 0 for kind `offline`, and the renewed receipt's
|
||
`renewal` plus 1 for kind `renewal`. So a licence never outlives the entitlement, a renewal
|
||
must come before the licence it renews lapses, and an entitlement bounds how many renewals
|
||
there are. A licence that lapsed is not renewed; the device asks for a new offline licence,
|
||
which the entitlement must still permit.
|
||
|
||
### 7.4 Key rotation
|
||
|
||
A key whose list entry has a period is one key of a rotation: the CPIX document holds one key
|
||
per period (CPIX §8.2). Check 9 releases the key of a period only to an entitlement that holds
|
||
during some of it, so a viewer whose entitlement ends gets no key of a later period, and one
|
||
whose entitlement begins gets none of an earlier one. A period known by its index alone has no
|
||
time, and check 9 does not apply to it. A live track over MoQ states each period's key in a
|
||
catalog of its own ([moq-streaming-format](moq-streaming-format.md) §4.9), and a packager is
|
||
answered a rotation's keys by a key provider (§15).
|
||
|
||
## 8. The proxy form of the `wai.video.keys` receipt
|
||
|
||
### 8.1 Forms
|
||
|
||
A `wai.video.keys` receipt has two forms: **form 1**, SPEC §5's, unchanged; and **form 2**, the
|
||
proxy form, defined here. They sign under different domains (§8.3), so no signature of one is
|
||
a signature of the other; they share the key commitment (§4.1) and the identity domain, so a
|
||
figure profile of energy-measurement §6.1, `wai:video-keys-receipt-id`, names either.
|
||
|
||
### 8.2 Fields and their JSON carriage
|
||
|
||
A form-2 receipt is one JSON object (RFC 8259) with exactly these members. A byte field is
|
||
lowercase hexadecimal of its exact length, an integer a JSON integer from 0 to 2^64 − 1, and
|
||
an absent value `null`:
|
||
|
||
| member | value |
|
||
|---|---|
|
||
| `form` | 2 |
|
||
| `content_id` | a string: the route's content |
|
||
| `list` | 32 bytes: the identity of the content's commitment list (§4.2), or `null` when the proxy has none |
|
||
| `cpix_sha256` | 32 bytes: the list's `cpix`, or `null` |
|
||
| `required_entitlement` | a string: the list's, or `null` |
|
||
| `delivery` | `{"kind", "system_id"}`: the kind's label (§3.2), and its system ID, 16 bytes, or `null` |
|
||
| `keys` | an array, in ascending KID order: each requested key, `{"kid", "scheme", "period", "key_commit"}`, `kid` 16 bytes; `scheme` its label (§3.1), or `null` for a KID not in the list; `period` `null` or `{"index", "start", "end"}`, each `null` where the period has none; `key_commit` 32 bytes or `null` |
|
||
| `kind` | `streaming`, `offline` or `renewal` |
|
||
| `renews` | 32 bytes, or `null` |
|
||
| `entitlement` | the entitlement's bytes, or `null` when none was presented |
|
||
| `request_sha256` | 32 bytes |
|
||
| `request_t` | an integer |
|
||
| `proof` | 64 bytes, or `null` when no entitlement was presented |
|
||
| `issued` | an integer |
|
||
| `decision` | its label (§7.2) |
|
||
| `licence_expiry` | an integer, or `null` |
|
||
| `renewal` | an integer from 0 to 2^32 − 1, or `null` |
|
||
| `upstream_status` | the licence server's HTTP status, from 100 to 599, or `null` when the request was not forwarded |
|
||
| `response_sha256` | 32 bytes: the SHA-256 of the response body the licence server returned and the proxy forwarded, or `null` |
|
||
| `released` | `true` when the key was released (§9.2, §9.3), `false` otherwise |
|
||
| `joules_micro` | the decision's energy, or 0 unmetered |
|
||
| `energy_provenance` | its class's label (energy-measurement §2.7: `ModelBased`, `Estimator`, `OnChipCounter`, `CalibratedInstrument`), or `null` unmetered |
|
||
| `energy_uncertainty`, `energy_counter_filtering`, `energy_calibration_ref` | only for a class that carries them, as energy-measurement §2.7 names and writes them |
|
||
| `signer_pubkey` | 32 bytes: the proxy's Ed25519 key |
|
||
| `signer_id` | a string |
|
||
| `sig` | 64 bytes |
|
||
|
||
A receipt that does not read so, whose `entitlement` is not hexadecimal, or that carries the
|
||
legacy class label `HwShunt` (energy-measurement §2.6), is refused `receipt_malformed`.
|
||
|
||
### 8.3 Signing payload and identity
|
||
|
||
The **signing payload** is the concatenation of:
|
||
|
||
1. `wai:video-keys-proxy-receipt` (ASCII) and 0x01;
|
||
2. `content_id` (string);
|
||
3. `list`, `cpix_sha256`: each 0 for `null`, or 1 and its 32 bytes;
|
||
4. `required_entitlement`: 0, or 1 and the string;
|
||
5. the delivery's code (u8), then `system_id`: 0, or 1 and its 16 bytes;
|
||
6. the keys' count (u32), then each key: `kid`; its scheme's code (u8), 0 for `null`; its
|
||
period, as §4.1 writes one (tag 0 for `null`; tag 1, 2 or 3 as `index`, `start` and `end`
|
||
are present); and `key_commit`: 0, or 1 and its 32 bytes;
|
||
7. the kind's code (u8); `renews`: 0, or 1 and its 32 bytes;
|
||
8. `entitlement`: 0, or 1, its length (u32) and its bytes;
|
||
9. `request_sha256`; `request_t` (u64); `proof`: 0, or 1 and its 64 bytes;
|
||
10. `issued` (u64); the decision's code (u8); `licence_expiry`: 0, or 1 and u64; `renewal`: 0,
|
||
or 1 and u32;
|
||
11. `upstream_status`: 0, or 1 and u16; `response_sha256`: 0, or 1 and its 32 bytes;
|
||
`released`: u8, 1 or 0;
|
||
12. `joules_micro` (u64), then the class in its signed encoding (energy-measurement §2.7), the
|
||
tag byte 0 for none;
|
||
13. `signer_pubkey`, then `signer_id` (string).
|
||
|
||
`sig` is Ed25519 by `signer_pubkey` over the signing payload. The receipt's **identity** is
|
||
BLAKE3 over `wai:video-keys-receipt-id` 0x01, the signing payload and `sig`: form 1's identity
|
||
domain, over a payload whose first bytes no form-1 payload has.
|
||
|
||
### 8.4 Its own rules
|
||
|
||
A form-2 receipt holds, on its own:
|
||
|
||
1. its signature verifies (`bad_key`, `bad_signature`);
|
||
2. `keys` has from 1 to 64 entries, their KIDs strictly ascending;
|
||
3. `system_id` is present when, and only when, the delivery is `drm`, and is not all zero; an
|
||
`hls-key-uri` receipt has one key;
|
||
4. every key whose `scheme` is present has a scheme its delivery carries (§3.3), or the
|
||
receipt is refused `scheme_delivery_mismatch`;
|
||
5. when `list` is `null`, so are `cpix_sha256`, `required_entitlement` and every key's
|
||
`scheme`, `period` and `key_commit`; when it is present, `required_entitlement` is too;
|
||
6. `entitlement` and `proof` are both present or both `null`, and when both are `null` the
|
||
decision is `DeniedEntitlementInvalid`; `renews` is present when, and only when, the kind is
|
||
`renewal`;
|
||
7. `released` only with the decision `Granted`, and every key's `key_commit` present when,
|
||
and only when, `released`;
|
||
8. for a `drm` delivery, `upstream_status` and `response_sha256` are present when, and only
|
||
when, the decision is `Granted`, and `released` is `true` when, and only when,
|
||
`upstream_status` is from 200 to 299; for a clear delivery both are `null`, and `released`
|
||
is `true` when the decision is `Granted`;
|
||
9. `licence_expiry` and `renewal` are present when, and only when, `released` and the kind is
|
||
not `streaming`; then `licence_expiry` is at least `issued`, and `renewal` is 0 when, and
|
||
only when, the kind is `offline`;
|
||
10. the figure and its class cohere as energy-measurement §2 has them: `joules_micro` 0 with
|
||
no class, or above 0 with a class, a hardware class's declared uncertainty with
|
||
`relative_ppm` and `window_us` above 0 and at the resolution floor (`joules_micro` ×
|
||
`relative_ppm` at least 288 676), and a calibrated instrument's reference holding a
|
||
character that is not Unicode White_Space.
|
||
|
||
A rule of 2, 3 or 5 to 10 that fails is `receipt_invalid`. Rule 8 means a refused request
|
||
was never forwarded, and a released key was released by the licence server's answer or by a
|
||
ticket, never by the proxy's word alone. These rules are what a licensor holding one receipt
|
||
checks alone; §10 adds what the lists, the authorities and the log let a verifier check.
|
||
|
||
## 9. The signing proxy
|
||
|
||
### 9.1 What it holds, and what it never holds
|
||
|
||
A proxy holds: its Ed25519 signing key; the authorities (§7.1); the commitment lists of the
|
||
content it serves; its **routes**, each a name, a `content_id`, a delivery (§3.2) and, for a
|
||
`drm` delivery, the licence server's URI, for a clear delivery the key server's; and its log.
|
||
It holds no content key and needs none: every `key_commit` it signs is copied from a list the
|
||
key holder signed. A proxy MUST refuse to load a route whose list holds a key whose scheme the
|
||
route's delivery does not carry (§3.3).
|
||
|
||
For each request with a receipt (§6.2) the proxy computes the decision (§7.2) with its clock as
|
||
`issued`, and then:
|
||
|
||
- a refusal is answered 403 with the receipt as its body (`application/json`), and the request
|
||
goes no further;
|
||
- a `Granted` request of a `drm` delivery is forwarded (§9.2);
|
||
- a `Granted` request of a clear delivery is answered with a release ticket (§9.3).
|
||
|
||
The proxy appends the receipt to its log before it answers (§9.5).
|
||
|
||
### 9.2 Wrapped delivery: forwarding
|
||
|
||
The proxy sends the request body, unchanged, to the route's licence server, with the
|
||
request's `Content-Type`, the field `WAI-Key-Ids` as received, and, for an offline licence,
|
||
`WAI-Key-Licence-Expiry`, `licence_expiry` as a decimal integer, so that a licence server
|
||
configured to read it issues no longer licence. It does not send the entitlement or the proof.
|
||
It answers the client with the licence server's status and body, unchanged, and
|
||
`WAI-Key-Receipt`. A licence server that gives no HTTP response is recorded as status 502
|
||
with the SHA-256 of no bytes, and the client is answered 502. The receipt binds the status
|
||
and the SHA-256 of that body: `released` is `true` when the status is from 200 to 299 (§8.4
|
||
rule 8). The licence carries the key encrypted for the client's decryption module, so the
|
||
proxy relays bytes it cannot open.
|
||
|
||
### 9.3 Clear delivery: the release ticket `WKT1`
|
||
|
||
The response to an `hls-key-uri` or `clear-key` request is the key itself, so the proxy does
|
||
not relay it. It answers 307 (RFC 9110 §15.4.8) with `WAI-Key-Receipt` and `Location` set to
|
||
the route's key server URI with the query parameter `wai-ticket`, the base64url of this
|
||
ticket:
|
||
|
||
| field | bytes |
|
||
|---|---|
|
||
| magic | `WKT1` |
|
||
| version | u8, 1 |
|
||
| `receipt` | 32: the identity of the receipt that released it |
|
||
| `delivery` | u8: 1 or 2 (§3.2) |
|
||
| `session_type` | u8: 1, `temporary`, for kind `streaming`; 2, `persistent-license`, otherwise (EME §9.1.1) |
|
||
| KID count | u32, then each KID (16), in ascending order |
|
||
| `expires` | u64: `issued` plus from 1 to 300 seconds |
|
||
| `proxy_pubkey` | 32: the receipt's `signer_pubkey` |
|
||
| `sig` | 64: Ed25519 by `proxy_pubkey` over `wai:video-keys-ticket` 0x01 and every byte before `sig` |
|
||
|
||
A 307 keeps the method and the body, so a Clear Key licence request reaches the key server as
|
||
it was sent, and an HLS key file request is a GET.
|
||
|
||
### 9.4 The key server
|
||
|
||
A key server holds the content keys of its content (it is the key holder's), the proxy keys it
|
||
trusts, and the identities of the tickets it has redeemed. It answers a request carrying
|
||
`wai-ticket` with the keys the ticket names only when: the ticket reads as §9.3 has it and its
|
||
signature verifies; `proxy_pubkey` is one it trusts; its clock is at most `expires`; and no
|
||
ticket with the same `receipt` was redeemed before. It answers an `hls-key-uri` ticket with the
|
||
key's octets (`application/octet-stream`, `draft-pantos-hls-rfc8216bis-22` §5.1), and a
|
||
`clear-key` ticket with a JSON Web Key Set (EME §9.1.4): `{"keys": [{"kty": "oct", "k", "kid"},
|
||
...], "type"}`, `k` and `kid` base64url and `type` the ticket's session type. Otherwise it
|
||
answers 403, or 409 for a ticket already redeemed, and releases nothing. A ticket is a
|
||
single-use bearer of one release for at most 300 seconds, carried over TLS.
|
||
|
||
### 9.5 The log
|
||
|
||
The proxy appends each receipt, as the JSON of §8.2 on one line, to its log, and makes the
|
||
append durable before it answers the request. The log is the input to checks 3 and 11 to 13
|
||
(§7.2): a proxy that restarts reads its log back before it answers again, so a replay or a
|
||
renewal is decided against every receipt it ever signed. A proxy MUST hold one log, written by
|
||
one process at a time.
|
||
|
||
### 9.6 HTTP interface
|
||
|
||
| method and path | answer |
|
||
|---|---|
|
||
| `POST /v1/licence/{route}`, `GET /v1/licence/{route}` | a licence request (§6.2) |
|
||
| `GET /v1/receipts/{identity}` | the receipt with that identity, `application/json`, or 404 |
|
||
| `GET /v1/lists` | the identity, `content_id` and key count of each list the proxy holds |
|
||
| `GET /v1/health` | the proxy's key, its route count and its log's length |
|
||
|
||
The whole log is an operator's record of sessions: a proxy serves it, as JSON lines, only on a
|
||
listener that is not public.
|
||
|
||
## 10. Verification
|
||
|
||
### 10.1 Inputs
|
||
|
||
A key-release verifier is given: the **authorities** and the **proxies**, the Ed25519 keys
|
||
trusted to sign entitlements and receipts, and the **attesters**, the keys trusted to sign
|
||
output statements of class 2 (§14.2); the **lists**; for some lists, the **CPIX
|
||
document** each is derived from; the **log**, a proxy's receipts in the order it signed them,
|
||
from its first; and **tickets**, each with the receipt it names.
|
||
|
||
### 10.2 Steps
|
||
|
||
1. Each list: §4.3 (`list_malformed`, `list_bad_signature`); two lists for one `content_id`
|
||
are refused `list_malformed`.
|
||
2. Each CPIX document with its list: §4.4 (`cpix_malformed`, `cpix_unsupported`,
|
||
`cpix_mismatch`).
|
||
3. Each receipt, in the log's order:
|
||
1. it reads as §8.2 (`receipt_malformed`);
|
||
2. its own rules (§8.4: `bad_key`, `bad_signature`, `receipt_invalid`,
|
||
`scheme_delivery_mismatch`);
|
||
3. its `signer_pubkey` is one of the proxies (`signer_unknown`);
|
||
4. its identity is not one an earlier receipt has (`receipt_duplicate`);
|
||
5. its `list`, when present, is the identity of a list given (`list_unknown`) whose
|
||
`content_id` is the receipt's; when `null`, no list given has the receipt's
|
||
`content_id`; `cpix_sha256` and `required_entitlement` are the list's; each key in the
|
||
list has the list's scheme and period, each key not in it has `null` ones, and each
|
||
present `key_commit` is the list's (`list_mismatch`);
|
||
6. the decision is the one §7.2 computes from the receipt's fields, the authorities, the
|
||
list and the receipts before it, and when `released` and the kind is not `streaming`,
|
||
`licence_expiry` and `renewal` are the ones §7.3 computes (`decision_mismatch`).
|
||
4. Each ticket: it reads as §9.3 and its signature verifies; its `receipt` is the identity of
|
||
a receipt of the log that is released, of a clear delivery, whose `delivery`, kind and
|
||
KIDs the ticket's `delivery`, `session_type` and KIDs follow, whose `signer_pubkey` is
|
||
`proxy_pubkey`, and whose `issued` is from 1 to 300 seconds below `expires`
|
||
(`ticket_invalid`).
|
||
|
||
The first step that fails refuses the log, with its reason and its place: `["list", i]`,
|
||
`["cpix", i]`, `["receipt", i]` or `["ticket", i]`.
|
||
|
||
### 10.3 What a verification states
|
||
|
||
An accepted log states, for each receipt: its identity, decision and `released`; for a
|
||
released one, each key's KID and `key_commit`, and `licence_expiry` and `renewal` when present.
|
||
A licensor holding a content key confirms that a release named it by computing the key's
|
||
commitment (§4.1), as form 1 lets it.
|
||
|
||
## 11. Reasons
|
||
|
||
The codes this extension adds, each with where it is raised:
|
||
|
||
| reason | raised at |
|
||
|---|---|
|
||
| `receipt_malformed` | §8.2, §10.2 step 3.1 |
|
||
| `bad_key` | §8.4 rule 1 |
|
||
| `bad_signature` | §8.4 rule 1 |
|
||
| `receipt_invalid` | §8.4 |
|
||
| `scheme_delivery_mismatch` | §3.3, §8.4 rule 4 |
|
||
| `signer_unknown` | §10.2 step 3.3 |
|
||
| `receipt_duplicate` | §10.2 step 3.4 |
|
||
| `list_malformed` | §4.3, §10.2 step 1 |
|
||
| `list_bad_signature` | §4.3 |
|
||
| `list_unknown` | §10.2 step 3.5 |
|
||
| `list_mismatch` | §10.2 step 3.5 |
|
||
| `cpix_malformed` | §4.4 |
|
||
| `cpix_unsupported` | §4.4 |
|
||
| `cpix_mismatch` | §4.4 |
|
||
| `decision_mismatch` | §10.2 step 3.6 |
|
||
| `ticket_invalid` | §10.2 step 4 |
|
||
|
||
A refused entitlement, a stale or replayed request and the other outcomes of §7.2 are not
|
||
reasons: each is a decision, signed in a valid receipt.
|
||
|
||
## 12. Conformance
|
||
|
||
The corpus is [`live-conformance/keys/`](../live-conformance/keys/): logs of a proxy over
|
||
CPIX-derived lists, each with the outcome its verification reaches (§10). It gives releases
|
||
over each delivery; refusals of each decision of §7.2; a list with a release time, refusing
|
||
before it under every entitlement and releasing from it; an expired entitlement; key rotation
|
||
over three periods; offline licences, their renewals and their lapse; a licence server that
|
||
refused a granted request; a limit on sessions streaming at once and an output level required
|
||
(§14), with the undecided cases; and negative controls that each verification refuses with a
|
||
reason of §11. Its second implementation, `live-conformance/key_release_verify.py`, is written from
|
||
this text and shares no code with `wai-rs`.
|
||
|
||
A conforming proxy computes every decision §7.2 computes, signs every receipt §8 describes and
|
||
none it does not, forwards no refused request, relays no clear key (§9.3), and holds its log as
|
||
§9.5 has it. A conforming verifier reaches the outcome, reason and place the corpus states for
|
||
every log.
|
||
|
||
## 13. What a receipt does not prove
|
||
|
||
- **A licence's contents.** For a `drm` delivery the receipt binds the SHA-256 of the licence
|
||
the licence server returned, which the proxy cannot open. A licence server that puts in a
|
||
licence a key the request did not name, or a longer duration than `licence_expiry`, is not
|
||
caught by the receipt: the proxy forwards `WAI-Key-Ids` and `WAI-Key-Licence-Expiry` so that
|
||
a licence server can be configured to follow them.
|
||
- **The kind of licence.** The kind is the device's signed statement (§6.1). A licence
|
||
challenge asking for a persistent licence under a `streaming` request is the device's
|
||
misstatement, and an operator that issues persistent licences only through routes whose
|
||
licence server issues nothing else closes it.
|
||
- **The clock.** `issued` is the proxy's clock and `request_t` the device's. Check 2 bounds
|
||
their difference; neither is a trusted time.
|
||
- **The redemption.** A released clear-delivery receipt records that a ticket was issued. The
|
||
key server's record of what it redeemed is its own.
|
||
- **That the list is right.** The proxy copies its commitments from the key holder's list, and
|
||
a verifier with the CPIX document checks that list (§4.4). Without the document, the list is
|
||
the key holder's signed statement.
|
||
- **That a release time is the latest.** A release time binds the list it is in, and a receipt
|
||
is held to the list it names (§10.2 step 3.5). No list supersedes another: an earlier signed
|
||
list of the content stays a list, a proxy that loaded it decides by it, and its log verifies
|
||
against it. Two lists of one `content_id` are refused only when both are given to one
|
||
verification (§10.2 step 1).
|
||
|
||
## 14. Entitlement rules
|
||
|
||
An entitlement authority may put two rules in an entitlement: a limit on how many sessions of a
|
||
**group** stream at once, and the least **output level** the device must have. Both are decided
|
||
by the proxy from signed records alone, a verifier recomputes both, and a request the records
|
||
cannot decide is signed as **undecided**: it is not released, and it is not stated as refused
|
||
for a cause nobody established. An entitlement that states no rule is of version 1 and is
|
||
decided, and receipted, exactly as §5 to §10 have it.
|
||
|
||
### 14.1 The entitlement, version 2
|
||
|
||
An entitlement that states rules has version 2, and between `renewals` and `authority_pubkey`
|
||
these fields:
|
||
|
||
| field | bytes |
|
||
|---|---|
|
||
| `group` | 16: the sessions that share the limit, as the authority names them. It is compared byte for byte among the entitlements of one `authority_pubkey`, and says nothing else |
|
||
| `streams` | u32: how many sessions of the group may stream at once; 0 for no limit |
|
||
| `window_s` | u32: how long a release counts as a session streaming, in seconds, from 1 to 86 400; 0 when `streams` is 0 |
|
||
| `output_min` | u8: the least output level (§14.2), from 1 to 9; 0 for none required |
|
||
| `output_class` | u8: the least class of output statement it accepts (§14.2), 1 or 2; 0 when `output_min` is 0 |
|
||
|
||
It holds when §5.2 holds, these fields are in their ranges, and it states a rule: `streams` or
|
||
`output_min` is above 0. An authority writes version 2 exactly for an entitlement that states a
|
||
rule, so every entitlement of version 1 keeps its bytes and its digest. An entitlement **states
|
||
rules** when its first five bytes are `WKE1` and 2, whether or not it holds.
|
||
|
||
### 14.2 The output statement `WKO1`
|
||
|
||
An output level is one of the nine values of the W3C Encrypted Media Extensions HDCP Version
|
||
Registry, in the registry's order: code 1 is `1.0`, then `1.1`, `1.2`, `1.3`, `1.4`, `2.0`,
|
||
`2.1`, `2.2`, and code 9 is `2.3`. A higher code meets a requirement of a lower one. An **output
|
||
statement** says that the outputs of one device are protected at a level:
|
||
|
||
| field | bytes |
|
||
|---|---|
|
||
| magic | `WKO1` |
|
||
| version | u8, 1 |
|
||
| `holder` | 32: the device's holder key |
|
||
| `level` | u8, from 1 to 9 |
|
||
| `class` | u8: 1, **declared**: the device states it of itself; 2, **attested**: a party the proxy trusts for it, an **attester**, states it of the device |
|
||
| `not_before`, `not_after` | u64 each, seconds: the statement stands from the first to the second, both included |
|
||
| `signer_pubkey` | 32: for class 1 the `holder` itself; for class 2 the attester's Ed25519 key |
|
||
| `sig` | 64: Ed25519 by `signer_pubkey` over `wai:video-keys-output` 0x01 and every byte before `sig` |
|
||
|
||
It holds when its magic, version and ranges are these, `not_before` is at most `not_after`, a
|
||
statement of class 1 is signed by its `holder`, no byte follows `sig`, and its signature
|
||
verifies. The class is the whole of what the record says about how the level was learned: a
|
||
statement of class 1 is the device's word, and one of class 2 is as good as the proxy's trust in
|
||
the attester that signed it. How a device or an attester reads the level is theirs. A browser
|
||
may ask its key system with `getStatusForPolicy()` and a `minHdcpVersion`, which answers only
|
||
whether the key system would allow presentation at that version; this revision's reference and
|
||
tests read no platform's signal, and nothing here states that a statement reflects one.
|
||
|
||
### 14.3 The request
|
||
|
||
A request under an entitlement that states rules may present one output statement: the field
|
||
`WAI-Key-Output`, its bytes in base64url. The proof (§6.1) then covers one more item, after
|
||
`request_t`: 0 for no statement, or 1 and the SHA-256 of the statement's bytes. A field that
|
||
does not read as base64url is no statement. Under an entitlement of version 1 the field is not
|
||
read and the proof is §6.1's.
|
||
|
||
### 14.4 The checks
|
||
|
||
A request that §7.2's checks 1 to 13 would grant, under an entitlement that states rules, is
|
||
decided by the first of these that applies. A statement **establishes** its level when it
|
||
holds, its `holder` is the entitlement's, `issued` is from its `not_before` to its `not_after`,
|
||
its class is at least `output_class`, and, for class 2, its `signer_pubkey` is one of the
|
||
proxy's attesters.
|
||
|
||
| check | decision | code | when |
|
||
|---|---|---|---|
|
||
| 14 | `UndecidedOutput` | 15 | `output_min` is above 0, and the request presents no statement that establishes a level |
|
||
| 15 | `DeniedOutputUnmet` | 16 | `output_min` is above 0, and the level established is below it |
|
||
| 16 | `UndecidedStreams` | 17 | the kind is `streaming`, `streams` is above 0, and a counted release (below) carries an entitlement whose `streams` or `window_s` is not this one's |
|
||
| 17 | `DeniedStreamLimit` | 18 | the kind is `streaming`, `streams` is above 0, and the other sessions counted are `streams` or more |
|
||
|
||
The **counted releases** of a request are the receipts earlier in the log that are released,
|
||
of kind `streaming`, whose `issued` differs from this decision's by less than `window_s`, and
|
||
whose entitlement has rules, this entitlement's `authority_pubkey` and `group`, and another
|
||
`session`. The **other sessions counted** are the distinct `session`s among them, and the
|
||
decision's **leases** are, for each of those sessions, the identity of its last counted
|
||
release, in the log's order. A decision that reaches check 16 names its leases in its receipt
|
||
(§14.5), whether it grants, refuses or is undecided; any other names none.
|
||
|
||
So a session holds a place in the limit for `window_s` after each release to it, and keeps it
|
||
by asking again before then: for the key of the next key period (§7.4), or for the same keys. A
|
||
session that stops asking gives its place up when its last release is `window_s` old; this
|
||
revision has no record by which it gives it up sooner. A request of the session itself is never
|
||
counted against it, and an offline licence or a renewal is no stream and is neither counted nor
|
||
limited. Two entitlements of one group that state different limits or windows give no one limit
|
||
to count against: the request is undecided, and the authority that issued them is the one to
|
||
put it right.
|
||
|
||
Checks 14 and 15 apply to every kind of request. The four decisions release nothing; a proxy
|
||
answers each 403 with its receipt, as it answers a refusal (§9.1).
|
||
|
||
### 14.5 The receipt, form 3
|
||
|
||
The receipt of a request whose entitlement states rules has **form 3**: `form` is 3, and it has
|
||
form 2's members and two more, after `released`:
|
||
|
||
| member | value |
|
||
|---|---|
|
||
| `output` | the output statement the request presented, its bytes in hexadecimal, at most 1 024; or `null` |
|
||
| `leases` | an array of at most 4 096 receipt identities, each 32 bytes, distinct: the decision's leases (§14.4) |
|
||
|
||
Its signing payload is §8.3's with `wai:video-keys-proxy-receipt` and 0x02 as item 1, and,
|
||
between items 11 and 12: `output`, 0, or 1, its length (u32) and its bytes; then the leases'
|
||
count (u32) and each identity. Its identity is §8.3's. The rules of §8.4 hold for it, and:
|
||
a receipt has form 3 when, and only when, its `entitlement` is present and states rules
|
||
(§14.1); a decision of §14.4 is carried by form 3 alone; and `leases` is empty unless the kind
|
||
is `streaming` and the decision is `Granted`, `UndecidedStreams` or `DeniedStreamLimit`. One
|
||
that fails these is `receipt_invalid`. Every receipt of form 2 keeps its bytes, and a reader of
|
||
form 2 alone refuses a form-3 receipt as `receipt_malformed`, which is what it is to that reader.
|
||
|
||
A release ticket (§9.3) follows a released form-3 receipt as it follows one of form 2.
|
||
|
||
### 14.6 Verification
|
||
|
||
A proxy holds its attesters as it holds its authorities (§9.1), and a verifier is given them
|
||
(§10.1). Step 3.6 of §10.2 computes the decision
|
||
from §7.2 and §14.4, with the request's statement and the log before the receipt, and requires
|
||
the receipt's decision and, for form 3, its `leases` to be the ones computed
|
||
(`decision_mismatch`). An accepted log states, for a receipt of form 3, its leases beside what
|
||
§10.3 lists. So a release past the limit, a release with the level unmet or not established, an
|
||
undecided request restated as refused, and a count that leaves out a session are each refused,
|
||
and the corpus plants each.
|
||
|
||
### 14.7 What the rules do not prove
|
||
|
||
- **That a session stopped.** A session's place lapses with time alone. A viewer who stops is
|
||
counted until the window ends, and a window shorter than a key period lets a session through
|
||
between two requests: an operator sets `window_s` at or above the interval at which its
|
||
players ask.
|
||
- **One log.** The count is over one proxy's log (§9.5). Two proxies with two logs count apart.
|
||
- **The output.** A statement is a signed claim of its class. Neither class states that a
|
||
platform's output-protection signal was read; none was read or tested for this revision, and
|
||
a licence a content protection system issues enforces its own output rules, which the proxy
|
||
does not see (§13).
|
||
- **Who is watching.** `group` and `session` are the authority's names for sets of releases.
|
||
Nothing here says whose they are.
|
||
|
||
## 15. The key provider
|
||
|
||
A packager that encrypts needs content keys, and the party that holds them is the key holder
|
||
(§2). The **key provider** is the key holder's service a packager asks: CPIX documents over
|
||
HTTP, in the workflow the CPIX guidelines call the encryptor consumer, where the keys are made
|
||
by the party the encryptor pulls them from. It is neutral: its routes carry CPIX documents and
|
||
signed records, name no content protection system, and a provider of any operator answers them.
|
||
What it adds to an exchange of documents is two signed records: the commitment list of the
|
||
keys it answered with (§4), which a signing proxy loads, and a receipt of the exchange.
|
||
|
||
### 15.1 What it holds
|
||
|
||
A provider holds: its Ed25519 signing key, which is the key holder's and signs the lists; the
|
||
**requesters**, the Ed25519 keys of the packagers it serves; the **contents** it serves, each a
|
||
`content_id` with the `required_entitlement` its lists state and, where the content has one,
|
||
its release time (§4.1); its keys, or the means to make them; and its log of receipts.
|
||
|
||
### 15.2 The request
|
||
|
||
A request is a CPIX document of at most 1 048 576 bytes, read by §4.4's steps 1 to 5 with one
|
||
change: a `ContentKey` has no `Data` child. A request asks for keys and carries none, and one
|
||
that carries a key is `cpix_unsupported`. It names the content (`@contentId`) and, for each key,
|
||
its KID, its scheme and, by a usage rule, its key period: the requester chooses the KIDs and the
|
||
periods, and the provider the octets. It is sent with:
|
||
|
||
| field | value |
|
||
|---|---|
|
||
| `WAI-Provider-Requester` | the requester's Ed25519 key, 64 lowercase hexadecimal digits |
|
||
| `WAI-Provider-Time` | `request_t`, the requester's clock in seconds, a decimal integer |
|
||
| `WAI-Provider-Proof` | 64 bytes, base64url: Ed25519 by the requester over `wai:key-provider-request` 0x01, the SHA-256 of the request's bytes, and `request_t` (u64) |
|
||
|
||
A request without a requester, a time and a proof that read names nothing to decide: it is
|
||
answered 400 and has no receipt.
|
||
|
||
### 15.3 The decision and the answer
|
||
|
||
The decision is the first of these that applies, with `issued` the provider's clock:
|
||
|
||
| check | decision | code | when |
|
||
|---|---|---|---|
|
||
| 1 | `DeniedRequesterUnknown` | 2 | the requester is not one the provider serves |
|
||
| 2 | `DeniedProofInvalid` | 3 | the proof does not verify under the requester over the request and `request_t` |
|
||
| 3 | `DeniedStale` | 4 | `issued` and `request_t` differ by more than 300 |
|
||
| 4 | `DeniedReplayed` | 5 | a receipt earlier in the provider's log carries the same proof |
|
||
| 5 | `DeniedRequestMalformed` | 6 | the request is refused `cpix_malformed`, or is longer than the limit |
|
||
| 6 | `DeniedRequestUnsupported` | 7 | the request is refused `cpix_unsupported` |
|
||
| 7 | `DeniedContentUnknown` | 8 | the provider does not serve the request's content |
|
||
| — | `Provided` | 1 | none of these applies |
|
||
|
||
A provision is answered 200 with the **answer**, a CPIX document (`application/xml`) that holds
|
||
the request's content, and for each of its keys, in ascending KID order, the KID, the scheme,
|
||
the key's octets in a `PlainValue`, and the key's period where the request named one. The
|
||
provider signs the commitment list §4.4 derives from the answer's exact bytes, with its
|
||
content's `required_entitlement` and release time, and `issued` as the list's. A refusal is
|
||
answered 403 with the receipt as its body; nothing is provided. Either answer names the
|
||
receipt in `WAI-Provider-Receipt`, and a provision the list in `WAI-Provider-List`, each an
|
||
identity in 64 lowercase hexadecimal digits. The provider appends the receipt to its log, and
|
||
makes the append durable, before it answers.
|
||
|
||
The answer carries keys in the clear, which CPIX allows over a protected channel and does not
|
||
recommend otherwise: the exchange is carried over TLS between two parties that hold each
|
||
other's keys. CPIX's encrypted delivery (`DeliveryData`, CPIX §8.1) is not written by this
|
||
revision, as §4.4 does not read it.
|
||
|
||
The reference provider derives a key's octets from a seed: the first octets, as many as the
|
||
scheme takes, of the SHA-256 of `wai:key-provider-test-key` 0x01, the seed (32), the
|
||
`content_id` (string) and the KID. It is a provider of test keys: the derivation shows the API
|
||
end to end and is no statement about how a provider in service makes or holds keys.
|
||
|
||
### 15.4 The receipt
|
||
|
||
A provider receipt is one JSON object with exactly these members; a byte field is lowercase
|
||
hexadecimal, and an absent value `null`:
|
||
|
||
| member | value |
|
||
|---|---|
|
||
| `kind` | `wai-key-provider-receipt` |
|
||
| `version` | 1 |
|
||
| `content_id` | the request's content, or `null` when the request does not read |
|
||
| `requester` | 32 bytes |
|
||
| `request_sha256` | 32 bytes: the SHA-256 of the request's bytes |
|
||
| `request_t`, `issued` | integers |
|
||
| `proof` | 64 bytes |
|
||
| `decision` | its label (§15.3) |
|
||
| `kids` | the KIDs provided, 16 bytes each, ascending; empty for a refusal |
|
||
| `response_sha256` | 32 bytes: the SHA-256 of the answer; `null` for a refusal |
|
||
| `list` | 32 bytes: the identity of the list derived from the answer; `null` for a refusal |
|
||
| `signer_pubkey` | 32 bytes: the provider's key |
|
||
| `signer_id` | a string |
|
||
| `sig` | 64 bytes |
|
||
|
||
`sig` is Ed25519 by `signer_pubkey` over: `wai:key-provider-receipt` 0x01; `content_id`, 0 or 1
|
||
and the string; `requester`; `request_sha256`; `request_t` (u64); `proof`; `issued` (u64); the
|
||
decision's code (u8); the KIDs' count (u32) and each KID; `response_sha256` and `list`, each 0
|
||
or 1 and its bytes; `signer_pubkey`; `signer_id` (string). Its identity is BLAKE3 over
|
||
`wai:key-provider-receipt-id` 0x01, those bytes and `sig`. A receipt holds, on its own, when
|
||
`response_sha256`, `list` and at least one KID are present for a provision and absent for a
|
||
refusal, the KIDs ascend, and a provision names its content.
|
||
|
||
### 15.5 Verification
|
||
|
||
A verifier of an exchange is given the provider keys it trusts, the requesters and the contents
|
||
the provider serves, the request, the receipt, whether the provider's log already held the
|
||
receipt's proof, and, for a provision, the answer and the list. It refuses the exchange at the
|
||
first of these that fails:
|
||
|
||
| step | reason | what is required |
|
||
|---|---|---|
|
||
| 1 | `provider_receipt_malformed` | the receipt reads as §15.4 |
|
||
| 2 | `provider_receipt_invalid` | it holds on its own |
|
||
| 3 | `provider_bad_signature` | its signature verifies |
|
||
| 4 | `provider_unknown` | its `signer_pubkey` is a provider the verifier trusts |
|
||
| 5 | `provider_request_mismatch` | `request_sha256` is the request's |
|
||
| 6 | `provider_decision_mismatch` | its decision and `content_id` are the ones §15.3 computes |
|
||
| 7 | `provider_response_mismatch` | a refusal comes with no answer and no list; a provision with both, `response_sha256` the answer's, and `kids` the request's KIDs |
|
||
| 8 | `list_malformed`, `list_bad_signature` | the list holds (§4.3) |
|
||
| 9 | `provider_list_mismatch` | the list's identity is `list`, and its signer the receipt's |
|
||
| 10 | `cpix_malformed`, `cpix_unsupported`, `cpix_mismatch` | the answer derives the list (§4.4) |
|
||
| 11 | `provider_response_mismatch` | the answer's content, KIDs, schemes and periods are the request's |
|
||
|
||
An accepted exchange states the receipt's identity and decision and, for a provision, each
|
||
key's KID, commitment and period as the list states them. So an answer whose keys are not the
|
||
ones the list commits to is refused `cpix_mismatch`, whoever signed what; and a packager that
|
||
holds the receipt, the answer and the list has a list a signing proxy loads (§9.1) and a
|
||
verifier of that proxy's log is given (§10.1).
|
||
|
||
### 15.6 HTTP interface
|
||
|
||
| method and path | answer |
|
||
|---|---|
|
||
| `POST /v1/provider/keys` | a request (§15.2) |
|
||
| `GET /v1/provider/lists/{identity}` | the list with that identity, `application/octet-stream`, or 404 |
|
||
| `GET /v1/provider/receipts/{identity}` | the receipt with that identity, `application/json`, or 404 |
|
||
| `GET /v1/provider/health` | the provider's key, and how many contents, requesters and receipts it holds |
|
||
|
||
### 15.7 What is not done
|
||
|
||
A content protection system's own key exchange and licence exchange are not this API and are
|
||
not tested here: a `drm` route's licence server is configured with its keys as its system has
|
||
it, and nothing in this section states that one accepts these documents. The provider signs
|
||
no `DRMSystem` signalling. The reference provider is `wai-key-release provider`; the corpus is
|
||
`live-conformance/keys/provider.json`, and `key_release_verify.py` verifies every exchange of
|
||
it and derives every key of every answer again from the request and the seed.
|
||
|