The wire identifiers of extensions/key-release.md (WKL1, WKE1, WKT1 and the WAI-Key-* header fields) in the registry table, and the export regenerated; the wai.video.keys row of SPEC §5 names its proxy form, and a §5 paragraph describes it; §6.2 states how a request missing a field is read. CI: the key-release step in the independent verifiers job (the module's tests, form 1's, tests/key_release.rs and key_release_verify.py), the extension under the workflow's paths, and the wai-key-proxy workflow (tests, clippy, the release binary end to end). Co-Authored-By: Claude Opus 5.5 <[email protected]>
654 lines
38 KiB
Markdown
654 lines
38 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. 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, 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. 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. 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 (§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); and a device limit, which is
|
||
a separate rule.
|
||
|
||
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 |
|
||
| `content_id` | string, not empty |
|
||
| `required_entitlement` | string, not empty: the entitlement class the content demands (§7.2 check 5) |
|
||
| `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 |
|
||
| 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 |
|
||
|
||
`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 version; 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` 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).
|
||
|
||
## 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 | `DeniedNoEntitlement` | 2 | the entitlement's `entitlement` is not the list's `required_entitlement` |
|
||
| 6 | `DeniedNotYetValid` | 8 | `issued` is below `not_before` |
|
||
| 7 | `DeniedExpired` | 3 | `issued` is above `not_after` |
|
||
| 8 | `DeniedPeriodOutside` | 9 | a requested key's period has `start` and `end`, and the period does not meet the entitlement: `start` above `not_after`, or `end` at most `not_before` |
|
||
| 9 | `DeniedOfflineNotEntitled` | 10 | the kind is `offline` or `renewal`, and `offline_s` is 0 |
|
||
| 10 | `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` |
|
||
| 11 | `DeniedRenewalsExhausted` | 12 | the kind is `renewal`, and that receipt's `renewal` plus 1 is above `renewals` |
|
||
| 12 | `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.
|
||
|
||
### 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 8 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 8 does not apply to it.
|
||
|
||
## 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 10 to 12
|
||
(§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; 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; an expired entitlement; key rotation
|
||
over three periods; offline licences, their renewals and their lapse; a licence server that
|
||
refused a granted request; 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.
|