diff --git a/Cargo.lock b/Cargo.lock index e158896..ccaf6e6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,17 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "aes" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8eb277bec05f56a0e0591f155a484cbd0f4f07ff2905051a48c72f004f7ed58" +dependencies = [ + "cipher", + "cpubits", + "cpufeatures", +] + [[package]] name = "aho-corasick" version = "1.1.5" @@ -187,6 +198,16 @@ dependencies = [ "rand_core 0.10.1", ] +[[package]] +name = "cipher" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8cf2a2c93cd704877c0858356ed03480ff301ee950b43f1cbe4573b088bfa6c" +dependencies = [ + "crypto-common", + "inout", +] + [[package]] name = "cmake" version = "0.1.58" @@ -228,6 +249,12 @@ version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" +[[package]] +name = "cpubits" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15b85f9c39137c3a891689859392b1bd49812121d0d61c9caf00d46ed5ce06ae" + [[package]] name = "cpufeatures" version = "0.3.1" @@ -674,6 +701,15 @@ dependencies = [ "hashbrown", ] +[[package]] +name = "inout" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4250ce6452e92010fdf7268ccc5d14faa80bb12fc741938534c58f16804e03c7" +dependencies = [ + "hybrid-array", +] + [[package]] name = "ipnet" version = "2.12.2" @@ -1369,7 +1405,9 @@ dependencies = [ name = "segmentor" version = "0.1.0" dependencies = [ + "aes", "axum", + "base64", "bytes", "hyper-util", "mp4", diff --git a/Cargo.toml b/Cargo.toml index 9383be7..942d418 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -16,7 +16,9 @@ categories = ["multimedia::video", "web-programming::http-server"] exclude = ["/.github", "/docs", "/demo", "/fuzz", "/tests/fixtures", "/Dockerfile", "/.dockerignore", "/book.toml", "/Makefile"] [dependencies] +aes = "0.9" axum = "0.8" +base64 = "0.23" bytes = "1" hyper-util = { version = "0.1", features = ["http1", "http2", "server-auto", "server-graceful", "service", "tokio"] } reqwest = { version = "0.13.5", default-features = false, features = ["rustls", "http2"] } diff --git a/README.md b/README.md index d84d1f1..6410f93 100644 --- a/README.md +++ b/README.md @@ -39,8 +39,10 @@ proportional to the segment asked for, not to the length of the video. - **No transcoding.** The codecs must already suit the protocol, and there is no bitrate ladder unless you supply the renditions ([mapper API](docs/mapper-api.md#renditions)). Trimming and joining files is done on keyframes, not by re-encoding ([clips](docs/mapper-api.md#clips)). -- **No DRM, no live streaming, no MPEG-TS output.** Segments are fragmented MP4. DRM is planned - after the features above. +- **No live streaming, no MPEG-TS output.** Segments are fragmented MP4. DRM is supported through + `cbcs` encryption with Widevine, FairPlay, PlayReady, and Clear Key signalling + ([mapper API](docs/mapper-api.md#encryption)); video encryption is H.264 for now, with HEVC + next. - **No authentication.** Run it behind a reverse proxy or CDN that provides it; see [operations](docs/operations.md). TLS itself is optional but built in ([`[server.tls]`](docs/operations.md#tls)) for the case of running with no proxy in front. @@ -51,7 +53,7 @@ proportional to the segment asked for, not to the length of the video. | --- | --- | | Convert files to HLS/DASH once and host static files | FFmpeg, [Shaka Packager](https://github.com/shaka-project/shaka-packager), or Bento4 | | Serve HLS/DASH without storing packaged output, as a standalone service, with strict resource limits | **segmentor** | -| DRM, live streaming, or MPEG-TS output today | Shaka Packager | +| Live streaming or MPEG-TS output today | Shaka Packager | | A mature on-demand packager that runs inside nginx | Kaltura's [nginx-vod-module](https://github.com/kaltura/nginx-vod-module) (AGPL-3.0) | segmentor takes its idea, packaging on demand instead of ahead of time, from nginx-vod-module but diff --git a/admin/index.html b/admin/index.html index 257cc11..b17c516 100644 --- a/admin/index.html +++ b/admin/index.html @@ -68,10 +68,10 @@

segmentor control panel

Player

-
@@ -163,7 +163,7 @@

segmentor control panel

if (!diagnose()) log('cannot start, see warnings under the player'); $('log').innerHTML = ''; markCurrentRow(); - const asset = encodeURIComponent($('asset').value); + const asset = encodeURIComponent(assetId()); if ($('proto').value === 'hls') { const url = `${base()}/hls/${asset}/master.m3u8`; log('HLS ' + url); @@ -197,28 +197,45 @@

segmentor control panel

} /// Plays a row's asset id when a cache or catalog row is clicked, so browsing needs no typing. -function playAsset(assetId) { - $('asset').value = assetId; +function playAsset(id) { + selectAsset(id); load(); } function markCurrentRow() { - const current = $('asset').value; + const current = assetId(); for (const row of document.querySelectorAll('#cache tbody tr')) row.classList.toggle('current', row.dataset.asset === current); - // The picker only ever shows a known asset; typing a custom name falls back to its - // placeholder rather than pretending one of the listed assets is selected. - const picker = $('asset-picker'); - picker.value = [...picker.options].some(o => o.value === current) ? current : ''; } -/// The dropdown next to the Asset field: every asset ID the cache table lists. Picking one plays -/// it; typing a name that isn't listed (a mapper asset nobody has requested yet, for example) -/// keeps working in the text field regardless, since the picker is a convenience, not a gate. -function renderAssetPicker(ids) { - $('asset-picker').innerHTML = '' - + ids.map(id => ``).join(''); - markCurrentRow(); +// ---------- asset dropdown ---------- +// Every asset the server can name (its static catalog, or a mapper's optional listing) plus +// whatever it has loaded, then "Custom…" for any other name. Asset IDs are never empty, so the +// empty value marks "Custom…" without colliding with one. +const assetId = () => $('asset-pick').value || $('asset').value.trim(); + +function showCustomField() { + $('asset-custom').hidden = $('asset-pick').value !== ''; +} + +/// Rebuilds the options from `ids`, keeping the current choice: a selected asset stays selected +/// even when the list no longer names it, and "Custom…" stays chosen with its text intact. +function renderAssetOptions(ids) { + const pick = $('asset-pick'), selected = pick.value; + const all = [...new Set(selected ? [...ids, selected] : ids)].sort(); + pick.innerHTML = all.map(id => ``).join('') + + ''; + pick.value = selected; + showCustomField(); +} + +/// Selects `id`, adding it to the dropdown if it is not listed yet. +function selectAsset(id) { + const pick = $('asset-pick'); + if (![...pick.options].some(option => option.value === id)) + pick.add(new Option(id, id), pick.options.length - 1); + pick.value = id; + showCustomField(); } function renderPlayerStats() { @@ -349,7 +366,8 @@

segmentor control panel

: ''; for (const row of $('cache').tBodies[0].rows) row.onclick = () => playAsset(row.dataset.asset); - renderAssetPicker([...rows.keys()].sort()); + renderAssetOptions([...rows.keys()]); + markCurrentRow(); } function escapeHtml(s) { @@ -406,7 +424,11 @@

segmentor control panel

$('load').onclick = load; $('asset').addEventListener('input', markCurrentRow); -$('asset-picker').addEventListener('change', () => { if ($('asset-picker').value) playAsset($('asset-picker').value); }); +$('asset').addEventListener('keydown', event => { if (event.key === 'Enter') load(); }); +$('asset-pick').addEventListener('change', () => { + showCustomField(); + if ($('asset-pick').value) load(); else $('asset').focus(); +}); load(); clearTimeout(pollTimer); poll(); diff --git a/demo/index.html b/demo/index.html index f9f1801..ea60256 100644 --- a/demo/index.html +++ b/demo/index.html @@ -31,16 +31,16 @@ #log { height:150px; overflow:auto; font:11px/1.5 ui-monospace,monospace; color:var(--dim); } #status { font-size:12px; color:var(--dim); } .ok { color:var(--ok); } .bad { color:var(--bad); } .warn { color:var(--warn); } - .chip { background:var(--panel); border:1px solid var(--line); border-radius:99px; padding:2px 10px; color:var(--fg); cursor:pointer; font:inherit; font-size:12px; } - .chip:hover { border-color:var(--acc); color:var(--acc); } - .chip.current { border-color:var(--acc); color:var(--acc); background:rgba(76,194,255,.12); }

segmentor demo

- + + @@ -48,7 +48,6 @@

segmentor demo

-
@@ -106,23 +105,45 @@

Resolver & cache

const tile = (k, v, cls='') => `
${k}
${v}
`; const escapeHtml = s => String(s).replace(/[&<>"']/g, c => ({ '&':'&','<':'<','>':'>','"':'"',"'":''' }[c])); -// ---------- assets to try ---------- -// A quick way in besides typing a name: whatever the static catalog lists (if any) plus whatever -// has already been played (any resolver). /admin/status has the same trust model as /metrics, -// which this page already reads, so nothing new is exposed by also reading this. -async function refreshTryRow() { +// ---------- asset dropdown ---------- +// Every asset the server can name (its static catalog, or a mapper's optional listing) plus +// whatever it has loaded, then "Custom…" for any other name. Asset IDs are never empty, so the +// empty value marks "Custom…" without colliding with one. +const assetId = () => $('asset-pick').value || $('asset').value.trim(); + +function showCustomField() { + $('asset-custom').hidden = $('asset-pick').value !== ''; +} + +/// Rebuilds the options from `ids`, keeping the current choice: a selected asset stays selected +/// even when the list no longer names it, and "Custom…" stays chosen with its text intact. +function renderAssetOptions(ids) { + const pick = $('asset-pick'), selected = pick.value; + const all = [...new Set(selected ? [...ids, selected] : ids)].sort(); + pick.innerHTML = all.map(id => ``).join('') + + ''; + pick.value = selected; + showCustomField(); +} + +/// Selects `id`, adding it to the dropdown if it is not listed yet. +function selectAsset(id) { + const pick = $('asset-pick'); + if (![...pick.options].some(option => option.value === id)) + pick.add(new Option(id, id), pick.options.length - 1); + pick.value = id; + showCustomField(); +} + +// /admin/status has the same trust model as /metrics, which this page already reads, so nothing +// new is exposed by also reading it. +async function refreshAssets() { try { const status = await (await fetch(base() + '/admin/status')).json(); - const ids = [...new Set([...(status.resolver.known_assets || []), ...status.cache.assets.map(a => a.asset_id)])].sort(); - const row = $('try-row'); - if (!ids.length) { row.textContent = ''; return; } - const current = $('asset').value; - row.innerHTML = 'Try: ' + ids.map(id => - ``).join(' '); - for (const chip of row.querySelectorAll('.chip')) chip.onclick = () => { $('asset').value = chip.dataset.asset; load(); }; - } catch { /* the try row is a convenience; leave it as it was if the origin is unreachable */ } + renderAssetOptions([...(status.resolver.known_assets || []), ...status.cache.assets.map(a => a.asset_id)]); + } catch { /* the list is a convenience; keep it as it was if the origin is unreachable */ } } -setInterval(refreshTryRow, 10000); +setInterval(refreshAssets, 10000); // ---------- player ---------- function teardown() { @@ -149,7 +170,7 @@

Resolver & cache

teardown(); if (!diagnose()) log('cannot start, see warnings under the player'); $('log').innerHTML = ''; - const asset = encodeURIComponent($('asset').value); + const asset = encodeURIComponent(assetId()); if ($('proto').value === 'hls') { const url = `${base()}/hls/${asset}/master.m3u8`; log('HLS ' + url); @@ -300,8 +321,11 @@

Resolver & cache

pollTimer = setTimeout(poll, interval * 1000); } -$('load').onclick = () => { load(); clearTimeout(pollTimer); prev = null; poll(); setTimeout(refreshTryRow, 1200); }; -load(); poll(); refreshTryRow(); +function start() { load(); clearTimeout(pollTimer); prev = null; poll(); setTimeout(refreshAssets, 1200); } +$('load').onclick = start; +$('asset-pick').addEventListener('change', () => { showCustomField(); if ($('asset-pick').value) start(); else $('asset').focus(); }); +$('asset').addEventListener('keydown', event => { if (event.key === 'Enter') start(); }); +load(); poll(); refreshAssets(); diff --git a/docs/SUMMARY.md b/docs/SUMMARY.md index aa74d6f..dd3a5ec 100644 --- a/docs/SUMMARY.md +++ b/docs/SUMMARY.md @@ -22,6 +22,7 @@ - [Trick play, subtitles, and adaptive renditions](technical-design/0006-trick-play-subtitles-and-renditions.md) - [Viewer authorization](technical-design/0007-viewer-authorization.md) - [Clipping and concatenation](technical-design/0008-clipping-and-concatenation.md) + - [Common encryption and DRM](technical-design/0009-common-encryption-and-drm.md) - [Technical design template](technical-design/template.md) # Implementation diff --git a/docs/internals/http-server.md b/docs/internals/http-server.md index a0f5ba0..d467971 100644 --- a/docs/internals/http-server.md +++ b/docs/internals/http-server.md @@ -94,8 +94,8 @@ The most involved handler: 1. Look up asset, require `?v=`, parse the track, and answer `304` if the ETag matches. 2. **Prepare** the segment on the blocking pool (`prepare_media_segment`): this yields the header bytes, source ranges, and total length without reading payload. 3. Compute the requested byte interval from `Range` and `If-Range`; `416` if unsatisfiable. -4. If the method is `HEAD`, return headers with an empty body. No job slot, no task. -5. Otherwise acquire the first job slot (`503` on queue timeout), spawn a `StreamJob`, and return `Body::from_stream` over a two-item channel. +4. If the method is `HEAD`, return headers with an empty body. No task; a clear segment takes no job slot (an encrypted one releases the slot it was encrypted under). +5. Otherwise acquire the first job slot (`503` on queue timeout), or for an encrypted segment reuse the slot it was encrypted under, spawn a `StreamJob`, and return `Body::from_stream` over a two-item channel. The response's `Content-Length` and `Content-Range` are known up front because the header length and payload length are known from metadata. @@ -103,13 +103,13 @@ The response's `Content-Length` and `Content-Range` are known up front because t An async task that produces the body. Its `stream` method: -1. Sends the part of the header (`moof` plus `mdat` header) that overlaps the requested interval. +1. Sends the part of the header (`moof` plus `mdat` header, or a whole encrypted fragment) that overlaps the requested interval, in chunks of `stream_chunk_bytes`. 2. Walks the prepared source ranges. Byte positions in the response are "virtual offsets" (header first, then each range in order); for each range it intersects the requested interval and reads that overlap in chunks of `stream_chunk_bytes`. 3. For each chunk: `read` acquires a job slot (the first read reuses the slot acquired by the handler), runs `PackagedAsset::read_range` on the blocking pool, **releases the slot**, then `send` pushes the chunk into the channel under `response_idle_timeout_ms`. Failure handling: a source-read or permit failure sends one generic `io::Error` into the body (so the connection aborts instead of ending cleanly short) and counts an `error` abort. A full channel for longer than the idle timeout drops the stream and counts `idle`. A closed channel means the client left and counts `client`. -Invariants worth preserving: a slot is never held while waiting on the client; every send is time-bounded; nothing here reads more than the requested interval. +Invariants worth preserving: for clear segments, a slot is never held while waiting on the client (an encrypted segment's slot covers its bytes in memory until the stream ends); every send is time-bounded; nothing here reads more than the requested interval. ## Conditional and range helpers diff --git a/docs/mapper-api.md b/docs/mapper-api.md index 3c80206..0ff96ad 100644 --- a/docs/mapper-api.md +++ b/docs/mapper-api.md @@ -10,6 +10,7 @@ A mapper answers one question: *where is asset X, and which version of it is cur | --- | --- | --- | | `GET` | `/v1/assets/{asset_id}` | Resolve one asset | | `GET` | `/v1/health` | Optional reachability probe | +| `GET` | `/v1/assets` | Optional list of asset IDs, for control panels; see [List assets](#list-assets) | `{asset_id}` is 1 to 128 ASCII letters, digits, `-`, or `_`. The server rejects any other ID before contacting the mapper, so no escaping is needed. The mapper must ignore request headers it does not know, and the server ignores response fields it does not know, so either side can add fields without breaking the other. @@ -61,11 +62,14 @@ A file on an HTTP origin: | `subtitles` | No | Sidecar WebVTT files; see [Subtitles](#subtitles) | | `renditions` | Instead of `location` | Several files served as one adaptive asset; see [Renditions](#renditions). Exactly one of `location`, `renditions`, or `clips` must be set | | `clips` | Instead of `location` | One file trimmed, or several played back to back; see [Clips](#clips) | +| `encryption` | No | Content keys and DRM signalling; see [Encryption](#encryption). The answer then carries secrets | ### `304 Not Modified` Send this when `If-None-Match` names the current version. The body is empty. A `Cache-Control: max-age=N` header sets the new reuse window. The server only sends `If-None-Match` when it already holds a usable answer, and never when the previous location has passed its `expires_at`, so a `304` can only be an answer to a valid question. +A `304` means nothing in the answer changed, including `encryption`: the server keeps the answer it already holds. When you re-key an asset, answer `200` with the new `encryption` object (and change `version`), never `304`. + ### Errors | Status | Meaning | What the server does | @@ -83,6 +87,22 @@ Behavior depends only on the HTTP status, so error bodies are informational. `{" `GET /v1/health` returning any `2xx` means healthy. It is used only when the server's optional readiness probe is enabled, in which case the server reports itself not ready while the mapper is unreachable. Mappers without this endpoint can leave the probe disabled. +## List assets + +`GET /v1/assets` is optional. When a mapper implements it, the server's `/admin/status` offers the IDs it returns as `resolver.known_assets`, so the [demo player](../demo/) and [control panel](../admin/) can list them in their asset dropdowns before anyone has played them. Nothing else uses it: the server never preloads or resolves an asset because it is listed. + +```text +GET /v1/assets +Accept: application/json +Authorization: Bearer (if configured) +``` + +```json +{ "assets": ["big-buck-bunny", "movie-with-preroll", "trailer"] } +``` + +List whatever is useful to browse; it need not be every asset. The server drops any ID a request could not name (the rules in [Endpoints](#endpoints)), removes duplicates, sorts the rest, and keeps at most `limits.max_assets` (default 1000). The answer is bounded like any other (`max_response_bytes`). Any failure, including a `404` from a mapper that does not implement the endpoint, a malformed body, or an outage, simply means there is no list: `known_assets` is `null` and nothing else changes. `/admin/status` asks on every call, so keep the answer cheap. + ## Remote locations An `http` location makes the server fetch media from a URL the mapper chose, so the server applies the operator's policy before any request: @@ -207,6 +227,67 @@ Both times are at most 4294967295 ms, and an answer may list at most `limits.max `subtitles` cannot be combined with `clips` yet: the mapper cannot know where a keyframe-aligned clip begins, so cues written for the output would drift. Such an answer is rejected. +## Encryption + +An answer may carry an `encryption` object. segmentor then encrypts what it serves with Common Encryption in the `cbcs` scheme, and signals the DRM systems you name in the HLS and DASH manifests. One set of segments plays under Widevine, FairPlay, PlayReady, and Clear Key. The object applies to the whole asset: every track, every rendition, and every clip. + +```json +{ + "asset_id": "movie", + "version": "2026-10-01-a", + "location": { "type": "file", "path": "movies/movie.mp4" }, + "encryption": { + "scheme": "cbcs", + "keys": [ + { "tracks": "all", "key_id": "0123456789abcdef0123456789abcdef", "key": "00112233445566778899aabbccddeeff" } + ], + "systems": [ + { "system_id": "edef8ba9-79d6-4ace-a3c8-27dcd51d21ed", "pssh": "", "license_url": "https://license.example.net/widevine" }, + { "system_id": "94ce86fb-07ff-4f43-adb8-93d2fa968ca2", "hls_uri": "skd://movie" }, + { "system_id": "9a04f079-9840-4286-ab92-e65be0885f95", "pssh": "", "license_url": "https://license.example.net/playready" } + ] + } +} +``` + +| Field | Rules | +| --- | --- | +| `scheme` | `cbcs`. Anything else is rejected | +| `keys` | One entry with `"tracks": "all"` (or no `tracks`), or one `"video"` and one `"audio"` entry, for example `{ "tracks": "video", … }, { "tracks": "audio", … }`. Every track of the asset must end up with exactly one key. FairPlay needs one key for all tracks: an answer listing FairPlay with separate video and audio keys is rejected | +| `key_id`, `key` | 16 bytes each, as 32 hex digits | +| `iv` | Optional, 16 bytes as 32 hex digits: the constant IV declared in the init segment. When absent, it is the first 16 bytes of SHA-256(`"segmentor cbcs iv"` followed by `key_id`), so every replica derives the same one. It is never derived from the key | +| `systems` | At most 8. Each names a DRM system by its standard system ID (a UUID) | +| `pssh` | Optional base64 of a complete, well-formed `pssh` box whose system ID matches the entry's `system_id`, at most 16 KiB decoded | +| `license_url` | Optional `https` URL. Written into the DASH manifest as `dashif:Laurl`, and used as Clear Key's HLS key URI | +| `hls_uri` | The HLS key URI. Required for FairPlay (for example `skd://movie`); no quotes or control characters, at most 2048 bytes | + +**What each system needs:** + +| System | System ID | HLS signalling | Needs | +| --- | --- | --- | --- | +| Widevine | `edef8ba9-79d6-4ace-a3c8-27dcd51d21ed` | `SAMPLE-AES` key line with a data URI of the `pssh` and the key ID | `pssh`; `license_url` for DASH | +| FairPlay | `94ce86fb-07ff-4f43-adb8-93d2fa968ca2` | `SAMPLE-AES` key line with `com.apple.streamingkeydelivery` | `hls_uri`; one key for all tracks (`"tracks": "all"`) | +| PlayReady | `9a04f079-9840-4286-ab92-e65be0885f95` | `SAMPLE-AES` key line with a UTF-16 data URI of the PlayReady object taken from the `pssh` | `pssh`; `license_url` for DASH | +| Clear Key | `e2719d58-a985-b3c9-781a-b030af78d30e` | `SAMPLE-AES` key line with `org.w3.clearkey` and the `license_url` | `license_url` for HLS; nothing for DASH | + +**Clear Key in browsers.** dash.js plays Clear Key from the manifest's `default_KID` with keys it is given directly, so `{ "system_id": "e2719d58-a985-b3c9-781a-b030af78d30e" }` alone is enough there. hls.js asks the browser's Clear Key CDM for a licence from the init data in the init segment, and Chrome only recognises the W3C common system ID `1077efec-c0b2-4d02-ace3-3c1e52e2fb4b` for that. For HLS, therefore, list Clear Key with its `license_url` and add a second system with that common ID and a `pssh` box listing the key ID. + +A Widevine or PlayReady entry without a `pssh` gets no HLS key line: HLS signals both from the `pssh`, so such an entry is signalled in DASH only. + +Any other system ID is accepted and signalled in DASH only (its `pssh` and `license_url` go into its `ContentProtection`); HLS has no standard form for it. The DASH manifest carries a `mp4protection` `ContentProtection` with `cenc:default_KID`, then one per system. + +**segmentor never contacts a licence server.** It encrypts with the keys you give it and tells players where to get a licence; the player fetches the licence from your DRM vendor. The `pssh` boxes and URLs are yours to produce. + +**Codecs.** H.264 video and AAC, AC-3, and E-AC-3 audio can be encrypted. HEVC encryption is not supported yet: an asset with HEVC and an `encryption` object fails with "HEVC encryption is not supported yet". VP9, AV1, Opus, and FLAC cannot be encrypted. A source file that is itself encrypted is still rejected. + +**A malformed `encryption` object makes the whole answer malformed**: a `502` to players, never cached as valid, like any other bad answer. That covers an unknown scheme, a bad hex length, keys that do not cover every track, more than eight systems, a `pssh` that is not a well-formed box or names another system, a `license_url` that is not `https`, a FairPlay entry without `hls_uri`, and FairPlay listed with separate video and audio keys. Error messages and logs never contain key material. + +**The URL version covers the keys.** The key (as its SHA-256), the key IDs, the IVs, and the systems are hashed into the version in every URL. An answer whose keys changed therefore reloads the asset and hands out new URLs, even under an unchanged mapper `version`, so no player mixes old and new segments. That holds only when the mapper answers `200` with the new keys: a `304` tells the server nothing changed, and it keeps the old keys. Re-key with a `200` and a new `version`. + +**Large `pssh` boxes need a larger answer limit.** The server rejects mapper answers over `resolver.http.max_response_bytes` (16 KiB by default). A 16 KiB `pssh` is about 22 KiB in base64, so an answer carrying large `pssh` boxes, or several, can exceed it; raise the limit to fit. + +**The answer now carries secrets.** The mapper must be reached over `https` and must require the bearer token; do not use `allow_insecure_mapper` with real keys. + ## What a `version` means to the server The server keeps one loaded copy per asset, keyed by `(asset_id, version)`. A different `version`, or the same version at a different location (a rotated signed URL), makes it reload from the new location. There is **no grace period**: players holding URLs from the old version get `404` and recover by fetching the playlist again. Change `version` only when the media actually changes. @@ -275,10 +356,12 @@ Then `curl http://127.0.0.1:3000/hls/movie/master.m3u8`. - The response `asset_id` echoes the request. - `version` changes whenever the media changes, and only then. - Removed assets answer `404` or `410`, not `200`. -- Answers are small (the server's default limit is 16 KiB). +- Answers are small (the server's default limit is 16 KiB; raise `resolver.http.max_response_bytes` for large `pssh` boxes). +- A re-keyed asset is answered with `200` and a new `version`, never `304`. - The mapper answers quickly: the server's default per-request timeout is two seconds, with two retries. - `version` also changes when a subtitle file changes, or when any rendition's file changes. - Video renditions are encoded with the same keyframe interval, so their segments align. - `expires_at` is set for anything signed, and re-signing keeps the same `version`. - `from_ms`/`to_ms` are on the file's own clock, and a clip may start up to one keyframe interval early. - The mapper is reachable over `https` in production and requires the bearer token. +- Keys only travel over `https`: an answer with `encryption` is never served over plain HTTP outside development. diff --git a/docs/operations.md b/docs/operations.md index 57440a0..7061441 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -9,7 +9,7 @@ The service is an HTTP origin. It does not terminate TLS, authenticate viewers, Two protections are built in because a proxy cannot fully provide them: - `limits.max_concurrent_requests` answers `503` with `Retry-After: 1` once that many handlers are running. `/health`, `/ready`, and `/metrics` are exempt. -- `limits.response_idle_timeout_ms` closes a media response whose client stops reading, and a job slot is held only while a source read is in flight. A stalled client therefore cannot exhaust `limits.max_segment_jobs`. +- `limits.response_idle_timeout_ms` closes a media response whose client stops reading, and for clear assets a job slot is held only while a source read is in flight. A stalled client therefore cannot exhaust `limits.max_segment_jobs`. An encrypted segment holds its slot until its response finishes (see [Content encryption](#content-encryption)), so a stalled client holds one for at most the idle timeout. - `limits.header_read_timeout_ms` (default 10 s) closes a connection that has not delivered a complete request header block in that time, which stops slow-header (slowloris) clients. It also bounds how long an idle keep-alive connection waits for its next request. - `limits.max_connections` (default 10,000) closes connections beyond the cap immediately at accept. Raise the process file-descriptor limit (`ulimit -n`, or `LimitNOFILE` under systemd) above this number plus headroom for media file handles; otherwise accept fails with `EMFILE` before the cap applies (the accept loop then logs `accept_failed` and backs off for a second). @@ -23,9 +23,9 @@ The service still does not limit connections per client address. Do that on the | `/health` | The process is running | Liveness probe | | `/ready` | `200` while serving, `503` once shutdown begins | Readiness probe and load-balancer health check | | `/metrics` | Prometheus text | Scraping | -| `/admin/status` | JSON: the resolver's live health and every asset in the loaded-asset cache | A control panel or an ad hoc check | +| `/admin/status` | JSON: the resolver's live health, the assets it can list, and every asset in the loaded-asset cache | A control panel or an ad hoc check | -`/admin/status` carries the same trust model as `/metrics`: no authentication, meant to be reached only through the reverse proxy this page already asks you to put in front of the origin (see [Before you expose it](deployment.md#before-you-expose-it)). It names asset IDs, versions, and, for a mapper resolver, the mapper's base URL, so keep it off any path a viewer can reach. Unlike `/ready`'s cached health flag, it checks the resolver live on every call, so it costs one resolver round trip (a file resolver answers this immediately). See [the control panel](../admin/) for a page that reads it. +`/admin/status` carries the same trust model as `/metrics`: no authentication, meant to be reached only through the reverse proxy this page already asks you to put in front of the origin (see [Before you expose it](deployment.md#before-you-expose-it)). It names asset IDs, versions, and, for a mapper resolver, the mapper's base URL, so keep it off any path a viewer can reach. Unlike `/ready`'s cached health flag, it checks the resolver live on every call, so it costs one resolver round trip, two with a mapper that also answers the optional [asset listing](mapper-api.md#list-assets), made concurrently (a file resolver answers both immediately). See [the control panel](../admin/) for a page that reads it. ## Shutdown @@ -90,6 +90,22 @@ Alert on a rising `vod_segment_queue_timeouts_total` or `vod_http_requests_shed_ Each loaded asset keeps its full sample index in memory, about 40 bytes per sample. `limits.max_index_bytes` (default 4 GiB) rejects a catalog whose combined indexes exceed it, and startup fails with the measured size. Size the container's memory limit above that budget plus headroom for in-flight segment reads (`stream_chunk_bytes` times `max_segment_jobs`). +## Content encryption + +Assets whose mapper answer carries an `encryption` object ([mapper API](mapper-api.md#encryption)) are encrypted per request. The segment is read into memory, encrypted, and then sent in `limits.stream_chunk_bytes` pieces like any other. Its bytes are held under a segment-job slot from the read until the response finishes, so encrypted serving holds at most `limits.max_segment_jobs × limits.max_segment_bytes`. `max_segment_jobs` defaults to 2 × CPU cores, at most 32, and `max_segment_bytes` to 64 MiB: 512 MiB on a 4-core host, up to 2 GiB. Count it in the container's memory limit next to the index budget above, and lower either limit on small hosts. A client that stops reading an encrypted segment is cut off after `limits.response_idle_timeout_ms`, like any other, which frees its slot. + +| Metric | Type | Notes | +| --- | --- | --- | +| `vod_encrypted_segments_total` | counter | Segments encrypted and served | +| `vod_encryption_seconds_total` | counter | Time spent encrypting | +| `vod_encryption_failures_total` | counter | Segments that could not be encrypted | + +A failed encryption logs a `segment_encryption_failed` event naming the segment and the error, never key material. A rising `vod_encryption_failures_total` usually means a source the parser rejects; the log line says which. + +`/admin/status` reports, per asset, whether it is `encrypted` and its `key_ids`. It never shows keys. Keys are never written to disk or to logs. + +Because the mapper's answer carries the keys, the mapper must be reached over `https` and require the bearer token. segmentor never contacts a licence server. + ## Resolving assets from a mapper By default the catalog is the `[assets.*]` tables and every asset is loaded before the server accepts traffic. To resolve assets from an external service instead, replace those tables with a resolver (see the commented example in `vod.example.toml` and the [Mapper API reference](mapper-api.md)): diff --git a/docs/technical-design/0009-common-encryption-and-drm.md b/docs/technical-design/0009-common-encryption-and-drm.md new file mode 100644 index 0000000..4b0a93c --- /dev/null +++ b/docs/technical-design/0009-common-encryption-and-drm.md @@ -0,0 +1,181 @@ +# TDD 0009: Common encryption and DRM + +- Status: Accepted; H.264 and audio implemented, HEVC pending +- Created: 2026-10-01 +- Updated: 2026-10-01 +- Related ADRs: [ADR 0001](../adr/0001-use-fragmented-mp4-for-media-segments.md) (fragmented MP4 segments) +- Related designs: [TDD 0002](0002-asset-map-interface.md) (the mapper interface this extends), [TDD 0006](0006-trick-play-subtitles-and-renditions.md) (its "DRM (later)" section recorded what this must not break), [TDD 0008](0008-clipping-and-concatenation.md) (sequences, which this covers) + +## Summary + +segmentor encrypts what it serves with Common Encryption (ISO/IEC 23001-7) in the `cbcs` scheme, and signals DRM systems in HLS and DASH, so one set of segments plays under Widevine, FairPlay, PlayReady, and Clear Key. The mapper supplies the content keys and each DRM system's data in an optional `encryption` object of its answer. segmentor never talks to a licence server: players fetch licences from the operator's DRM vendor, and segmentor only encrypts and signals where to go. + +Encryption happens per request, like everything else segmentor serves. An encrypted segment is read into memory, encrypted, and returned; its output is deterministic, so immutable URLs, ETags, and CDN caching work unchanged. Assets without `encryption` are served byte for byte as today. + +This is the first stage of DRM: the shared core. It deliberately leaves out AV1 and VP9, different keys per clip, key rotation, whole-segment HLS AES-128, encrypted source files, and a SPEKE adapter (see [Deferred](#deferred)). + +## Context + +Premium content cannot be licensed without studio-grade DRM, and nginx-vod-module offers DRM, so its absence is the largest remaining reason to choose vod-module over segmentor. `cbcs` is the one scheme every major DRM system accepts on both HLS and DASH, which is what lets one set of segments serve every player. + +Two properties of the current pipeline shape this design: + +- **Headers come from the index alone.** `fmp4::prepare_media_segment` builds a fragment's `moof` from the sample index, then the response streams sample bytes from the source without holding them. With `cbcs`, the `moof` must carry each video sample's subsample map (which bytes are clear and which encrypted), and that map comes from the NAL units inside the samples. An encrypted segment therefore has to be read before its header can be written. +- **Init segments copy the source's sample entry.** `fmp4::write_init_segment` copies `stsd` untouched and rewrites only header durations. Encryption renames the sample entry and nests a `sinf` inside it. + +The mapper already carries per-asset secrets under HTTPS and a bearer token, has validation, TTLs, and caching, and is the component operators already write, so it is where content keys come from. Operators fetch keys from their DRM vendor inside their mapper. + +## Goals + +- One set of `cbcs` segments playable under Widevine, FairPlay, PlayReady, and Clear Key, across HLS and DASH, for single files, adaptive renditions, and sequences. +- Keys from the mapper, handled as secrets: never logged, never exposed, never written to disk. +- Deterministic encrypted output, so the URL and caching model is unchanged. +- No change of any kind for assets without `encryption`. +- Encryption verified by an independent implementation, not only by our own round trip. + +## Non-goals + +- Licence servers, key generation, or talking to a DRM vendor. The operator's mapper supplies keys and system data. +- The `cenc` (AES-CTR), `cbc1`, and `cens` schemes. +- AV1, VP9, Opus, and FLAC encryption in this stage. +- Different keys per clip, key rotation, whole-segment HLS AES-128, encrypted source passthrough, and SPEKE. See [Deferred](#deferred). + +## Design + +### Wire format + +The mapper answer gains an optional `encryption` object. It applies to the whole asset: every track, every rendition, and every clip. + +```json +"encryption": { + "scheme": "cbcs", + "keys": [ + { "tracks": "all", "key_id": "0123456789abcdef0123456789abcdef", "key": "…32 hex…", "iv": "…32 hex, optional…" } + ], + "systems": [ + { "system_id": "edef8ba9-79d6-4ace-a3c8-27dcd51d21ed", "pssh": "", "license_url": "https://license.example.net/widevine" }, + { "system_id": "94ce86fb-07ff-4f43-adb8-93d2fa968ca2", "hls_uri": "skd://asset-123" }, + { "system_id": "9a04f079-9840-4286-ab92-e65be0885f95", "pssh": "", "license_url": "https://license.example.net/playready" }, + { "system_id": "e2719d58-a985-b3c9-781a-b030af78d30e", "license_url": "https://license.example.net/clearkey" } + ] +} +``` + +| Field | Rules | +| --- | --- | +| `scheme` | `cbcs`. Anything else is rejected | +| `keys` | One entry with `"tracks": "all"` (or no `tracks`), or one `"video"` and one `"audio"` entry. Every track of the asset must end up with exactly one key. Separate video and audio keys cannot be combined with FairPlay, whose HLS key line carries no key ID: the answer is rejected with "FairPlay needs one key for all tracks" | +| `key_id`, `key` | 16 bytes each, as 32 hex digits | +| `iv` | Optional, 16 bytes as 32 hex digits: the constant IV declared in the init segment. When absent, it is the first 16 bytes of SHA-256(`"segmentor cbcs iv"` ‖ `key_id`), so every replica derives the same one. It is never derived from the key | +| `systems` | At most 8. Each names a DRM system by its standard system ID (a UUID) | +| `pssh` | Optional base64 of a complete, well-formed `pssh` box whose system ID matches, at most 16 KiB | +| `license_url` | Optional `https` URL, written into the DASH manifest (and used as Clear Key's HLS URI) | +| `hls_uri` | The HLS key URI; required for FairPlay (`skd://…`), ignored for systems HLS signals from their `pssh` | + +A malformed `encryption` object makes the whole answer malformed: `502` to players, never cached as valid, like any other bad answer. The mapper client checks every rule above before the answer is used. + +### Keys are secrets + +Content keys are held in the same redacting type as the mapper's bearer token, so a `Debug` or log line cannot print them. They are kept in memory with the cached mapper answer and the loaded asset, and nowhere else; nothing is written to disk. They never appear in logs, error bodies, or `/admin/status`, which reports `encrypted: true` and the key IDs only. The operations guide states that the mapper answer now carries keys, so HTTPS and the bearer token (already the production requirements) protect them in transit. + +### What is encrypted + +Following ISO/IEC 23001-7 for `cbcs`: AES-128 in CBC mode, with the chain restarting from the constant IV at the start of each protected range and running only across the blocks that are encrypted. + +- **Video (H.264, HEVC).** Each sample is a sequence of length-prefixed NAL units. Non-VCL NAL units (parameter sets, SEI, access unit delimiters) stay entirely clear. In each VCL NAL unit (slice data), the length prefix, NAL header, and slice header stay clear, and the rest is the protected range, encrypted with the 1:9 pattern: one 16-byte block encrypted, nine clear, repeating, with a trailing partial block clear. The slice header's length is found by parsing it against the sequence and picture parameter sets from the sample entry (`avcC`, `hvcC`), and from any parameter sets carried in-band earlier in the same sample. The parser is new, bounded (every read is checked against the NAL unit's length), and has a fuzz target. +- **Audio (AAC, AC-3, E-AC-3).** Each whole sample is one protected range with no pattern (every block encrypted), and a trailing partial block clear. +- **Subtitles** stay clear WebVTT. + +The cipher is AES-128 from the RustCrypto `aes` crate (pure Rust, constant time, using AES-NI where the CPU has it), with the CBC chaining written by hand, because the pattern skips blocks that a library's CBC mode would chain through. It is a new dependency; `aws-lc-rs`, already in the tree, does not expose a raw block cipher suited to this. + +### Boxes + +- **Init segment.** The sample entry's type becomes `encv` (video) or `enca` (audio), and it gains a `sinf` box: `frma` holding the original type, `schm` with scheme `cbcs` version 1.0, and `schi` holding a version 1 `tenc` with `default_isProtected = 1`, the pattern (1:9 for video, 0:0 for audio), `default_Per_Sample_IV_Size = 0`, the key ID, and the 16-byte constant IV. `moov` gains one `pssh` box per system that supplied one. Every enclosing box's size is fixed up; the init writer already rewrites header boxes and this extends it. +- **Media fragment.** Each `traf` gains, after `trun`: `senc` (the sample count, then per sample a subsample count and its `(clear bytes, protected bytes)` pairs; no per-sample IV, because the IV is constant), `saiz` (each sample's auxiliary information size), and `saio` (one offset, from the start of the `moof`, to the first sample's entry in `senc`, matching the fragments' `default-base-is-moof` addressing). Audio samples, having no subsamples, get `saiz` sizes of zero and no `senc` entries beyond the count, as the specification allows with `default_Per_Sample_IV_Size = 0`. + +### The encrypted segment path + +`PreparedSegment` today is a header plus the source byte ranges to stream after it. Its body becomes one of two kinds: byte ranges (today's zero-copy path, used for every clear track) or finished bytes in memory. An encrypted track's segment is prepared by a new step: + +1. Check the segment's payload against `limits.max_segment_bytes`, exactly as today, before reading anything. +2. Take a segment-job slot and read the samples' byte ranges into memory. The slot stays with the finished bytes and is handed to the response stream, which releases it when the response finishes or is aborted; no second slot is taken. +3. On the blocking pool: compute each sample's subsample map, encrypt in place, build the `moof` with `senc`, `saiz`, and `saio`, and return the finished fragment. + +Everything after that is unchanged: range requests slice the finished bytes, `If-None-Match` and ETags work as before, and the response carries the same headers. A `HEAD` of an encrypted segment also reads and encrypts it, because the `senc` size depends on the sample bytes. I-frame fragments of an encrypted video track take the same path, one sample each. + +Nothing encrypted is cached inside segmentor; a CDN caches it, as it caches everything else. Output depends only on the source bytes, the key, and the IV, so it is deterministic: two replicas, or two requests, produce identical bytes. + +### Composites and sequences + +For adaptive renditions, every rendition's tracks are encrypted with the key for their kind, and the composite's manifests carry one set of signalling. For sequences, every clip is encrypted with the same keys; the DASH signalling repeats in each Period, and the HLS `#EXT-X-KEY` lines, placed once before the first `#EXT-X-MAP`, stay in force across discontinuities because the key does not change. + +### Signalling: DASH + +Only for encrypted assets; unencrypted manifests are unchanged. The `MPD` declares `xmlns:cenc="urn:mpeg:cenc:2013"` and `xmlns:dashif="https://dashif.org/CPS"`. Each encrypted adaptation set gains: + +- ``, the key ID in UUID form; +- one `` per system, containing `` when a `pssh` was supplied and `` when a `license_url` was. + +This signals Widevine, PlayReady, Clear Key, and any other system the mapper lists. + +### Signalling: HLS + +One `#EXT-X-KEY` per system goes in every media playlist, before the first `#EXT-X-MAP`, and in the I-frame playlist; the master playlist repeats them as `#EXT-X-SESSION-KEY` so players can request licences before they fetch media. All use `METHOD=SAMPLE-AES`: + +| System | Attributes | +| --- | --- | +| FairPlay | `URI=""`, `KEYFORMAT="com.apple.streamingkeydelivery"`, `KEYFORMATVERSIONS="1"` | +| Widevine | `URI="data:text/plain;base64,"`, `KEYID=0x`, `KEYFORMAT="urn:uuid:edef8ba9-79d6-4ace-a3c8-27dcd51d21ed"`, `KEYFORMATVERSIONS="1"` | +| PlayReady | `URI="data:text/plain;charset=UTF-16;base64,"`, `KEYFORMAT="com.microsoft.playready"`, `KEYFORMATVERSIONS="1"` | +| Clear Key | `URI=""`, `KEYFORMAT="org.w3.clearkey"`, `KEYFORMATVERSIONS="1"` | + +Other system IDs have no standard HLS form and are signalled in DASH only; the mapper API reference says so. Checked in hls.js 1.7 in headless Chrome: it plays the Clear Key line, so Clear Key stays in HLS. hls.js takes the licence URL from the key line but builds its request from the init data, which Chrome's Clear Key CDM accepts only as a `pssh` with the W3C common system ID `1077efec-c0b2-4d02-ace3-3c1e52e2fb4b`; the mapper therefore lists that system with a `pssh` next to Clear Key (see the mapper API reference). A Clear Key `pssh` under the DASH-IF ID `e2719d58-…` is rejected by the CDM. + +### Versioning + +The URL version of an encrypted asset additionally hashes each key ID, a SHA-256 of each key, each IV, and the system list. Re-keying an asset, even under an unchanged mapper `version`, therefore gives new URLs, and a CDN never mixes segments encrypted under different keys. This needs the mapper to answer `200` with the new keys: a `304` means nothing in the answer changed, so the server keeps the keys it holds. The mapper API reference tells mapper authors to re-key with a `200` and a new `version`. `FORMAT_REVISION` does not change, because nothing served for an unencrypted asset changes. + +## Security and limits + +- Every field of `encryption` is validated in the mapper client before use (see [Wire format](#wire-format)); `pssh` input is size-limited and parsed as a box, never trusted as a length. +- Keys are secrets throughout (see [Keys are secrets](#keys-are-secrets)). +- Memory: each in-flight encrypted segment is held whole, under the segment-job slot taken to read it, from the read until its response finishes or is aborted; it is sent in `limits.stream_chunk_bytes` pieces, so a client that stops reading is cut off after `limits.response_idle_timeout_ms` like any other. Encrypted serving therefore holds at most `limits.max_segment_jobs × limits.max_segment_bytes`: `max_segment_jobs` defaults to min(2 × CPU cores, 32) and `max_segment_bytes` to 64 MiB, so 512 MiB on a 4-core host and up to 2 GiB. The payload buffer is allocated once at its final size, and the header is put in front of it in place. The operations guide says so and suggests lowering either limit on small hosts. +- The slice-header parser reads only within its NAL unit and fails closed: a slice it cannot parse fails that segment rather than being encrypted with a guessed clear range. +- Encryption requested for a codec this stage does not support fails the asset at load, naming the codec. Encrypted source files (`encv`, `enca`) stay rejected, as today. + +## Observability + +- Metrics: `vod_encrypted_segments_total`, `vod_encryption_seconds_total` (time spent reading and encrypting), and `vod_encryption_failures_total`. +- `asset_loaded` gains `encrypted` and the number of systems. +- A segment that fails to encrypt logs an error naming the asset, track, and segment, and never key material. +- `/admin/status` reports `encrypted` and key IDs per asset. + +## Testing + +- **Independent decryption.** FFmpeg decrypts `cbcs` with `-decryption_key`. For single files, renditions, and a sequence, the test follows the served HLS playlist and DASH manifest to each track's (and clip's) init and media segments, reassembles them, and requires that FFmpeg decodes them with no errors and the exact expected frame counts given the right key, and fails given no key or a wrong one. This validates the cipher, the subsample maps, and the boxes against an implementation we did not write. +- **Units:** the pattern cipher against published test vectors; the slice-header parser against the H.264 and HEVC fixtures (and a fuzz target in `fuzz/`); the init segment's `encv`/`enca`, `sinf`, and `pssh`, read back; each fragment's `senc`, `saiz`, and `saio`, read back, with the `saio` offset landing on the `senc` entries and the counts agreeing with `trun`. +- **Renderers:** DASH `ContentProtection` and each HLS `#EXT-X-KEY` form; unencrypted output byte-identical, checked by the conformance suite. +- **Wire and secrets:** every validation rule; keys absent from `/admin/status`, log output, and error bodies; the version changing with the key. +- **Players:** Clear Key in dash.js (and hls.js, per [Signalling: HLS](#signalling-hls)) in headless Chrome: real browser decryption without a DRM vendor. Widevine, FairPlay, and PlayReady need vendor accounts and devices; playing them is a manual release check with the operator's vendor. + +## Rollout + +Additive: a mapper that sends no `encryption` gets byte-identical output, and there is no configuration change. Staged as: + +1. The cipher, the slice-header parser, and the boxes, tested in isolation. +2. The `encryption` wire object, the encrypted segment path, and init segments, verified by FFmpeg decryption. +3. HLS and DASH signalling, and Clear Key in real players. +4. Documentation: a mapper API "Encryption" section and the operations guide. + +## Deferred + +- **AV1 and VP9**, which need their own subsample rules (OBU and superframe aware). +- **Different keys per clip**, such as a clear pre-roll before an encrypted movie, which needs HLS key changes at discontinuities and per-Period DASH signalling. +- **Key rotation** within an asset. +- **Whole-segment HLS AES-128**, a separate, simpler mode that protects without DRM. +- **Encrypted source passthrough**: re-segmenting files that are already `cbcs`-encrypted. +- **A SPEKE v2 adapter**, so a mapper can hand key retrieval to a DRM vendor's standard endpoint. + +## Open questions + +None blocking. Clear Key in hls.js is settled at the start of implementation, as described under [Signalling: HLS](#signalling-hls). diff --git a/docs/technical-design/README.md b/docs/technical-design/README.md index 1dfea4b..c98027b 100644 --- a/docs/technical-design/README.md +++ b/docs/technical-design/README.md @@ -14,6 +14,7 @@ Technical design documents explain a proposed subsystem before or during impleme | [0006](0006-trick-play-subtitles-and-renditions.md) | Trick play, subtitles, and adaptive renditions | Accepted; implemented | | [0007](0007-viewer-authorization.md) | Viewer authorization | Draft | | [0008](0008-clipping-and-concatenation.md) | Clipping and concatenation | Accepted; implemented | +| [0009](0009-common-encryption-and-drm.md) | Common encryption and DRM | Accepted; H.264 and audio implemented, HEVC pending | ## Workflow diff --git a/docs/usage.md b/docs/usage.md index 0fea305..e93ee7e 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -39,7 +39,7 @@ Configuration, logging, limits, CORS, and shutdown are described in [Operating t ## Web player demo -`demo/index.html` is a single-file player (hls.js and dash.js, loaded from a CDN) that plays an asset over HLS or DASH and shows live server metrics parsed from `/metrics` next to it: request rate, throughput, per-route latency, errors, and resolver and cache events, plus player-side stats such as buffer, bandwidth, and dropped frames. Besides typing an asset ID, a "Try:" row under the input lists assets you can click straight into: the static catalog's, and anything already played, from `/admin/status`. +`demo/index.html` is a single-file player (hls.js and dash.js, loaded from a CDN) that plays an asset over HLS or DASH and shows live server metrics parsed from `/metrics` next to it: request rate, throughput, per-route latency, errors, and resolver and cache events, plus player-side stats such as buffer, bandwidth, and dropped frames. The Asset dropdown lists every asset `/admin/status` names (the static catalog, or what a mapper returns from its optional [asset listing](mapper-api.md#list-assets), plus anything already loaded); picking one plays it, and its last entry, "Custom…", takes any other ID. ```sh make serve # terminal 1: the origin on :3000 @@ -50,7 +50,7 @@ The page reads `/metrics` cross-origin, so keep `[cors]` enabled, as in `vod.exa ## Control panel -`admin/index.html` is a second single-file page, separate from the player demo, for operating a running instance: it polls `/admin/status` for the resolver's live connection state and every asset currently in the loaded-asset cache (version, size, tracks, duration), and `/metrics` for the same request and throughput charts the player demo shows. It also embeds the same HLS/DASH player, with a "Known asset" dropdown next to the Asset field listing everything the status view names, so you can pick a playable asset without leaving the page or typing its ID — the field itself still takes any name, known or not. +`admin/index.html` is a second single-file page, separate from the player demo, for operating a running instance: it polls `/admin/status` for the resolver's live connection state and every asset currently in the loaded-asset cache (version, size, tracks, duration), and `/metrics` for the same request and throughput charts the player demo shows. It also embeds the same HLS/DASH player, with the same Asset dropdown as the player demo: everything the status view names, then "Custom…" for any other ID. Clicking a row in the cache table plays that asset too. ```sh make serve # terminal 1: the origin on :3000 diff --git a/examples/mapper/README.md b/examples/mapper/README.md index 79faa21..3705699 100644 --- a/examples/mapper/README.md +++ b/examples/mapper/README.md @@ -50,3 +50,7 @@ costs seconds, not a real encode) at `tests/fixtures/generated/long.mp4`, which picks up without a restart. It is not committed; requesting `long` before generating it fails like any other missing file — a mapper is asked lazily, per asset, so this cannot break startup the way a bad entry in a static `[assets.*]` catalog would. + +`drm-clearkey` is the same file encrypted with `cbcs` and signalled for Clear Key +([mapper API](../../docs/mapper-api.md#encryption)). Its key is public test material, published +in this repository: never use it, or any key committed to a repository, for real content. diff --git a/examples/mapper/catalog.json b/examples/mapper/catalog.json index c4f468c..7f3a736 100644 --- a/examples/mapper/catalog.json +++ b/examples/mapper/catalog.json @@ -29,5 +29,14 @@ { "location": { "type": "file", "path": "rendition-480p.mp4" } }, { "location": { "type": "file", "path": "rendition-720p.mp4" }, "from_ms": 1500 } ] + }, + "drm-clearkey": { + "version": "v1", + "location": { "type": "file", "path": "h264-aac.mp4" }, + "encryption": { + "scheme": "cbcs", + "keys": [{ "key_id": "0123456789abcdef0123456789abcdef", "key": "00112233445566778899aabbccddeeff" }], + "systems": [{ "system_id": "e2719d58-a985-b3c9-781a-b030af78d30e" }] + } } } diff --git a/examples/mapper/mapper.py b/examples/mapper/mapper.py index 1c0ad25..72136cf 100644 --- a/examples/mapper/mapper.py +++ b/examples/mapper/mapper.py @@ -2,7 +2,7 @@ """A minimal mapper for local development and demos. Implements just enough of the wire protocol in docs/mapper-api.md for segmentor to resolve -assets against it: GET /v1/health and GET /v1/assets/{id}. It answers from catalog.json, read +assets against it: GET /v1/health, GET /v1/assets/{id}, and the optional GET /v1/assets listing. It answers from catalog.json, read fresh on every request, so editing that file (or the mount in docker-compose.dev.yml) and waiting out the short TTL below is enough to see a change without restarting anything. @@ -37,6 +37,23 @@ def do_GET(self) -> None: # noqa: N802 - required name for BaseHTTPRequestHandl self.end_headers() return + if self.path == "/v1/assets": + # The optional listing: lets the demo and control panel offer every catalog entry. + try: + ids = sorted(load_catalog()) + except (OSError, json.JSONDecodeError) as error: + self.send_response(500) + self.end_headers() + self.wfile.write(f"catalog.json: {error}\n".encode()) + return + body = json.dumps({"assets": ids}).encode() + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + return + prefix = "/v1/assets/" if not self.path.startswith(prefix): self.send_response(404) @@ -63,8 +80,9 @@ def do_GET(self) -> None: # noqa: N802 - required name for BaseHTTPRequestHandl "version": entry["version"], "ttl_seconds": TTL_SECONDS, } - # Pass through whichever of the three alternatives the entry uses, and its subtitles. - for field in ("location", "renditions", "clips", "subtitles"): + # Pass through whichever of the three alternatives the entry uses, its subtitles, and any + # encryption (keys and DRM systems). + for field in ("location", "renditions", "clips", "subtitles", "encryption"): if field in entry: answer[field] = entry[field] body = json.dumps(answer).encode() diff --git a/fuzz/Cargo.lock b/fuzz/Cargo.lock index 5a05713..65102d6 100644 --- a/fuzz/Cargo.lock +++ b/fuzz/Cargo.lock @@ -2,6 +2,17 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "aes" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35f0f96ce78e38c3dc6d8948aa8163d06385be74000f3c7a95bf1eef35d3ea32" +dependencies = [ + "cipher", + "cpubits", + "cpufeatures", +] + [[package]] name = "aho-corasick" version = "1.1.5" @@ -172,6 +183,16 @@ dependencies = [ "rand_core", ] +[[package]] +name = "cipher" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8cf2a2c93cd704877c0858356ed03480ff301ee950b43f1cbe4573b088bfa6c" +dependencies = [ + "crypto-common", + "inout", +] + [[package]] name = "cmake" version = "0.1.58" @@ -213,6 +234,12 @@ version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" +[[package]] +name = "cpubits" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15b85f9c39137c3a891689859392b1bd49812121d0d61c9caf00d46ed5ce06ae" + [[package]] name = "cpufeatures" version = "0.3.1" @@ -648,6 +675,15 @@ dependencies = [ "hashbrown", ] +[[package]] +name = "inout" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4250ce6452e92010fdf7268ccc5d14faa80bb12fc741938534c58f16804e03c7" +dependencies = [ + "hybrid-array", +] + [[package]] name = "ipnet" version = "2.12.2" @@ -1209,7 +1245,9 @@ dependencies = [ name = "segmentor" version = "0.1.0" dependencies = [ + "aes", "axum", + "base64 0.23.1", "bytes", "hyper-util", "reqwest", diff --git a/fuzz/Cargo.toml b/fuzz/Cargo.toml index 1c3e43e..7ad7b39 100644 --- a/fuzz/Cargo.toml +++ b/fuzz/Cargo.toml @@ -18,3 +18,10 @@ path = "fuzz_targets/media_pipeline.rs" test = false doc = false bench = false + +[[bin]] +name = "avc-slice-header" +path = "fuzz_targets/avc_slice_header.rs" +test = false +doc = false +bench = false diff --git a/fuzz/fuzz_targets/avc_slice_header.rs b/fuzz/fuzz_targets/avc_slice_header.rs new file mode 100644 index 0000000..138473c --- /dev/null +++ b/fuzz/fuzz_targets/avc_slice_header.rs @@ -0,0 +1,7 @@ +#![no_main] + +use libfuzzer_sys::fuzz_target; + +fuzz_target!(|data: &[u8]| { + segmentor::fuzzing::exercise_avc_slice_header(data); +}); diff --git a/src/asset.rs b/src/asset.rs index 4e2d810..537a7da 100644 --- a/src/asset.rs +++ b/src/asset.rs @@ -14,6 +14,15 @@ use crate::subtitle::{self, Subtitle}; use crate::{fmp4, mp4, segment}; use std::sync::Arc; +/// What a load adds to the file itself: sidecar subtitles, a URL version chosen by the caller (a +/// clip's), and content encryption. +#[derive(Debug, Default)] +pub(crate) struct Extras { + pub(crate) subtitles: Vec, + pub(crate) version: Option, + pub(crate) encryption: Option>, +} + #[derive(Debug)] pub(crate) struct PackagedAsset { source: MediaSourceKind, @@ -24,6 +33,7 @@ pub(crate) struct PackagedAsset { version: String, rendered: RenderedManifests, subtitles: Vec, + protection: Option, } /// Playlists and manifests rendered once at load so requests never walk sample tables. @@ -56,14 +66,15 @@ impl PackagedAsset { segment_duration_ms: u64, limits: &LimitsConfig, ) -> Result { - Self::load_with_subtitles(source, Vec::new(), segment_duration_ms, limits).await + Self::load_with(source, Extras::default(), segment_duration_ms, limits).await } - /// Like [`Self::load`], with sidecar subtitle files already fetched. Each is validated and - /// moved onto the asset's timeline; one bad file fails the whole asset. - pub(crate) async fn load_with_subtitles( + /// Like [`Self::load`], with sidecar subtitle files already fetched and, optionally, content + /// encryption. Each subtitle is validated and moved onto the asset's timeline; one bad file + /// fails the whole asset. + pub(crate) async fn load_with( source: MediaSourceKind, - subtitles: Vec, + extras: Extras, segment_duration_ms: u64, limits: &LimitsConfig, ) -> Result { @@ -75,10 +86,9 @@ impl PackagedAsset { source, index, &metadata, - subtitles, segment_duration_ms, &limits, - None, + extras, ) }) .await @@ -86,31 +96,52 @@ impl PackagedAsset { } /// Plans, writes init segments, and renders playlists for an already parsed index: a whole - /// file's, or one trimmed to a clip (see `clip::trim`). `version` replaces the URL version - /// derived from the content, for a clip whose version must also cover its window. + /// file's, or one trimmed to a clip (see `clip::trim`). `extras.version` replaces the URL + /// version derived from the content, for a clip whose version must also cover its window. pub(crate) fn assemble( source: MediaSourceKind, index: MediaIndex, metadata: &Metadata, - subtitles: Vec, segment_duration_ms: u64, limits: &LimitsConfig, - version: Option, + extras: Extras, ) -> Result { + let Extras { + subtitles, + version, + encryption, + } = extras; let plan = segment::plan(&index, segment_duration_ms, limits)?; let subtitles = prepare_subtitles(&index, subtitles)?; + let protection = encryption + .map(|encryption| { + crate::cenc::AssetProtection::new(encryption, &index.tracks, metadata) + }) + .transpose()?; let init_segments = index .tracks .iter() .map(|track| { - fmp4::write_init_segment(metadata, track.id) - .map(Bytes::from) - .map(|bytes| (track.key, bytes)) + let bytes = match protection + .as_ref() + .and_then(|p| p.track(track.id).map(|t| (p, t))) + { + Some((asset, track_protection)) => fmp4::write_protected_init_segment( + metadata, + track.id, + &track_protection.init(&asset.pssh), + ), + None => fmp4::write_init_segment(metadata, track.id), + }?; + Ok((track.key, Bytes::from(bytes))) }) .collect::>>()?; - let version = version.unwrap_or_else(|| version_of(&index, &subtitles)); + let encryption_ref = protection.as_ref().map(|p| p.encryption.as_ref()); + let version = version.unwrap_or_else(|| version_of(&index, &subtitles, encryption_ref)); let rendered = RenderedManifests::render( - Presentation::new(&index.tracks, &plan, &version).with_subtitles(&subtitles), + Presentation::new(&index.tracks, &plan, &version) + .with_subtitles(&subtitles) + .with_encryption(encryption_ref), )?; Ok(Self { source, @@ -121,6 +152,7 @@ impl PackagedAsset { version, rendered, subtitles, + protection, }) } @@ -132,6 +164,35 @@ impl PackagedAsset { pub(crate) fn presentation(&self) -> Presentation<'_> { Presentation::new(&self.index.tracks, &self.plan, &self.version) .with_subtitles(&self.subtitles) + .with_encryption(self.encryption()) + } + + /// The content keys and DRM systems this asset is encrypted with, if any. + pub(crate) fn encryption(&self) -> Option<&crate::cenc::Encryption> { + self.protection + .as_ref() + .map(|protection| protection.encryption.as_ref()) + } + + /// Marks an encrypted track's segment as still to be encrypted once its bytes are read. + fn with_encryption( + &self, + track: &Track, + segment: TrackSegment, + sequence_number: u32, + mut prepared: fmp4::PreparedSegment, + ) -> fmp4::PreparedSegment { + if let Some(protection) = self.protection.as_ref().and_then(|p| p.track(track.id)) { + prepared.encryption = Some(Box::new(crate::cenc::PendingEncryption { + track_id: track.id, + kind: track.kind, + samples: track.samples[segment.first_sample..segment.end_sample].to_vec(), + decode_time: segment.decode_time, + sequence_number, + protection: Arc::clone(protection), + })); + } + prepared } pub(crate) fn init_segment(&self, key: TrackKey) -> Result { @@ -188,7 +249,8 @@ impl PackagedAsset { let sequence_number = frame_index .checked_add(1) .ok_or_else(|| Error::InvalidMedia("sequence number overflow".to_owned()))?; - fmp4::prepare_media_segment(track, segment, sequence_number, &self.limits) + let prepared = fmp4::prepare_media_segment(track, segment, sequence_number, &self.limits)?; + Ok(self.with_encryption(track, segment, sequence_number, prepared)) } /// The HLS playlist that lists one subtitle file. @@ -248,7 +310,9 @@ impl PackagedAsset { .copied() .ok_or_else(|| Error::InvalidMedia("segment is missing a track".to_owned()))?; - fmp4::prepare_media_segment(track, track_segment, sequence_number, &self.limits) + let prepared = + fmp4::prepare_media_segment(track, track_segment, sequence_number, &self.limits)?; + Ok(self.with_encryption(track, track_segment, sequence_number, prepared)) } /// Logs what packaging left out or adjusted, so an operator can see why a file behaves as it @@ -383,7 +447,11 @@ fn prepare_subtitles(index: &MediaIndex, subtitles: Vec) -> Result String { +fn version_of( + index: &MediaIndex, + subtitles: &[Subtitle], + encryption: Option<&crate::cenc::Encryption>, +) -> String { use std::fmt::Write; use sha2::{Digest, Sha256}; @@ -408,6 +476,11 @@ fn version_of(index: &MediaIndex, subtitles: &[Subtitle]) -> String { hasher.update(part); } } + // Nothing is added when clear, so a clear asset keeps the version it always had. + if let Some(encryption) = encryption { + hasher.update(b"cbcs"); + hasher.update(encryption.fingerprint()); + } hasher .finalize() .iter() @@ -445,7 +518,7 @@ mod tests { assert!(asset.version().bytes().all(|byte| byte.is_ascii_hexdigit())); assert_ne!(asset.version(), moov_only); assert_eq!( - version_of(&asset.index, &[]), + version_of(&asset.index, &[], None), asset.version(), "and it is stable" ); @@ -495,10 +568,12 @@ mod tests { source, index, &metadata, - Vec::new(), 1000, &limits, - Some("feedfacefeedface".to_owned()), + Extras { + version: Some("feedfacefeedface".to_owned()), + ..Extras::default() + }, ) .unwrap(); diff --git a/src/cenc/avc.rs b/src/cenc/avc.rs new file mode 100644 index 0000000..76bdbb8 --- /dev/null +++ b/src/cenc/avc.rs @@ -0,0 +1,586 @@ +//! Just enough H.264 (ITU-T H.264, 7.3) to find where each slice header ends, so `cbcs` can leave +//! it clear (ISO/IEC 23001-7, 10.4). Every read is bounded by the NAL unit; anything this does +//! not understand fails closed. + +use std::collections::HashMap; + +use super::bits::{BitReader, Rbsp}; +use crate::error::{Error, Result}; +use crate::mp4::boxes::{Reader, optional_child}; +use crate::mp4::codec::visual_entry; + +pub(super) const MAX_SLICE_HEADER_BYTES: usize = 4096; +const MAX_PARAMETER_SET_BYTES: usize = 64 * 1024; +const HIGH_PROFILES: [u32; 13] = [100, 110, 122, 244, 44, 83, 86, 118, 128, 138, 139, 134, 135]; + +#[derive(Debug, Clone)] +struct Sps { + chroma_array_type: u32, + separate_colour_plane: bool, + log2_max_frame_num: usize, + poc_type: u32, + log2_max_poc_lsb: usize, + delta_pic_order_always_zero: bool, + frame_mbs_only: bool, +} + +/// The PPS flags are the syntax elements themselves (H.264 7.3.2.2), not a state machine. +#[allow( + clippy::struct_excessive_bools, + reason = "mirrors the PPS syntax elements" +)] +#[derive(Debug, Clone)] +pub(super) struct Pps { + sps_id: u32, + entropy_coding: bool, + bottom_field_pic_order_in_frame_present: bool, + num_ref_idx_l0_default: u32, + num_ref_idx_l1_default: u32, + weighted_pred: bool, + weighted_bipred_idc: u32, + deblocking_filter_control_present: bool, + redundant_pic_cnt_present: bool, +} + +/// A track's parameter sets and NAL length size, from `avcC`, updated by in-band sets. +#[derive(Debug, Clone)] +pub(crate) struct AvcParameters { + nal_length_size: usize, + sps: HashMap, + pub(super) pps: HashMap, + #[cfg(test)] + raw_sps: Vec>, + #[cfg(test)] + raw_pps: Vec>, +} + +impl AvcParameters { + /// No parameter sets yet (for the fuzz entry point). + pub(crate) fn empty(nal_length_size: usize) -> Self { + Self { + nal_length_size, + sps: HashMap::new(), + pps: HashMap::new(), + #[cfg(test)] + raw_sps: Vec::new(), + #[cfg(test)] + raw_pps: Vec::new(), + } + } + + /// Reads `avcC` from a visual sample entry's payload. + pub(crate) fn from_sample_entry(entry: &[u8]) -> Result { + let (_, _, children) = visual_entry(entry)?; + let config = optional_child(children, *b"avcC")? + .ok_or_else(|| Error::Unsupported("H.264 without avcC".to_owned()))?; + let mut reader = Reader::new(config.payload); + reader.skip(4)?; + let mut parameters = Self { + nal_length_size: usize::from(reader.u8()? & 3) + 1, + sps: HashMap::new(), + pps: HashMap::new(), + #[cfg(test)] + raw_sps: Vec::new(), + #[cfg(test)] + raw_pps: Vec::new(), + }; + let sps_count = reader.u8()? & 0x1f; + for _ in 0..sps_count { + let length = usize::from(reader.u16()?); + parameters.update(reader.take(length)?)?; + } + let pps_count = reader.u8()?; + for _ in 0..pps_count { + let length = usize::from(reader.u16()?); + parameters.update(reader.take(length)?)?; + } + Ok(parameters) + } + + pub(crate) const fn nal_length_size(&self) -> usize { + self.nal_length_size + } + + /// Takes in an SPS or PPS NAL unit (header byte included); other NAL units are ignored. + pub(crate) fn update(&mut self, nal: &[u8]) -> Result<()> { + match nal.first().map(|header| header & 0x1f) { + Some(7) => { + let (id, sps) = parse_sps(nal)?; + self.sps.insert(id, sps); + #[cfg(test)] + self.raw_sps.push(nal.to_vec()); + } + Some(8) => { + let (id, pps) = parse_pps(nal)?; + self.pps.insert(id, pps); + #[cfg(test)] + self.raw_pps.push(nal.to_vec()); + } + _ => {} + } + Ok(()) + } + + /// How many bytes of this slice NAL unit (header byte included) must stay clear. + pub(crate) fn clear_bytes(&self, nal: &[u8]) -> Result { + let bits = self.header_bits(nal)?; + Rbsp::new(nal, MAX_SLICE_HEADER_BYTES) + .nal_bytes_covering(bits) + .ok_or_else(|| Error::InvalidMedia("a slice header is truncated".to_owned())) + } + + /// The slice header's length in RBSP bits, NAL header byte included (H.264 7.3.3). + pub(super) fn header_bits(&self, nal: &[u8]) -> Result { + let rbsp = Rbsp::new(nal, MAX_SLICE_HEADER_BYTES); + let mut r = rbsp.reader(); + r.skip(1)?; + let nal_ref_idc = r.bits(2)?; + let nal_type = r.bits(5)?; + if !matches!(nal_type, 1 | 5) { + return Err(Error::Unsupported(format!( + "H.264 NAL unit type {nal_type} cannot be encrypted" + ))); + } + r.ue()?; // first_mb_in_slice + let raw_type = r.ue()?; + if raw_type > 9 { + return Err(Error::InvalidMedia( + "an H.264 slice type is out of range".to_owned(), + )); + } + let slice_type = raw_type % 5; + let pps = self + .pps + .get(&r.ue()?) + .ok_or_else(|| Error::InvalidMedia("a slice names an unknown PPS".to_owned()))?; + let sps = self + .sps + .get(&pps.sps_id) + .ok_or_else(|| Error::InvalidMedia("a PPS names an unknown SPS".to_owned()))?; + let (p, b, i, sp, si) = ( + slice_type == 0, + slice_type == 1, + slice_type == 2, + slice_type == 3, + slice_type == 4, + ); + if sps.separate_colour_plane { + r.skip(2)?; + } + r.skip(sps.log2_max_frame_num)?; + let mut field_pic = false; + if !sps.frame_mbs_only { + field_pic = r.bit()?; + if field_pic { + r.skip(1)?; + } + } + if nal_type == 5 { + r.ue()?; // idr_pic_id + } + if sps.poc_type == 0 { + r.skip(sps.log2_max_poc_lsb)?; + if pps.bottom_field_pic_order_in_frame_present && !field_pic { + r.se()?; + } + } + if sps.poc_type == 1 && !sps.delta_pic_order_always_zero { + r.se()?; + if pps.bottom_field_pic_order_in_frame_present && !field_pic { + r.se()?; + } + } + if pps.redundant_pic_cnt_present { + r.ue()?; + } + if b { + r.skip(1)?; // direct_spatial_mv_pred_flag + } + let (mut l0, mut l1) = (pps.num_ref_idx_l0_default, pps.num_ref_idx_l1_default); + if (p || sp || b) && r.bit()? { + l0 = ref_count(&mut r)?; + if b { + l1 = ref_count(&mut r)?; + } + } + if !i && !si { + list_modification(&mut r)?; + if b { + list_modification(&mut r)?; + } + } + if (pps.weighted_pred && (p || sp)) || (pps.weighted_bipred_idc == 1 && b) { + pred_weight_table(&mut r, sps.chroma_array_type, l0, if b { l1 } else { 0 })?; + } + if nal_ref_idc != 0 { + dec_ref_pic_marking(&mut r, nal_type == 5)?; + } + if pps.entropy_coding && !i && !si { + r.ue()?; // cabac_init_idc + } + r.se()?; // slice_qp_delta + if sp || si { + if sp { + r.skip(1)?; + } + r.se()?; + } + if pps.deblocking_filter_control_present && r.ue()? != 1 { + r.se()?; + r.se()?; + } + Ok(r.position()) + } +} + +fn ref_count(r: &mut BitReader<'_>) -> Result { + let count = r.ue()?.checked_add(1).filter(|count| *count <= 32); + count.ok_or_else(|| Error::InvalidMedia("too many H.264 reference indices".to_owned())) +} + +fn list_modification(r: &mut BitReader<'_>) -> Result<()> { + if !r.bit()? { + return Ok(()); + } + for _ in 0..=64 { + match r.ue()? { + 0..=2 => { + r.ue()?; + } + 3 => return Ok(()), + _ => break, + } + } + Err(Error::InvalidMedia( + "an H.264 reference list modification is malformed".to_owned(), + )) +} + +fn pred_weight_table(r: &mut BitReader<'_>, chroma: u32, l0: u32, l1: u32) -> Result<()> { + r.ue()?; + if chroma != 0 { + r.ue()?; + } + for _ in 0..l0.saturating_add(l1) { + if r.bit()? { + r.se()?; + r.se()?; + } + if chroma != 0 && r.bit()? { + for _ in 0..4 { + r.se()?; + } + } + } + Ok(()) +} + +fn dec_ref_pic_marking(r: &mut BitReader<'_>, idr: bool) -> Result<()> { + if idr { + return r.skip(2); + } + if !r.bit()? { + return Ok(()); + } + for _ in 0..=64 { + let operation = r.ue()?; + match operation { + 0 => return Ok(()), + 1 | 2 | 4 | 6 => { + r.ue()?; + } + 3 => { + r.ue()?; + r.ue()?; + } + 5 => {} + _ => break, + } + } + Err(Error::InvalidMedia( + "an H.264 reference marking is malformed".to_owned(), + )) +} + +fn parse_sps(nal: &[u8]) -> Result<(u32, Sps)> { + let rbsp = Rbsp::new(nal, MAX_PARAMETER_SET_BYTES); + let mut r = rbsp.reader(); + r.skip(8)?; + let profile_idc = r.bits(8)?; + r.skip(16)?; + let id = r.ue()?; + let (mut chroma_format_idc, mut separate) = (1, false); + if HIGH_PROFILES.contains(&profile_idc) { + chroma_format_idc = r.ue()?; + if chroma_format_idc > 3 { + return Err(Error::InvalidMedia( + "an H.264 chroma format is out of range".to_owned(), + )); + } + if chroma_format_idc == 3 { + separate = r.bit()?; + } + r.ue()?; + r.ue()?; + r.skip(1)?; + if r.bit()? { + let lists = if chroma_format_idc == 3 { 12 } else { 8 }; + for index in 0..lists { + if r.bit()? { + skip_scaling_list(&mut r, if index < 6 { 16 } else { 64 })?; + } + } + } + } + let log2_max_frame_num = small(r.ue()?, 12)? + 4; + let poc_type = r.ue()?; + let (mut log2_max_poc_lsb, mut delta_pic_order_always_zero) = (0, false); + match poc_type { + 0 => log2_max_poc_lsb = small(r.ue()?, 12)? + 4, + 1 => { + delta_pic_order_always_zero = r.bit()?; + r.se()?; + r.se()?; + for _ in 0..small(r.ue()?, 255)? { + r.se()?; + } + } + 2 => {} + _ => { + return Err(Error::InvalidMedia( + "an H.264 POC type is out of range".to_owned(), + )); + } + } + r.ue()?; + r.skip(1)?; + r.ue()?; + r.ue()?; + let frame_mbs_only = r.bit()?; + Ok(( + id, + Sps { + chroma_array_type: if separate { 0 } else { chroma_format_idc }, + separate_colour_plane: separate, + log2_max_frame_num, + poc_type, + log2_max_poc_lsb, + delta_pic_order_always_zero, + frame_mbs_only, + }, + )) +} + +fn parse_pps(nal: &[u8]) -> Result<(u32, Pps)> { + let rbsp = Rbsp::new(nal, MAX_PARAMETER_SET_BYTES); + let mut r = rbsp.reader(); + r.skip(8)?; + let id = r.ue()?; + let sps_id = r.ue()?; + let entropy_coding = r.bit()?; + let bottom_field_pic_order_in_frame_present = r.bit()?; + if r.ue()? != 0 { + return Err(Error::Unsupported( + "H.264 with slice groups (FMO) cannot be encrypted".to_owned(), + )); + } + let num_ref_idx_l0_default = ref_count(&mut r)?; + let num_ref_idx_l1_default = ref_count(&mut r)?; + let weighted_pred = r.bit()?; + let weighted_bipred_idc = r.bits(2)?; + r.se()?; + r.se()?; + r.se()?; + let deblocking_filter_control_present = r.bit()?; + r.skip(1)?; + let redundant_pic_cnt_present = r.bit()?; + Ok(( + id, + Pps { + sps_id, + entropy_coding, + bottom_field_pic_order_in_frame_present, + num_ref_idx_l0_default, + num_ref_idx_l1_default, + weighted_pred, + weighted_bipred_idc, + deblocking_filter_control_present, + redundant_pic_cnt_present, + }, + )) +} + +fn skip_scaling_list(r: &mut BitReader<'_>, size: usize) -> Result<()> { + let (mut last, mut next) = (8i64, 8i64); + for _ in 0..size { + if next != 0 { + next = (last + r.se()? + 256).rem_euclid(256); + } + if next != 0 { + last = next; + } + } + Ok(()) +} + +fn small(value: u32, max: u32) -> Result { + if value > max { + return Err(Error::InvalidMedia( + "an H.264 parameter is out of range".to_owned(), + )); + } + usize::try_from(value) + .map_err(|_| Error::InvalidMedia("an H.264 parameter is out of range".to_owned())) +} + +#[cfg(test)] +pub(crate) mod tests_support { + use super::AvcParameters; + + /// The first IDR slice NAL unit of `name`'s first sample, with the track's parameters. + pub(crate) fn first_idr(name: &str) -> (AvcParameters, Vec) { + let (parameters, samples) = super::tests::samples(name); + let length = parameters.nal_length_size(); + let sample = &samples[0]; + let mut at = 0; + while at < sample.len() { + let size = sample[at..at + length] + .iter() + .fold(0usize, |v, b| v << 8 | usize::from(*b)); + let nal = &sample[at + length..at + length + size]; + if nal[0] & 0x1f == 5 { + return (parameters, nal.to_vec()); + } + at += length + size; + } + panic!("{name} starts with an IDR slice"); + } +} + +impl AvcParameters { + /// Removes and returns one SPS and one PPS as NAL units, for in-band tests. + #[cfg(test)] + pub(crate) fn take_all_for_test(&mut self) -> (Vec, Vec) { + let sps = std::mem::take(&mut self.raw_sps); + let pps = std::mem::take(&mut self.raw_pps); + self.sps.clear(); + self.pps.clear(); + ( + sps.into_iter().next().unwrap(), + pps.into_iter().next().unwrap(), + ) + } +} + +#[cfg(test)] +pub(crate) mod tests { + use std::sync::Arc; + + use super::*; + use crate::config::LimitsConfig; + use crate::media::{MediaIndex, TrackKind}; + use crate::source::{ByteRange, LocalMediaSource, MediaSourceKind}; + use crate::testutil::fixture; + + /// The fixture's parameters and every video sample's bytes. + pub(crate) fn samples(name: &str) -> (AvcParameters, Vec>) { + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap(); + runtime.block_on(async { + let source = + MediaSourceKind::Local(Arc::new(LocalMediaSource::open(fixture(name)).unwrap())); + let parsed = crate::mp4::parse(&source, &LimitsConfig::default()) + .await + .unwrap(); + let index: &MediaIndex = &parsed.index; + let video = index + .tracks + .iter() + .find(|t| t.kind == TrackKind::Video) + .unwrap(); + let (_, entry) = crate::fmp4::sample_entry(&parsed.metadata, video.id).unwrap(); + let parameters = AvcParameters::from_sample_entry(&entry).unwrap(); + let mut bytes = Vec::new(); + for sample in &video.samples { + let range = ByteRange::new(sample.offset, u64::from(sample.size)); + bytes.push(source.read_range(range).await.unwrap().to_vec()); + } + (parameters, bytes) + }) + } + + fn nal_units(sample: &[u8], length_size: usize) -> Vec<&[u8]> { + let mut units = Vec::new(); + let mut at = 0; + while at < sample.len() { + let length = sample[at..at + length_size] + .iter() + .fold(0usize, |value, byte| value << 8 | usize::from(*byte)); + units.push(&sample[at + length_size..at + length_size + length]); + at += length_size + length; + } + units + } + + #[test] + fn every_slice_header_ends_where_cabac_alignment_begins() { + // The fixtures are CABAC: after the slice header come cabac_alignment_one_bit bits, all + // ones, up to a byte boundary. A parse that stopped early or late would land on bits that + // are not all ones (or on the wrong byte), so this checks every header's exact length. + for name in ["h264-aac.mp4", "rendition-720p.mp4"] { + let (parameters, samples) = samples(name); + let mut slices = 0; + for sample in &samples { + for nal in nal_units(sample, parameters.nal_length_size()) { + if !matches!(nal[0] & 0x1f, 1 | 5) { + continue; + } + let bits = parameters.header_bits(nal).unwrap(); + let rbsp = Rbsp::new(nal, MAX_SLICE_HEADER_BYTES); + let mut reader = rbsp.reader(); + reader.skip(bits).unwrap(); + while !reader.position().is_multiple_of(8) { + assert!(reader.bit().unwrap(), "{name}: alignment bits are ones"); + } + assert_eq!( + parameters.clear_bytes(nal).unwrap(), + rbsp.nal_bytes_covering(bits).unwrap() + ); + assert!(parameters.clear_bytes(nal).unwrap() < nal.len()); + slices += 1; + } + } + assert!(slices >= 90, "{name}: every frame has a slice ({slices})"); + } + } + + #[test] + fn an_in_band_pps_is_used_for_the_slices_after_it() { + let (mut parameters, samples) = samples("h264-aac.mp4"); + let slice = nal_units(&samples[0], parameters.nal_length_size()) + .into_iter() + .find(|nal| nal[0] & 0x1f == 5) + .unwrap() + .to_vec(); + let expected = parameters.clear_bytes(&slice).unwrap(); + // Move the only PPS to an in-band copy: without it the slice cannot be parsed, with it + // the parse is the same. + let pps = parameters.pps.drain().next().unwrap(); + assert!(parameters.clear_bytes(&slice).is_err(), "no PPS, no parse"); + parameters.pps.insert(pps.0, pps.1); + assert_eq!(parameters.clear_bytes(&slice).unwrap(), expected); + } + + #[test] + fn unsupported_or_corrupt_slices_fail_closed() { + let (parameters, _) = samples("h264-aac.mp4"); + + assert!(parameters.clear_bytes(&[0x65]).is_err(), "truncated"); + assert!( + parameters.clear_bytes(&[0x65, 0x88, 0xff, 0xff]).is_err(), + "unknown PPS" + ); + } +} diff --git a/src/cenc/bits.rs b/src/cenc/bits.rs new file mode 100644 index 0000000..fc78ffe --- /dev/null +++ b/src/cenc/bits.rs @@ -0,0 +1,160 @@ +//! Reading the RBSP of a NAL unit: emulation-prevention bytes removed, each RBSP byte mapped +//! back to the NAL byte it came from, so a header's length in bits becomes a clear length in NAL +//! bytes. + +use crate::error::{Error, Result}; + +pub(super) struct Rbsp { + bytes: Vec, + origin: Vec, +} + +impl Rbsp { + /// Unescapes at most `limit` NAL bytes: enough for any header read from them. + pub(super) fn new(nal: &[u8], limit: usize) -> Self { + let size = nal.len().min(limit); + let mut bytes = Vec::with_capacity(size); + let mut origin = Vec::with_capacity(size); + let mut zeros = 0u8; + for (index, &byte) in nal.iter().enumerate().take(limit) { + if zeros >= 2 && byte == 3 { + zeros = 0; + continue; + } + zeros = if byte == 0 { + zeros.saturating_add(1) + } else { + 0 + }; + bytes.push(byte); + origin.push(index); + } + Self { bytes, origin } + } + + #[cfg(test)] + pub(super) fn bytes(&self) -> &[u8] { + &self.bytes + } + + pub(super) fn reader(&self) -> BitReader<'_> { + BitReader { + data: &self.bytes, + bit: 0, + } + } + + /// How many NAL bytes cover the first `bits` RBSP bits: through the byte holding the last. + pub(super) fn nal_bytes_covering(&self, bits: usize) -> Option { + if bits == 0 { + return Some(0); + } + self.origin.get((bits - 1) / 8).map(|index| index + 1) + } +} + +pub(super) struct BitReader<'a> { + data: &'a [u8], + bit: usize, +} + +impl BitReader<'_> { + pub(super) const fn position(&self) -> usize { + self.bit + } + + pub(super) fn bit(&mut self) -> Result { + let byte = *self.data.get(self.bit / 8).ok_or_else(truncated)?; + let value = (byte >> (7 - self.bit % 8)) & 1; + self.bit += 1; + Ok(value == 1) + } + + pub(super) fn bits(&mut self, count: usize) -> Result { + if count > 32 { + return Err(Error::InvalidMedia( + "a field is wider than 32 bits".to_owned(), + )); + } + let mut value = 0u64; + for _ in 0..count { + value = value << 1 | u64::from(self.bit()?); + } + u32::try_from(value).map_err(|_| truncated()) + } + + pub(super) fn skip(&mut self, count: usize) -> Result<()> { + let end = self.bit.checked_add(count).ok_or_else(truncated)?; + if end > self.data.len() * 8 { + return Err(truncated()); + } + self.bit = end; + Ok(()) + } + + /// An unsigned exp-Golomb code (H.264 9.1). + pub(super) fn ue(&mut self) -> Result { + let mut zeros = 0; + while !self.bit()? { + zeros += 1; + if zeros > 31 { + return Err(Error::InvalidMedia( + "an exp-Golomb code is too long".to_owned(), + )); + } + } + let suffix = u64::from(self.bits(zeros)?); + u32::try_from((1u64 << zeros) - 1 + suffix).map_err(|_| truncated()) + } + + /// A signed exp-Golomb code (H.264 9.1.1). + pub(super) fn se(&mut self) -> Result { + let code = i64::from(self.ue()?); + let magnitude = (code + 1) / 2; + Ok(if code % 2 == 1 { magnitude } else { -magnitude }) + } +} + +fn truncated() -> Error { + Error::InvalidMedia("a slice or parameter set is truncated".to_owned()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn emulation_prevention_bytes_are_removed_and_mapped_back() { + let nal = [0x65, 0x00, 0x00, 0x03, 0x01, 0xff]; + + let rbsp = Rbsp::new(&nal, 64); + + assert_eq!(rbsp.bytes(), &[0x65, 0x00, 0x00, 0x01, 0xff]); + // RBSP byte 3 (0x01) came from NAL byte 4: the 0x03 in between is skipped. + assert_eq!(rbsp.nal_bytes_covering(32), Some(5)); + assert_eq!(rbsp.nal_bytes_covering(8), Some(1)); + } + + #[test] + fn exp_golomb_codes_read_back() { + // 1 | 010 | 011 | 00100 | 00101 => ue 0, 1, 2, 3; then se from 00101 (ue 4) = -2. + let data = [0b1010_0110, 0b0100_0010, 0b1000_0000]; + let rbsp = Rbsp::new(&data, 64); + let mut reader = rbsp.reader(); + + assert_eq!(reader.ue().unwrap(), 0); + assert_eq!(reader.ue().unwrap(), 1); + assert_eq!(reader.ue().unwrap(), 2); + assert_eq!(reader.ue().unwrap(), 3); + assert_eq!(reader.se().unwrap(), -2); + assert_eq!(reader.position(), 17); + } + + #[test] + fn reading_past_the_end_is_an_error() { + let rbsp = Rbsp::new(&[0x00], 64); + let mut reader = rbsp.reader(); + + assert!(reader.ue().is_err()); + } +} diff --git a/src/cenc/cipher.rs b/src/cenc/cipher.rs new file mode 100644 index 0000000..7edfe55 --- /dev/null +++ b/src/cenc/cipher.rs @@ -0,0 +1,123 @@ +//! AES-128-CBC with the `cbcs` pattern (ISO/IEC 23001-7, 10.4). A library CBC mode would chain +//! through the skipped blocks, so the chaining is done here over a raw block cipher. + +use aes::Aes128; +use aes::cipher::{BlockCipherEncrypt, KeyInit}; + +/// How many 16-byte blocks are encrypted, then skipped, repeating across a protected range. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) struct Pattern { + pub(crate) crypt: u8, + pub(crate) skip: u8, +} + +impl Pattern { + /// Video in `cbcs`: one block in ten. + pub(crate) const VIDEO: Self = Self { crypt: 1, skip: 9 }; + /// Audio in `cbcs`: no pattern; every whole block. + pub(crate) const FULL: Self = Self { crypt: 0, skip: 0 }; +} + +pub(crate) struct Cipher(Aes128); + +impl Cipher { + pub(crate) fn new(key: &[u8; 16]) -> Self { + Self(Aes128::new(&(*key).into())) + } + + /// Encrypts one protected range in place, starting the chain from `iv`. Skipped blocks and a + /// trailing partial block stay clear. + pub(crate) fn encrypt_range(&self, iv: &[u8; 16], data: &mut [u8], pattern: Pattern) { + let crypt = usize::from(pattern.crypt); + let period = crypt + usize::from(pattern.skip); + let mut chain = *iv; + for (index, block) in data.as_chunks_mut::<16>().0.iter_mut().enumerate() { + if period != 0 && index % period >= crypt { + continue; + } + for (byte, previous) in block.iter_mut().zip(chain) { + *byte ^= previous; + } + self.0.encrypt_block(block.into()); + chain = *block; + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn hex(text: &str) -> Vec { + (0..text.len()) + .step_by(2) + .map(|index| u8::from_str_radix(&text[index..index + 2], 16).unwrap()) + .collect() + } + + // NIST SP 800-38A, F.2.1 (CBC-AES128.Encrypt). + const KEY: &str = "2b7e151628aed2a6abf7158809cf4f3c"; + const IV: &str = "000102030405060708090a0b0c0d0e0f"; + const PLAIN: [&str; 4] = [ + "6bc1bee22e409f96e93d7e117393172a", + "ae2d8a571e03ac9c9eb76fac45af8e51", + "30c81c46a35ce411e5fbc1191a0a52ef", + "f69f2445df4f9b17ad2b417be66c3710", + ]; + const CIPHER: [&str; 4] = [ + "7649abac8119b246cee98e9b12e9197d", + "5086cb9b507219ee95db113a917678b2", + "73bed6b8e3c1743b7116e69e22229516", + "3ff1caa1681fac09120eca307586e1a7", + ]; + + fn cipher() -> (Cipher, [u8; 16]) { + ( + Cipher::new(&hex(KEY).try_into().unwrap()), + hex(IV).try_into().unwrap(), + ) + } + + #[test] + fn with_no_pattern_it_is_plain_cbc_and_leaves_a_partial_block_clear() { + let (cipher, iv) = cipher(); + let mut data = PLAIN + .iter() + .flat_map(|block| hex(block)) + .collect::>(); + data.extend_from_slice(b"tail"); + + cipher.encrypt_range(&iv, &mut data, Pattern::FULL); + + let expected = CIPHER + .iter() + .flat_map(|block| hex(block)) + .collect::>(); + assert_eq!(&data[..64], expected.as_slice()); + assert_eq!(&data[64..], b"tail"); + } + + #[test] + fn one_in_ten_chains_only_the_encrypted_blocks() { + let (cipher, iv) = cipher(); + // Blocks 0 and 10 are encrypted; their chain skips the nine clear blocks between them, + // so block 10 is encrypted exactly as the second block of plain CBC would be. + let mut data = vec![0x55; 16 * 20]; + data[..16].copy_from_slice(&hex(PLAIN[0])); + data[160..176].copy_from_slice(&hex(PLAIN[1])); + let original = data.clone(); + + cipher.encrypt_range(&iv, &mut data, Pattern::VIDEO); + + assert_eq!(&data[..16], hex(CIPHER[0]).as_slice()); + assert_eq!(&data[160..176], hex(CIPHER[1]).as_slice()); + for block in (1..20).filter(|block| *block != 10) { + let range = block * 16..block * 16 + 16; + assert_eq!( + data[range.clone()], + original[range], + "block {block} stays clear" + ); + } + } +} diff --git a/src/cenc/keys.rs b/src/cenc/keys.rs new file mode 100644 index 0000000..8e27279 --- /dev/null +++ b/src/cenc/keys.rs @@ -0,0 +1,607 @@ +//! Content keys and DRM systems, as the mapper supplies them, validated before any use. + +use std::fmt::{self, Write}; + +use base64::Engine; +use reqwest::Url; +use serde::Deserialize; +use sha2::{Digest, Sha256}; + +use crate::media::TrackKind; +use crate::mp4::boxes::Reader; + +pub(crate) const WIDEVINE: [u8; 16] = [ + 0xed, 0xef, 0x8b, 0xa9, 0x79, 0xd6, 0x4a, 0xce, 0xa3, 0xc8, 0x27, 0xdc, 0xd5, 0x1d, 0x21, 0xed, +]; +pub(crate) const FAIRPLAY: [u8; 16] = [ + 0x94, 0xce, 0x86, 0xfb, 0x07, 0xff, 0x4f, 0x43, 0xad, 0xb8, 0x93, 0xd2, 0xfa, 0x96, 0x8c, 0xa2, +]; +pub(crate) const PLAYREADY: [u8; 16] = [ + 0x9a, 0x04, 0xf0, 0x79, 0x98, 0x40, 0x42, 0x86, 0xab, 0x92, 0xe6, 0x5b, 0xe0, 0x88, 0x5f, 0x95, +]; +pub(crate) const CLEARKEY: [u8; 16] = [ + 0xe2, 0x71, 0x9d, 0x58, 0xa9, 0x85, 0xb3, 0xc9, 0x78, 0x1a, 0xb0, 0x30, 0xaf, 0x78, 0xd3, 0x0e, +]; + +const MAX_SYSTEMS: usize = 8; +const MAX_PSSH_BYTES: usize = 16 * 1024; +const MAX_URI_BYTES: usize = 2048; + +/// Sixteen bytes of content key. `Debug` never prints them, so no log line or panic can. +#[derive(Clone, PartialEq, Eq)] +pub(crate) struct KeyBytes([u8; 16]); + +impl KeyBytes { + pub(crate) const fn bytes(&self) -> &[u8; 16] { + &self.0 + } +} + +impl fmt::Debug for KeyBytes { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("KeyBytes()") + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct ContentKey { + pub(crate) key_id: [u8; 16], + pub(crate) key: KeyBytes, + /// The constant IV declared in `tenc`, restarted at every protected range. + pub(crate) iv: [u8; 16], +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum Keys { + All(ContentKey), + Split { + video: ContentKey, + audio: ContentKey, + }, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct DrmSystem { + pub(crate) system_id: [u8; 16], + /// A complete, validated `pssh` box. + pub(crate) pssh: Option>, + pub(crate) license_url: Option, + pub(crate) hls_uri: Option, +} + +/// What the mapper's `encryption` object asks for, validated. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct Encryption { + pub(crate) keys: Keys, + pub(crate) systems: Vec, +} + +impl Encryption { + pub(crate) const fn key_for(&self, kind: TrackKind) -> &ContentKey { + match (&self.keys, kind) { + (Keys::All(key), _) => key, + (Keys::Split { video, .. }, TrackKind::Video) => video, + (Keys::Split { audio, .. }, TrackKind::Audio) => audio, + } + } + + /// Each key once: split keys that are identical count as one. + pub(crate) fn distinct_keys(&self) -> Vec<&ContentKey> { + match &self.keys { + Keys::All(key) => vec![key], + Keys::Split { video, audio } if video == audio => vec![video], + Keys::Split { video, audio } => vec![video, audio], + } + } + + /// Key IDs in UUID form, for status reporting: never the keys. + pub(crate) fn key_ids(&self) -> Vec { + self.distinct_keys() + .into_iter() + .map(|key| uuid_string(&key.key_id)) + .collect() + } + + /// Everything the encrypted bytes and the signalling depend on, hashed, for the URL version. + /// Keys enter only as their own SHA-256. + pub(crate) fn fingerprint(&self) -> [u8; 32] { + let mut hasher = Sha256::new(); + let mut feed = |part: &[u8]| { + hasher.update((part.len() as u64).to_be_bytes()); + hasher.update(part); + }; + for key in self.distinct_keys() { + feed(&key.key_id); + feed(&Sha256::digest(key.key.bytes())); + feed(&key.iv); + } + for system in &self.systems { + feed(&system.system_id); + feed(system.pssh.as_deref().unwrap_or_default()); + feed(system.license_url.as_deref().unwrap_or_default().as_bytes()); + feed(system.hls_uri.as_deref().unwrap_or_default().as_bytes()); + } + hasher.finalize().into() + } +} + +/// The wire form of `encryption` (TDD 0009, "Wire format"). Unknown fields are ignored, as +/// everywhere in the mapper contract. +#[derive(Debug, Deserialize)] +pub(crate) struct WireEncryption { + scheme: String, + keys: Vec, + #[serde(default)] + systems: Vec, +} + +#[derive(Deserialize)] +struct WireKey { + tracks: Option, + key_id: String, + key: String, + iv: Option, +} + +/// The wire key is still a plain string, so `Debug` must not print it either. +impl fmt::Debug for WireKey { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("WireKey") + .field("tracks", &self.tracks) + .field("key_id", &self.key_id) + .field("key", &"") + .field("iv", &self.iv) + .finish() + } +} + +#[derive(Debug, Deserialize)] +struct WireSystem { + system_id: String, + pssh: Option, + license_url: Option, + hls_uri: Option, +} + +impl WireEncryption { + /// Checks every rule of TDD 0009's wire format. The reason names the field, never its value, + /// so a rejected answer cannot leak a key into a log or an error body. + pub(crate) fn validate(&self) -> Result { + if self.scheme != "cbcs" { + return Err("encryption scheme is not supported; only cbcs is".to_owned()); + } + let keys = self.keys()?; + let systems = self.systems()?; + // FairPlay's key line has no key ID, so a player could not tell the audio key apart. + if matches!(keys, Keys::Split { .. }) + && systems.iter().any(|system| system.system_id == FAIRPLAY) + { + return Err( + "FairPlay needs one key for all tracks (encryption keys tracks \"all\")".to_owned(), + ); + } + Ok(Encryption { keys, systems }) + } + + fn keys(&self) -> Result { + let mut all = None; + let mut video = None; + let mut audio = None; + for wire in &self.keys { + let key = ContentKey { + key_id: hex16(&wire.key_id, "key_id")?, + key: KeyBytes(hex16(&wire.key, "key")?), + iv: match &wire.iv { + Some(iv) => hex16(iv, "iv")?, + None => derived_iv(&hex16(&wire.key_id, "key_id")?), + }, + }; + let slot = match wire.tracks.as_deref() { + None | Some("all") => &mut all, + Some("video") => &mut video, + Some("audio") => &mut audio, + Some(_) => { + return Err("encryption key tracks must be all, video, or audio".to_owned()); + } + }; + if slot.replace(key).is_some() { + return Err(SHAPE.to_owned()); + } + } + match (all, video, audio) { + (Some(key), None, None) => Ok(Keys::All(key)), + (None, Some(video), Some(audio)) => Ok(Keys::Split { video, audio }), + _ => Err(SHAPE.to_owned()), + } + } + + fn systems(&self) -> Result, String> { + if self.systems.len() > MAX_SYSTEMS { + return Err(format!("encryption lists more than {MAX_SYSTEMS} systems")); + } + let mut systems: Vec = Vec::with_capacity(self.systems.len()); + for wire in &self.systems { + let system_id = uuid(&wire.system_id)?; + if systems.iter().any(|known| known.system_id == system_id) { + return Err("encryption lists a system_id twice".to_owned()); + } + let pssh = wire + .pssh + .as_deref() + .map(|encoded| decode_pssh(encoded, &system_id)) + .transpose()?; + let license_url = wire.license_url.as_deref().map(license_url).transpose()?; + let hls_uri = wire.hls_uri.as_deref().map(hls_uri).transpose()?; + if system_id == FAIRPLAY && hls_uri.is_none() { + return Err("FairPlay needs an hls_uri".to_owned()); + } + systems.push(DrmSystem { + system_id, + pssh, + license_url, + hls_uri, + }); + } + Ok(systems) + } +} + +const SHAPE: &str = + "encryption keys must be one for all tracks, or one for video and one for audio"; + +fn hex16(text: &str, field: &str) -> Result<[u8; 16], String> { + let bytes = text.as_bytes(); + if bytes.len() != 32 { + return Err(format!("encryption {field} must be 32 hex digits")); + } + let digit = |byte: u8| (byte as char).to_digit(16); + let mut out = [0; 16]; + for (index, pair) in bytes.as_chunks::<2>().0.iter().enumerate() { + let (Some(high), Some(low)) = (digit(pair[0]), digit(pair[1])) else { + return Err(format!("encryption {field} must be 32 hex digits")); + }; + out[index] = u8::try_from(high * 16 + low).expect("two hex digits fit in a byte"); + } + Ok(out) +} + +fn uuid(text: &str) -> Result<[u8; 16], String> { + let groups = text.split('-').map(str::len).collect::>(); + if groups != [8, 4, 4, 4, 12] { + return Err("encryption system_id must be a UUID".to_owned()); + } + hex16(&text.replace('-', ""), "system_id") +} + +fn derived_iv(key_id: &[u8; 16]) -> [u8; 16] { + let mut hasher = Sha256::new(); + hasher.update(b"segmentor cbcs iv"); + hasher.update(key_id); + hasher.finalize()[..16] + .try_into() + .expect("a SHA-256 digest has 16 bytes to spare") +} + +fn decode_pssh(encoded: &str, system_id: &[u8; 16]) -> Result, String> { + let bytes = base64::engine::general_purpose::STANDARD + .decode(encoded) + .map_err(|_| "encryption pssh must be base64".to_owned())?; + if bytes.len() > MAX_PSSH_BYTES { + return Err(format!( + "encryption pssh is larger than {MAX_PSSH_BYTES} bytes" + )); + } + match parse_pssh(&bytes) { + Some((id, _)) if id == *system_id => Ok(bytes), + Some(_) => Err("encryption pssh names a different system_id".to_owned()), + None => Err("encryption pssh is not a well-formed pssh box".to_owned()), + } +} + +/// A `pssh` box's system ID and data, or `None` when it is not exactly one well-formed box. +fn parse_pssh(bytes: &[u8]) -> Option<([u8; 16], &[u8])> { + let mut reader = Reader::new(bytes); + let size = usize::try_from(reader.u32().ok()?).ok()?; + if size != bytes.len() || reader.take(4).ok()? != b"pssh" { + return None; + } + let version = reader.full_box().ok()?; + let system_id: [u8; 16] = reader.take(16).ok()?.try_into().ok()?; + if version == 1 { + let kids = usize::try_from(reader.u32().ok()?).ok()?; + reader.take(kids.checked_mul(16)?).ok()?; + } else if version != 0 { + return None; + } + let data_size = usize::try_from(reader.u32().ok()?).ok()?; + let data = reader.take(data_size).ok()?; + reader.rest().is_empty().then_some((system_id, data)) +} + +/// The data of a `pssh` box the mapper supplied (already validated): `PlayReady`'s HLS URI needs +/// the `PlayReady` object it carries, not the whole box. +pub(crate) fn pssh_data(pssh: &[u8]) -> Option<&[u8]> { + parse_pssh(pssh).map(|(_, data)| data) +} + +fn license_url(text: &str) -> Result { + let url = Url::parse(text).map_err(|_| "encryption license_url is not a URL".to_owned())?; + if url.scheme() != "https" || text.len() > MAX_URI_BYTES { + return Err("encryption license_url must be an https URL".to_owned()); + } + Ok(url.to_string()) +} + +/// Written between double quotes in an HLS attribute, so quotes and control characters would +/// end it early or corrupt the playlist. +fn hls_uri(text: &str) -> Result { + if text.is_empty() + || text.len() > MAX_URI_BYTES + || text + .chars() + .any(|character| character == '"' || character.is_control()) + { + return Err("encryption hls_uri must be 1 to 2048 characters without quotes".to_owned()); + } + Ok(text.to_owned()) +} + +pub(crate) fn hex_string(bytes: &[u8]) -> String { + bytes + .iter() + .fold(String::with_capacity(bytes.len() * 2), |mut out, byte| { + write!(out, "{byte:02x}").expect("writing to a String cannot fail"); + out + }) +} + +pub(crate) fn uuid_string(bytes: &[u8; 16]) -> String { + let hex = hex_string(bytes); + format!( + "{}-{}-{}-{}-{}", + &hex[..8], + &hex[8..12], + &hex[12..16], + &hex[16..20], + &hex[20..] + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + const KID: &str = "0123456789abcdef0123456789abcdef"; + const KEY: &str = "00112233445566778899aabbccddeeff"; + + fn wire(json: &str) -> WireEncryption { + serde_json::from_str(json).expect("the test JSON is well-formed") + } + + fn one_key(extra: &str) -> String { + format!(r#"{{"scheme":"cbcs","keys":[{{"key_id":"{KID}","key":"{KEY}"}}]{extra}}}"#) + } + + /// A minimal version 0 `pssh` box for `system`, with `data` as its payload. + fn pssh(system: &[u8; 16], data: &[u8]) -> Vec { + let size = u32::try_from(32 + data.len()).unwrap(); + let mut boxed = size.to_be_bytes().to_vec(); + boxed.extend_from_slice(b"pssh"); + boxed.extend_from_slice(&[0, 0, 0, 0]); + boxed.extend_from_slice(system); + boxed.extend_from_slice(&u32::try_from(data.len()).unwrap().to_be_bytes()); + boxed.extend_from_slice(data); + boxed + } + + fn b64(bytes: &[u8]) -> String { + use base64::Engine; + base64::engine::general_purpose::STANDARD.encode(bytes) + } + + #[test] + fn one_key_covers_every_track_and_the_iv_is_derived_from_the_key_id() { + let encryption = wire(&one_key("")).validate().unwrap(); + + let video = encryption.key_for(TrackKind::Video); + assert_eq!(video, encryption.key_for(TrackKind::Audio)); + assert_eq!(hex_string(&video.key_id), KID); + let expected: [u8; 16] = { + use sha2::{Digest, Sha256}; + let mut hasher = Sha256::new(); + hasher.update(b"segmentor cbcs iv"); + hasher.update(video.key_id); + hasher.finalize()[..16].try_into().unwrap() + }; + assert_eq!(video.iv, expected); + } + + #[test] + fn video_and_audio_keys_are_kept_apart() { + let json = format!( + r#"{{"scheme":"cbcs","keys":[{{"tracks":"video","key_id":"{KID}","key":"{KEY}","iv":"{KEY}"}},{{"tracks":"audio","key_id":"{KEY}","key":"{KID}"}}]}}"# + ); + + let encryption = wire(&json).validate().unwrap(); + + assert_eq!( + hex_string(&encryption.key_for(TrackKind::Video).key_id), + KID + ); + assert_eq!(hex_string(&encryption.key_for(TrackKind::Video).iv), KEY); + assert_eq!( + hex_string(&encryption.key_for(TrackKind::Audio).key_id), + KEY + ); + assert_eq!(encryption.distinct_keys().len(), 2); + assert_eq!(encryption.key_ids().len(), 2); + } + + /// `FairPlay`'s HLS key line carries no key ID, so it cannot name a separate audio key. + #[test] + fn fairplay_with_split_keys_is_refused() { + let json = format!( + r#"{{"scheme":"cbcs","keys":[{{"tracks":"video","key_id":"{KID}","key":"{KEY}"}},{{"tracks":"audio","key_id":"{KEY}","key":"{KID}"}}], + "systems":[{{"system_id":"94ce86fb-07ff-4f43-adb8-93d2fa968ca2","hls_uri":"skd://asset-1"}}]}}"# + ); + + let error = wire(&json).validate().unwrap_err(); + + assert!(error.contains("FairPlay"), "{error}"); + assert!(error.contains("tracks"), "names the field: {error}"); + assert!(!error.contains(KEY) && !error.contains(KID), "{error}"); + } + + #[test] + fn malformed_encryption_is_refused_with_a_reason() { + let cases = [ + (r#"{"scheme":"cenc","keys":[]}"#.to_owned(), "scheme"), + ( + format!(r#"{{"scheme":"cbcs","keys":[{{"key_id":"{KID}","key":"00"}}]}}"#), + "key", + ), + ( + format!( + r#"{{"scheme":"cbcs","keys":[{{"key_id":"zz{}","key":"{KEY}"}}]}}"#, + &KID[2..] + ), + "key_id", + ), + ( + format!( + r#"{{"scheme":"cbcs","keys":[{{"tracks":"video","key_id":"{KID}","key":"{KEY}"}}]}}"# + ), + "one for video and one for audio", + ), + ( + format!( + r#"{{"scheme":"cbcs","keys":[{{"tracks":"video","key_id":"{KID}","key":"{KEY}"}},{{"tracks":"video","key_id":"{KID}","key":"{KEY}"}}]}}"# + ), + "one for video and one for audio", + ), + ( + format!( + r#"{{"scheme":"cbcs","keys":[{{"tracks":"subtitles","key_id":"{KID}","key":"{KEY}"}}]}}"# + ), + "tracks", + ), + ( + one_key(r#","systems":[{"system_id":"not-a-uuid"}]"#), + "system_id", + ), + ( + one_key(r#","systems":[{"system_id":"94ce86fb-07ff-4f43-adb8-93d2fa968ca2"}]"#), + "hls_uri", + ), + ( + one_key(&format!( + r#","systems":[{{"system_id":"edef8ba9-79d6-4ace-a3c8-27dcd51d21ed","pssh":"{}"}}]"#, + b64(&pssh(&PLAYREADY, b"x")) + )), + "pssh", + ), + ( + one_key( + r#","systems":[{"system_id":"edef8ba9-79d6-4ace-a3c8-27dcd51d21ed","pssh":"!!"}]"#, + ), + "pssh", + ), + ( + one_key( + r#","systems":[{"system_id":"e2719d58-a985-b3c9-781a-b030af78d30e","license_url":"ftp://x"}]"#, + ), + "license_url", + ), + ( + one_key( + r#","systems":[{"system_id":"94ce86fb-07ff-4f43-adb8-93d2fa968ca2","hls_uri":"skd://a\"b"}]"#, + ), + "hls_uri", + ), + ]; + for (json, needle) in cases { + let error = wire(&json).validate().expect_err(&json); + assert!(error.contains(needle), "{json}: {error}"); + assert!( + !error.contains(KEY), + "a reason never echoes key material: {error}" + ); + } + let nine = (0..9) + .map(|index| format!(r#"{{"system_id":"00000000-0000-0000-0000-00000000000{index}"}}"#)) + .collect::>() + .join(","); + assert!( + wire(&one_key(&format!(r#","systems":[{nine}]"#))) + .validate() + .unwrap_err() + .contains('8') + ); + } + + #[test] + fn systems_keep_their_pssh_urls_and_uris() { + let widevine = pssh(&WIDEVINE, b"widevine-data"); + let json = one_key(&format!( + r#","systems":[{{"system_id":"edef8ba9-79d6-4ace-a3c8-27dcd51d21ed","pssh":"{}","license_url":"https://license.example.net/wv?a=1&b=2"}},{{"system_id":"94ce86fb-07ff-4f43-adb8-93d2fa968ca2","hls_uri":"skd://asset-1"}}]"#, + b64(&widevine) + )); + + let encryption = wire(&json).validate().unwrap(); + + assert_eq!(encryption.systems[0].system_id, WIDEVINE); + assert_eq!( + encryption.systems[0].pssh.as_deref(), + Some(widevine.as_slice()) + ); + assert_eq!(pssh_data(&widevine), Some(b"widevine-data".as_slice())); + assert_eq!( + encryption.systems[0].license_url.as_deref(), + Some("https://license.example.net/wv?a=1&b=2") + ); + assert_eq!( + encryption.systems[1].hls_uri.as_deref(), + Some("skd://asset-1") + ); + } + + #[test] + fn the_fingerprint_changes_with_the_key_and_debug_never_prints_it() { + let first = wire(&one_key("")).validate().unwrap(); + let rekeyed = wire(&one_key("").replace(KEY, "ffeeddccbbaa99887766554433221100")) + .validate() + .unwrap(); + + assert_ne!(first.fingerprint(), rekeyed.fingerprint()); + assert_eq!( + first.fingerprint(), + wire(&one_key("")).validate().unwrap().fingerprint() + ); + let printed = format!("{first:?}"); + assert!( + !printed.contains(KEY) && !printed.contains("[0, 17, 34"), + "{printed}" + ); + } + + #[test] + fn the_wire_form_never_debug_prints_the_key() { + let printed = format!("{:?}", wire(&one_key(""))); + + assert!(!printed.contains(KEY), "{printed}"); + assert!(printed.contains(KID), "{printed}"); + } + + #[test] + fn uuids_print_in_their_canonical_form() { + assert_eq!( + uuid_string(&WIDEVINE), + "edef8ba9-79d6-4ace-a3c8-27dcd51d21ed" + ); + assert_eq!( + uuid_string(&CLEARKEY), + "e2719d58-a985-b3c9-781a-b030af78d30e" + ); + } +} diff --git a/src/cenc/mod.rs b/src/cenc/mod.rs new file mode 100644 index 0000000..8d23baf --- /dev/null +++ b/src/cenc/mod.rs @@ -0,0 +1,45 @@ +//! Common Encryption (ISO/IEC 23001-7) in the `cbcs` scheme, and the DRM signalling that goes with +//! it. See `docs/technical-design/0009-common-encryption-and-drm.md`. + +mod avc; +mod bits; +mod cipher; +mod keys; +mod segment; +mod signal; + +pub(crate) use keys::{Encryption, WireEncryption}; +pub(crate) use segment::{AssetProtection, PendingEncryption}; +pub(crate) use signal::{dash_content_protection, hls_key_lines, hls_session_keys}; + +/// See `fuzzing::exercise_avc_slice_header`. +pub(crate) fn fuzz_avc(data: &[u8]) { + let Some((&split, rest)) = data.split_first() else { + return; + }; + let split = usize::from(split).min(rest.len()); + let (sps, rest) = rest.split_at(split); + let (pps, slice) = rest.split_at(rest.len() / 2); + let mut parameters = avc::AvcParameters::empty(4); + let _ = parameters.update(sps); + let _ = parameters.update(pps); + let _ = parameters.clear_bytes(slice); + let _ = segment::avc_subsamples(¶meters, slice); +} + +#[cfg(test)] +pub(crate) mod tests_support { + use super::{Encryption, WireEncryption}; + + /// One key, with Widevine (license URL only) and `FairPlay`. + pub(crate) fn sample_encryption() -> Encryption { + serde_json::from_str::( + r#"{"scheme":"cbcs","keys":[{"key_id":"0123456789abcdef0123456789abcdef","key":"00112233445566778899aabbccddeeff"}], + "systems":[{"system_id":"edef8ba9-79d6-4ace-a3c8-27dcd51d21ed","license_url":"https://l.example.net/wv"}, + {"system_id":"94ce86fb-07ff-4f43-adb8-93d2fa968ca2","hls_uri":"skd://a"}]}"#, + ) + .unwrap() + .validate() + .unwrap() + } +} diff --git a/src/cenc/segment.rs b/src/cenc/segment.rs new file mode 100644 index 0000000..72cbbcc --- /dev/null +++ b/src/cenc/segment.rs @@ -0,0 +1,336 @@ +//! Encrypting one segment: which bytes of each sample are protected, the encryption itself, and +//! the fragment that describes it. + +use std::collections::HashMap; +use std::sync::Arc; + +use bytes::Bytes; + +use super::avc::AvcParameters; +use super::cipher::{Cipher, Pattern}; +use super::keys::{ContentKey, Encryption}; +use crate::error::{Error, Result}; +use crate::fmp4::{self, InitProtection, MAX_SUBSAMPLES}; +use crate::media::{CodecConfig, Sample, Track, TrackKind}; +use crate::source::Metadata; + +/// What a track needs to encrypt its segments. +#[derive(Debug)] +pub(crate) struct TrackProtection { + pub(crate) key: ContentKey, + pub(crate) kind: TrackKind, + /// H.264 parameter sets, for finding slice headers; `None` for audio. + pub(crate) avc: Option, +} + +impl TrackProtection { + pub(crate) const fn pattern(&self) -> Pattern { + match self.kind { + TrackKind::Video => Pattern::VIDEO, + TrackKind::Audio => Pattern::FULL, + } + } + + pub(crate) fn init<'a>(&self, pssh: &'a [Vec]) -> InitProtection<'a> { + let pattern = self.pattern(); + InitProtection { + key_id: self.key.key_id, + constant_iv: self.key.iv, + crypt_byte_block: pattern.crypt, + skip_byte_block: pattern.skip, + pssh, + } + } +} + +/// An encrypted asset's per-track protection, built once when it loads. +#[derive(Debug)] +pub(crate) struct AssetProtection { + pub(crate) encryption: Arc, + tracks: HashMap>, + /// Every `pssh` the mapper supplied, for each init segment's `moov`. + pub(crate) pssh: Vec>, +} + +impl AssetProtection { + /// Fails for a codec this stage cannot encrypt, naming it (TDD 0009, "Security and limits"). + pub(crate) fn new( + encryption: Arc, + tracks: &[Track], + metadata: &Metadata, + ) -> Result { + let mut protected = HashMap::with_capacity(tracks.len()); + for track in tracks { + let avc = match &track.codec { + CodecConfig::Avc { .. } => { + let (_, entry) = fmp4::sample_entry(metadata, track.id)?; + Some(AvcParameters::from_sample_entry(&entry)?) + } + CodecConfig::Aac { .. } | CodecConfig::Ac3 { .. } | CodecConfig::Eac3 { .. } => { + None + } + CodecConfig::Hevc { .. } => { + return Err(Error::Unsupported( + "HEVC encryption is not supported yet".to_owned(), + )); + } + other => { + return Err(Error::Unsupported(format!( + "{} cannot be encrypted", + other.codecs() + ))); + } + }; + protected.insert( + track.id, + Arc::new(TrackProtection { + key: encryption.key_for(track.kind).clone(), + kind: track.kind, + avc, + }), + ); + } + let pssh = encryption + .systems + .iter() + .filter_map(|system| system.pssh.clone()) + .collect(); + Ok(Self { + encryption, + tracks: protected, + pssh, + }) + } + + pub(crate) fn track(&self, id: u32) -> Option<&Arc> { + self.tracks.get(&id) + } +} + +/// A segment of an encrypted track, prepared up to the point where its bytes are needed. +#[derive(Debug, Clone)] +pub(crate) struct PendingEncryption { + pub(crate) track_id: u32, + pub(crate) kind: TrackKind, + pub(crate) samples: Vec, + pub(crate) decode_time: u64, + pub(crate) sequence_number: u32, + pub(crate) protection: Arc, +} + +impl PendingEncryption { + /// Room to reserve ahead of the payload for the fragment header, so [`Self::finish`] can put + /// the header in front without copying the payload into a second buffer. An estimate: a + /// sample's `trun`, `senc` (a few subsamples), and `saiz` entries, plus the fixed boxes; a + /// larger header only costs a reallocation. + pub(crate) fn header_room(&self) -> usize { + self.samples.len().saturating_mul(64).saturating_add(1024) + } + + /// Encrypts `payload` (the samples' bytes, in order) and returns the finished fragment. + pub(crate) fn finish(&self, mut payload: Vec) -> Result { + let invalid = |message: &str| Error::InvalidMedia(message.to_owned()); + let expected = self.samples.iter().try_fold(0usize, |total, sample| { + usize::try_from(sample.size) + .ok() + .and_then(|size| total.checked_add(size)) + }); + if expected != Some(payload.len()) { + return Err(invalid("encrypted segment bytes do not match its samples")); + } + let cipher = Cipher::new(self.protection.key.key.bytes()); + let iv = self.protection.key.iv; + let pattern = self.protection.pattern(); + let mut maps = Vec::with_capacity(self.samples.len()); + let mut offset = 0; + for sample in &self.samples { + let size = usize::try_from(sample.size).map_err(|_| invalid("sample too large"))?; + let bytes = &mut payload[offset..offset + size]; + let map = match &self.protection.avc { + Some(avc) => avc_subsamples(avc, bytes)?, + None => Vec::new(), + }; + if map.is_empty() { + cipher.encrypt_range(&iv, bytes, pattern); + } else { + let mut at = 0; + for &(clear, protected) in &map { + at += usize::from(clear); + let end = + at + usize::try_from(protected).map_err(|_| invalid("range too large"))?; + cipher.encrypt_range(&iv, &mut bytes[at..end], pattern); + at = end; + } + } + maps.push(map); + offset += size; + } + let header = fmp4::encrypted_fragment_header( + self.track_id, + self.kind, + &self.samples, + self.decode_time, + self.sequence_number, + &maps, + payload.len(), + )?; + // Put the header in front within the payload's own buffer (room is usually reserved by + // the caller), rather than copying the whole payload after the header. + payload.reserve_exact(header.len()); + payload.splice(0..0, header); + Ok(Bytes::from(payload)) + } +} + +/// One H.264 sample's `(clear, protected)` subsamples (TDD 0009, "What is encrypted"): every NAL +/// unit that is not a slice joins the next clear run, and each slice's header stays clear. +pub(super) fn avc_subsamples(parameters: &AvcParameters, sample: &[u8]) -> Result> { + let invalid = |message: &str| Error::InvalidMedia(message.to_owned()); + let length_size = parameters.nal_length_size(); + let mut in_band: Option = None; + let mut map = Vec::new(); + let mut clear = 0usize; + let mut at = 0usize; + while at < sample.len() { + let prefix = sample + .get(at..at + length_size) + .ok_or_else(|| invalid("a NAL length is truncated"))?; + let length = prefix + .iter() + .fold(0usize, |value, byte| value << 8 | usize::from(*byte)); + let start = at + length_size; + let end = start + .checked_add(length) + .filter(|end| *end <= sample.len()) + .ok_or_else(|| invalid("a NAL unit runs past its sample"))?; + let nal = &sample[start..end]; + let nal_type = nal.first().ok_or_else(|| invalid("an empty NAL unit"))? & 0x1f; + match nal_type { + 1 | 5 => { + let header = in_band.as_ref().unwrap_or(parameters).clear_bytes(nal)?; + push_subsample(&mut map, clear + length_size + header, length - header)?; + clear = 0; + } + 2..=4 => { + return Err(Error::Unsupported( + "H.264 data partitioning cannot be encrypted".to_owned(), + )); + } + 7 | 8 => { + in_band + .get_or_insert_with(|| parameters.clone()) + .update(nal)?; + clear += length_size + length; + } + _ => clear += length_size + length, + } + at = end; + } + if clear > 0 { + push_subsample(&mut map, clear, 0)?; + } + if map.len() > MAX_SUBSAMPLES { + return Err(invalid( + "a sample has more than 42 subsamples, more than saiz can describe", + )); + } + Ok(map) +} + +/// Adds one subsample; a clear run longer than `u16::MAX` becomes several clear-only ones. +fn push_subsample(map: &mut Vec<(u16, u32)>, mut clear: usize, protected: usize) -> Result<()> { + while clear > usize::from(u16::MAX) { + map.push((u16::MAX, 0)); + clear -= usize::from(u16::MAX); + } + let clear = u16::try_from(clear).expect("reduced below u16::MAX above"); + let protected = u32::try_from(protected) + .map_err(|_| Error::InvalidMedia("a slice is larger than 4 GiB".to_owned()))?; + map.push((clear, protected)); + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn avc_fixture() -> (AvcParameters, Vec) { + // One IDR access unit from the fixture, with its parameters: see avc.rs tests. + crate::cenc::avc::tests_support::first_idr("h264-aac.mp4") + } + + fn length_prefixed(nals: &[&[u8]]) -> Vec { + nals.iter() + .flat_map(|nal| { + let mut unit = u32::try_from(nal.len()).unwrap().to_be_bytes().to_vec(); + unit.extend_from_slice(nal); + unit + }) + .collect() + } + + #[test] + fn non_slice_nal_units_join_the_next_clear_run() { + let (parameters, idr) = avc_fixture(); + let sei = vec![0x06; 20]; + let sample = length_prefixed(&[&sei, &idr]); + + let map = avc_subsamples(¶meters, &sample).unwrap(); + + let header = parameters.clear_bytes(&idr).unwrap(); + assert_eq!( + map, + vec![( + u16::try_from(4 + 20 + 4 + header).unwrap(), + u32::try_from(idr.len() - header).unwrap() + )] + ); + } + + #[test] + fn a_long_clear_run_is_split_into_u16_subsamples() { + let (parameters, idr) = avc_fixture(); + let sei = vec![0x06; 70_000]; + let sample = length_prefixed(&[&sei, &idr]); + + let map = avc_subsamples(¶meters, &sample).unwrap(); + + assert_eq!(map[0], (65_535, 0)); + let clear: usize = map.iter().map(|(clear, _)| usize::from(*clear)).sum(); + let protected: usize = map.iter().map(|(_, protected)| *protected as usize).sum(); + assert_eq!(clear + protected, sample.len()); + } + + #[test] + fn too_many_slices_fail_instead_of_truncating_saiz() { + let (parameters, idr) = avc_fixture(); + let slices = vec![idr.as_slice(); 43]; + let sample = length_prefixed(&slices); + + let error = avc_subsamples(¶meters, &sample).unwrap_err(); + + assert!(error.to_string().contains("42"), "{error}"); + } + + #[test] + fn an_in_band_parameter_set_is_honoured_within_its_sample() { + let (mut parameters, idr) = avc_fixture(); + let (sps, pps) = parameters.take_all_for_test(); + let sample = length_prefixed(&[&sps, &pps, &idr]); + + let map = avc_subsamples(¶meters, &sample).unwrap(); + + assert_eq!( + map.len(), + 1, + "the in-band SPS and PPS are clear bytes of the one slice's subsample" + ); + } + + #[test] + fn a_truncated_nal_length_fails() { + let (parameters, _) = avc_fixture(); + + assert!(avc_subsamples(¶meters, &[0, 0, 0, 9, 0x65]).is_err()); + } +} diff --git a/src/cenc/signal.rs b/src/cenc/signal.rs new file mode 100644 index 0000000..2d8f595 --- /dev/null +++ b/src/cenc/signal.rs @@ -0,0 +1,261 @@ +//! DRM signalling: HLS `EXT-X-KEY` lines and DASH `ContentProtection` elements (TDD 0009). + +use std::fmt::Write; + +use base64::Engine; + +use super::keys::{ + CLEARKEY, ContentKey, DrmSystem, Encryption, FAIRPLAY, PLAYREADY, WIDEVINE, hex_string, + pssh_data, uuid_string, +}; + +fn base64(bytes: &[u8]) -> String { + base64::engine::general_purpose::STANDARD.encode(bytes) +} + +/// One `#{tag}` line per system HLS can name. Systems with no standard HLS form are DASH-only. +pub(crate) fn hls_key_lines(encryption: &Encryption, key: &ContentKey, tag: &str) -> String { + let mut lines = String::new(); + for system in &encryption.systems { + if let Some(attributes) = hls_attributes(system, Some(&key.key_id)) { + push_key_line(&mut lines, tag, &attributes); + } + } + lines +} + +/// `EXT-X-SESSION-KEY` lines for every distinct key, so players can request licences early. +/// +/// RFC 8216 4.3.4.5 forbids two session keys with the same `METHOD`, `URI`, `IV`, `KEYFORMAT`, +/// and `KEYFORMATVERSIONS`; `KEYID` does not tell them apart. Only Widevine's line depends on +/// the key, and only through `KEYID`, so each system's line is given once, for the first key. +pub(crate) fn hls_session_keys(encryption: &Encryption) -> String { + let mut lines = String::new(); + let mut seen: Vec = Vec::new(); + for key in encryption.distinct_keys() { + for system in &encryption.systems { + let Some(identity) = hls_attributes(system, None) else { + continue; + }; + if seen.contains(&identity) { + continue; + } + seen.push(identity); + if let Some(attributes) = hls_attributes(system, Some(&key.key_id)) { + push_key_line(&mut lines, "EXT-X-SESSION-KEY", &attributes); + } + } + } + lines +} + +fn push_key_line(lines: &mut String, tag: &str, attributes: &str) { + writeln!(lines, "#{tag}:METHOD=SAMPLE-AES,{attributes}") + .expect("writing to a String cannot fail"); +} + +/// A system's HLS key attributes after `METHOD`, or `None` if HLS cannot name it. `KEYID` is +/// written only when `key_id` is given. +fn hls_attributes(system: &DrmSystem, key_id: Option<&[u8; 16]>) -> Option { + match system.system_id { + FAIRPLAY => system.hls_uri.as_ref().map(|uri| { + format!(r#"URI="{uri}",KEYFORMAT="com.apple.streamingkeydelivery",KEYFORMATVERSIONS="1""#) + }), + WIDEVINE => system.pssh.as_ref().map(|pssh| { + let key_id = key_id + .map(|key_id| format!(",KEYID=0x{}", hex_string(key_id))) + .unwrap_or_default(); + format!( + r#"URI="data:text/plain;base64,{}"{key_id},KEYFORMAT="urn:uuid:edef8ba9-79d6-4ace-a3c8-27dcd51d21ed",KEYFORMATVERSIONS="1""#, + base64(pssh), + ) + }), + PLAYREADY => system.pssh.as_deref().and_then(pssh_data).map(|object| { + format!( + r#"URI="data:text/plain;charset=UTF-16;base64,{}",KEYFORMAT="com.microsoft.playready",KEYFORMATVERSIONS="1""#, + base64(object) + ) + }), + CLEARKEY => system.license_url.as_ref().map(|url| { + format!(r#"URI="{url}",KEYFORMAT="org.w3.clearkey",KEYFORMATVERSIONS="1""#) + }), + _ => None, + } +} + +/// The `ContentProtection` elements of one adaptation set, indented for its children. +pub(crate) fn dash_content_protection(encryption: &Encryption, key: &ContentKey) -> String { + let mut xml = format!( + " \n", + uuid_string(&key.key_id) + ); + for system in &encryption.systems { + writeln!( + xml, + " ", + uuid_string(&system.system_id) + ) + .expect("writing to a String cannot fail"); + if let Some(pssh) = &system.pssh { + writeln!(xml, " {}", base64(pssh)) + .expect("writing to a String cannot fail"); + } + if let Some(url) = &system.license_url { + writeln!( + xml, + " {}", + url.replace('&', "&").replace('<', "<") + ) + .expect("writing to a String cannot fail"); + } + xml.push_str(" \n"); + } + xml +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::cenc::WireEncryption; + use crate::cenc::keys::{CLEARKEY, FAIRPLAY, PLAYREADY, WIDEVINE}; + + fn pssh(system: &[u8; 16], data: &[u8]) -> String { + use base64::Engine; + let mut boxed = u32::try_from(32 + data.len()) + .unwrap() + .to_be_bytes() + .to_vec(); + boxed.extend_from_slice(b"pssh"); + boxed.extend_from_slice(&[0; 4]); + boxed.extend_from_slice(system); + boxed.extend_from_slice(&u32::try_from(data.len()).unwrap().to_be_bytes()); + boxed.extend_from_slice(data); + base64::engine::general_purpose::STANDARD.encode(boxed) + } + + fn encryption() -> Encryption { + let json = format!( + r#"{{"scheme":"cbcs","keys":[{{"key_id":"0123456789abcdef0123456789abcdef","key":"00112233445566778899aabbccddeeff"}}], + "systems":[ + {{"system_id":"edef8ba9-79d6-4ace-a3c8-27dcd51d21ed","pssh":"{}","license_url":"https://l.example.net/wv?a=1&b=2"}}, + {{"system_id":"94ce86fb-07ff-4f43-adb8-93d2fa968ca2","hls_uri":"skd://asset-1"}}, + {{"system_id":"9a04f079-9840-4286-ab92-e65be0885f95","pssh":"{}"}}, + {{"system_id":"e2719d58-a985-b3c9-781a-b030af78d30e","license_url":"https://l.example.net/ck"}}, + {{"system_id":"11111111-2222-3333-4444-555555555555"}} + ]}}"#, + pssh(&WIDEVINE, b"wv"), + pssh(&PLAYREADY, b"pr") + ); + serde_json::from_str::(&json) + .unwrap() + .validate() + .unwrap() + } + + #[test] + fn hls_names_each_system_by_its_key_format() { + let encryption = encryption(); + let key = encryption.key_for(crate::media::TrackKind::Video); + + let lines = hls_key_lines(&encryption, key, "EXT-X-KEY"); + + assert_eq!( + lines.lines().count(), + 4, + "the unknown system has no HLS form: {lines}" + ); + assert!(lines.contains(r#"#EXT-X-KEY:METHOD=SAMPLE-AES,URI="skd://asset-1",KEYFORMAT="com.apple.streamingkeydelivery",KEYFORMATVERSIONS="1""#), "{lines}"); + assert!(lines.contains(&format!(r#"URI="data:text/plain;base64,{}",KEYID=0x0123456789abcdef0123456789abcdef,KEYFORMAT="urn:uuid:edef8ba9-79d6-4ace-a3c8-27dcd51d21ed""#, pssh(&WIDEVINE, b"wv"))), "{lines}"); + assert!(lines.contains(r#"URI="data:text/plain;charset=UTF-16;base64,cHI=",KEYFORMAT="com.microsoft.playready""#), "the PlayReady object, not the box: {lines}"); + assert!( + lines.contains(r#"URI="https://l.example.net/ck",KEYFORMAT="org.w3.clearkey""#), + "{lines}" + ); + let _ = (FAIRPLAY, CLEARKEY); + } + + /// RFC 8216 4.3.4.5: no two `EXT-X-SESSION-KEY` lines may share `METHOD`, `URI`, `IV`, + /// `KEYFORMAT`, and `KEYFORMATVERSIONS`, so split keys must not repeat the per-system lines. + #[test] + fn split_keys_give_each_session_key_line_once() { + let json = format!( + r#"{{"scheme":"cbcs","keys":[ + {{"tracks":"video","key_id":"0123456789abcdef0123456789abcdef","key":"00112233445566778899aabbccddeeff"}}, + {{"tracks":"audio","key_id":"fedcba9876543210fedcba9876543210","key":"ffeeddccbbaa99887766554433221100"}}], + "systems":[ + {{"system_id":"edef8ba9-79d6-4ace-a3c8-27dcd51d21ed","pssh":"{}"}}, + {{"system_id":"9a04f079-9840-4286-ab92-e65be0885f95","pssh":"{}"}}, + {{"system_id":"e2719d58-a985-b3c9-781a-b030af78d30e","license_url":"https://l.example.net/ck"}} + ]}}"#, + pssh(&WIDEVINE, b"wv"), + pssh(&PLAYREADY, b"pr") + ); + let encryption = serde_json::from_str::(&json) + .unwrap() + .validate() + .unwrap(); + + let lines = hls_session_keys(&encryption); + + let identities: Vec = lines + .lines() + .map(|line| { + line.split(',') + .filter(|attribute| !attribute.starts_with("KEYID=")) + .collect::>() + .join(",") + }) + .collect(); + let mut unique = identities.clone(); + unique.sort(); + unique.dedup(); + assert_eq!(unique.len(), identities.len(), "{lines}"); + assert_eq!(identities.len(), 3, "one line per system: {lines}"); + assert!( + lines.contains("KEYID=0x0123456789abcdef0123456789abcdef"), + "Widevine keeps the first (video) key's line: {lines}" + ); + } + + #[test] + fn identical_split_keys_are_one_session_key() { + let key = r#""key_id":"0123456789abcdef0123456789abcdef","key":"00112233445566778899aabbccddeeff""#; + let json = format!( + r#"{{"scheme":"cbcs","keys":[{{"tracks":"video",{key}}},{{"tracks":"audio",{key}}}]}}"# + ); + let encryption = serde_json::from_str::(&json) + .unwrap() + .validate() + .unwrap(); + + assert_eq!(encryption.distinct_keys().len(), 1); + assert_eq!(encryption.key_ids().len(), 1); + } + + #[test] + fn dash_lists_the_scheme_then_every_system() { + let encryption = encryption(); + let key = encryption.key_for(crate::media::TrackKind::Video); + + let xml = dash_content_protection(&encryption, key); + + assert!(xml.contains(r#""#), "{xml}"); + assert_eq!( + xml.matches("https://l.example.net/wv?a=1&b=2"), + "escaped: {xml}" + ); + assert!( + xml.contains(&format!( + "{}", + pssh(&WIDEVINE, b"wv") + )), + "{xml}" + ); + } +} diff --git a/src/clip.rs b/src/clip.rs index 89ddff2..2ebd360 100644 --- a/src/clip.rs +++ b/src/clip.rs @@ -306,6 +306,7 @@ fn moved_track(track: &Track, range: (usize, usize), origin: u64, base: u64) -> pub(crate) fn version_of<'a>( mapper_version: &str, clips: impl IntoIterator, + encryption: Option<&crate::cenc::Encryption>, ) -> String { use std::fmt::Write; @@ -331,6 +332,10 @@ pub(crate) fn version_of<'a>( None => hasher.update([0]), } } + if let Some(encryption) = encryption { + hasher.update(b"cbcs"); + hasher.update(encryption.fingerprint()); + } hasher .finalize() .iter() @@ -711,32 +716,32 @@ mod tests { let index = parse("h264-aac.mp4"); let other = parse("hevc-aac.mp4"); let source = &index.source; - let base = version_of("v1", [(source, window(0, Some(2000)))]); + let base = version_of("v1", [(source, window(0, Some(2000)))], None); assert_eq!(base.len(), 16); assert_eq!( base, - version_of("v1", [(source, window(0, Some(2000)))]), + version_of("v1", [(source, window(0, Some(2000)))], None), "stable" ); assert_ne!( base, - version_of("v1", [(source, window(0, Some(3000)))]), + version_of("v1", [(source, window(0, Some(3000)))], None), "the window" ); assert_ne!( base, - version_of("v1", [(source, window(0, None))]), + version_of("v1", [(source, window(0, None))], None), "an open end" ); assert_ne!( base, - version_of("v2", [(source, window(0, Some(2000)))]), + version_of("v2", [(source, window(0, Some(2000)))], None), "the mapper version" ); assert_ne!( base, - version_of("v1", [(&other.source, window(0, Some(2000)))]), + version_of("v1", [(&other.source, window(0, Some(2000)))], None), "the content" ); assert_ne!( @@ -746,7 +751,8 @@ mod tests { [ (source, window(0, Some(2000))), (source, window(0, Some(2000))) - ] + ], + None ), "the clip count" ); diff --git a/src/composite.rs b/src/composite.rs index aaf5206..693f35b 100644 --- a/src/composite.rs +++ b/src/composite.rs @@ -31,6 +31,18 @@ pub(crate) enum ServedAsset { } impl ServedAsset { + /// The key IDs (as UUIDs) the asset is encrypted with, or `None` when it is served clear. + pub(crate) fn key_ids(&self) -> Option> { + match self { + Self::Single(asset) => asset.encryption().map(crate::cenc::Encryption::key_ids), + Self::Composite(asset) => asset.video[0] + .asset + .encryption() + .map(crate::cenc::Encryption::key_ids), + Self::Sequence(asset) => asset.encryption().map(crate::cenc::Encryption::key_ids), + } + } + pub(crate) fn version(&self) -> &str { match self { Self::Single(asset) => asset.version(), @@ -710,7 +722,7 @@ fn version_of( } fn presentation_for<'a>(asset: &'a PackagedAsset, version: &'a str) -> Presentation<'a> { - Presentation::new(&asset.index.tracks, &asset.plan, version) + Presentation::new(&asset.index.tracks, &asset.plan, version).with_encryption(asset.encryption()) } /// Which underlying asset supplies one member of the shared audio group. @@ -813,6 +825,8 @@ fn render( iframe_source: usize, ) -> Result { let (video_views, audio_views) = build_views(video, audio_only, audio, version)?; + // Every rendition is encrypted with the same keys, so the first one speaks for the asset. + let encryption = video[0].asset.encryption(); let adaptive_video = video_views .iter() @@ -840,6 +854,7 @@ fn render( &adaptive_audio, subtitles, iframe_stream_line.as_deref(), + encryption, )?); let hls_video = video @@ -873,7 +888,11 @@ fn render( version, )?; let duration = dash::track_duration(video_views[0].2)?; - let dash = Bytes::from(dash::wrap_manifest(&duration, &dash_body)); + let dash = Bytes::from(dash::wrap_manifest( + &duration, + &dash_body, + encryption.is_some(), + )); Ok(CompositeManifests { hls_master, diff --git a/src/config/mod.rs b/src/config/mod.rs index 0f16391..08109ba 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -542,7 +542,7 @@ mod tests { !format!("{mapper:?}").contains("secret-value"), "tokens must be redacted" ); - assert!(config.assets.is_empty()); + assert_eq!(config.assets.len(), 0); assert_eq!(config.remote_media.allowed_hosts, ["origin.example.net"]); } diff --git a/src/fmp4/fragment.rs b/src/fmp4/fragment.rs index a6858f6..8165f1d 100644 --- a/src/fmp4/fragment.rs +++ b/src/fmp4/fragment.rs @@ -16,6 +16,9 @@ pub(crate) struct PreparedSegment { pub(crate) header: Bytes, pub(crate) ranges: Vec, pub(crate) content_length: u64, + /// For an encrypted track: what remains once the samples are read (see `cenc::segment`). + /// `header`, `ranges`, and `content_length` then describe the clear fragment and are replaced. + pub(crate) encryption: Option>, } pub(crate) async fn write_media_segment( @@ -53,15 +56,25 @@ pub(crate) fn prepare_media_segment( )); } - let provisional_moof = build_moof(track, samples, segment.decode_time, sequence_number, 0)?; + let provisional_moof = build_moof( + track.id, + track.kind, + samples, + segment.decode_time, + sequence_number, + 0, + &[], + )?; let data_offset = i32::try_from(provisional_moof.len() + 8) .map_err(|_| Error::InvalidMedia("fragment header is too large".to_owned()))?; let moof = build_moof( - track, + track.id, + track.kind, samples, segment.decode_time, sequence_number, data_offset, + &[], )?; let payload_len = samples.iter().try_fold(0u64, |total, sample| { total @@ -89,6 +102,7 @@ pub(crate) fn prepare_media_segment( header: Bytes::from(header), ranges: coalesced_ranges(samples)?, content_length, + encryption: None, }) } @@ -111,11 +125,13 @@ fn coalesced_ranges(samples: &[Sample]) -> Result> { } fn build_moof( - track: &Track, + track_id: u32, + kind: TrackKind, samples: &[Sample], decode_time: u64, sequence_number: u32, data_offset: i32, + extra: &[u8], ) -> Result> { let mut mfhd = Vec::new(); write_full_box_fields(&mut mfhd, 0, 0); @@ -123,7 +139,7 @@ fn build_moof( let mut tfhd = Vec::new(); write_full_box_fields(&mut tfhd, 0, TFHD_DEFAULT_BASE_IS_MOOF); - tfhd.extend_from_slice(&track.id.to_be_bytes()); + tfhd.extend_from_slice(&track_id.to_be_bytes()); let mut tfdt = Vec::new(); write_full_box_fields(&mut tfdt, 1, 0); @@ -138,7 +154,7 @@ fn build_moof( for sample in samples { trun.extend_from_slice(&sample.duration.to_be_bytes()); trun.extend_from_slice(&sample.size.to_be_bytes()); - let flags = match track.kind { + let flags = match kind { TrackKind::Audio => SYNC_SAMPLE_FLAGS, TrackKind::Video if sample.is_sync => SYNC_SAMPLE_FLAGS, TrackKind::Video => NON_SYNC_SAMPLE_FLAGS, @@ -151,6 +167,7 @@ fn build_moof( write_box(&mut traf_payload, *b"tfhd", &tfhd)?; write_box(&mut traf_payload, *b"tfdt", &tfdt)?; write_box(&mut traf_payload, *b"trun", &trun)?; + traf_payload.extend_from_slice(extra); let mut moof_payload = Vec::new(); write_box(&mut moof_payload, *b"mfhd", &mfhd)?; @@ -183,3 +200,175 @@ fn write_box_header(output: &mut Vec, size: u64, name: [u8; 4]) -> Result<() output.extend_from_slice(&name); Ok(()) } + +/// Most subsamples one sample can have: `saiz` records each sample's `2 + 6n` bytes in a `u8`. +pub(crate) const MAX_SUBSAMPLES: usize = 42; + +/// The `moof` and `mdat` header of an encrypted fragment: the usual boxes plus `senc`, `saiz`, +/// and `saio`, which describe each sample's clear and protected bytes (ISO/IEC 23001-7, 7.2). +/// An empty map means a whole-sample (audio) encryption with no subsamples. +pub(crate) fn encrypted_fragment_header( + track_id: u32, + kind: TrackKind, + samples: &[Sample], + decode_time: u64, + sequence_number: u32, + subsamples: &[Vec<(u16, u32)>], + payload_len: usize, +) -> Result> { + let overflow = || Error::InvalidMedia("encrypted fragment is too large".to_owned()); + let probe_extra = encryption_boxes(subsamples, 0)?; + let probe = build_moof( + track_id, + kind, + samples, + decode_time, + sequence_number, + 0, + &probe_extra, + )?; + // `senc` is the first extra box, at the end of the `moof`; its entries start 16 bytes in + // (box header 8, version and flags 4, sample count 4). Box sizes do not depend on the values + // filled in below, so the probe's layout is the final one. + let senc_entries = probe.len() - probe_extra.len() + 16; + let extra = encryption_boxes( + subsamples, + u32::try_from(senc_entries).map_err(|_| overflow())?, + )?; + let data_offset = i32::try_from(probe.len() + 8).map_err(|_| overflow())?; + let mut header = build_moof( + track_id, + kind, + samples, + decode_time, + sequence_number, + data_offset, + &extra, + )?; + let payload = u64::try_from(payload_len).map_err(|_| overflow())?; + write_box_header( + &mut header, + payload.checked_add(8).ok_or_else(overflow)?, + *b"mdat", + )?; + Ok(header) +} + +fn encryption_boxes(subsamples: &[Vec<(u16, u32)>], aux_offset: u32) -> Result> { + let invalid = |message: &str| Error::InvalidMedia(message.to_owned()); + let use_subsamples = subsamples.iter().any(|map| !map.is_empty()); + let count = u32::try_from(subsamples.len()).map_err(|_| invalid("too many samples"))?; + let mut senc = Vec::new(); + write_full_box_fields(&mut senc, 0, if use_subsamples { 2 } else { 0 }); + senc.extend_from_slice(&count.to_be_bytes()); + let mut sizes = Vec::with_capacity(subsamples.len()); + for map in subsamples { + if !use_subsamples { + sizes.push(0u8); + continue; + } + if map.len() > MAX_SUBSAMPLES { + return Err(invalid( + "a sample has more than 42 subsamples, more than saiz can describe", + )); + } + let entries = u16::try_from(map.len()).map_err(|_| invalid("too many subsamples"))?; + senc.extend_from_slice(&entries.to_be_bytes()); + for (clear, protected) in map { + senc.extend_from_slice(&clear.to_be_bytes()); + senc.extend_from_slice(&protected.to_be_bytes()); + } + sizes.push(u8::try_from(2 + 6 * map.len()).map_err(|_| invalid("too many subsamples"))?); + } + let mut aux_sizes = Vec::new(); + write_full_box_fields(&mut aux_sizes, 0, 0); + // A non-zero default means "every sample has this size"; zero means the sizes are listed. + let uniform = sizes + .first() + .copied() + .filter(|first| *first != 0 && sizes.iter().all(|size| size == first)); + aux_sizes.push(uniform.unwrap_or(0)); + aux_sizes.extend_from_slice(&count.to_be_bytes()); + if uniform.is_none() { + aux_sizes.extend_from_slice(&sizes); + } + let mut aux_offsets = Vec::new(); + write_full_box_fields(&mut aux_offsets, 0, 0); + aux_offsets.extend_from_slice(&1u32.to_be_bytes()); + aux_offsets.extend_from_slice(&aux_offset.to_be_bytes()); + let mut boxes = Vec::new(); + write_box(&mut boxes, *b"senc", &senc)?; + write_box(&mut boxes, *b"saiz", &aux_sizes)?; + write_box(&mut boxes, *b"saio", &aux_offsets)?; + Ok(boxes) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::media::Sample; + + fn sample(size: u32) -> Sample { + Sample { + offset: 0, + size, + decode_time: 0, + duration: 512, + composition_offset: 0, + is_sync: true, + } + } + + fn position(data: &[u8], name: [u8; 4]) -> usize { + data.windows(4).position(|window| window == name).unwrap() - 4 + } + + #[test] + fn senc_saiz_and_saio_describe_the_subsamples() { + let samples = [sample(100), sample(60)]; + let subsamples = vec![vec![(10, 90)], vec![(5, 20), (7, 28)]]; + + let header = + encrypted_fragment_header(1, TrackKind::Video, &samples, 0, 3, &subsamples, 160) + .unwrap(); + + let senc = position(&header, *b"senc"); + assert_eq!( + &header[senc + 8..senc + 12], + &[0, 0, 0, 2], + "subsample flag" + ); + assert_eq!(&header[senc + 12..senc + 16], &2u32.to_be_bytes()); + assert_eq!(&header[senc + 16..senc + 24], &[0, 1, 0, 10, 0, 0, 0, 90]); + let saiz = position(&header, *b"saiz"); + assert_eq!(header[saiz + 12], 0, "sizes differ, so they are listed"); + assert_eq!(&header[saiz + 13..saiz + 19], &[0, 0, 0, 2, 8, 14]); + let offsets = position(&header, *b"saio"); + let offset = u32::from_be_bytes(header[offsets + 16..offsets + 20].try_into().unwrap()); + assert_eq!( + offset as usize, + senc + 16, + "saio points at the first senc entry" + ); + let mdat = position(&header, *b"mdat"); + assert_eq!(mdat + 8, header.len()); + assert_eq!( + u32::from_be_bytes(header[mdat..mdat + 4].try_into().unwrap()), + 168 + ); + } + + #[test] + fn whole_sample_audio_lists_zero_sized_entries() { + let samples = [sample(50), sample(50)]; + + let header = + encrypted_fragment_header(2, TrackKind::Audio, &samples, 0, 1, &[vec![], vec![]], 100) + .unwrap(); + + let senc = position(&header, *b"senc"); + assert_eq!(&header[senc + 8..senc + 16], &[0, 0, 0, 0, 0, 0, 0, 2]); + let saiz = position(&header, *b"saiz"); + assert_eq!(&header[saiz + 12..saiz + 19], &[0, 0, 0, 0, 2, 0, 0]); + } +} diff --git a/src/fmp4/init.rs b/src/fmp4/init.rs index e547928..c95c198 100644 --- a/src/fmp4/init.rs +++ b/src/fmp4/init.rs @@ -14,7 +14,33 @@ use crate::source::Metadata; /// compatible. The source's own brands describe the source file, not this stream. const FTYP: [u8; 24] = *b"\x00\x00\x00\x18ftypiso6\x00\x00\x00\x00iso6mp41"; +/// What an encrypted track's init segment declares (ISO/IEC 23001-7, 8.1 and 8.2). +pub(crate) struct InitProtection<'a> { + pub(crate) key_id: [u8; 16], + pub(crate) constant_iv: [u8; 16], + pub(crate) crypt_byte_block: u8, + pub(crate) skip_byte_block: u8, + /// Complete `pssh` boxes, copied into `moov`. + pub(crate) pssh: &'a [Vec], +} + pub(crate) fn write_init_segment(metadata: &Metadata, track_id: u32) -> Result> { + write_init(metadata, track_id, None) +} + +pub(crate) fn write_protected_init_segment( + metadata: &Metadata, + track_id: u32, + protection: &InitProtection<'_>, +) -> Result> { + write_init(metadata, track_id, Some(protection)) +} + +fn write_init( + metadata: &Metadata, + track_id: u32, + protection: Option<&InitProtection<'_>>, +) -> Result> { let moov = box_payload(metadata.moov_bytes(), 0)?; let children = child_boxes(moov.payload)?; let movie_header = children @@ -33,14 +59,45 @@ pub(crate) fn write_init_segment(metadata: &Metadata, track_id: u32) -> Result Result<([u8; 4], Vec)> { + let moov = box_payload(metadata.moov_bytes(), 0)?; + let track = child_boxes(moov.payload)? + .into_iter() + .filter(|child| child.name == *b"trak") + .find(|track| track_id_of(track).is_ok_and(|id| id == track_id)) + .ok_or_else(|| Error::InvalidMedia(format!("track {track_id} does not exist")))?; + let media = required_child(track.payload, *b"mdia")?; + let info = required_child(media.payload, *b"minf")?; + let table = required_child(info.payload, *b"stbl")?; + let description = required_child(table.payload, *b"stsd")?; + let mut reader = Reader::new(description.payload); + reader.full_box()?; + reader.skip(4)?; + let entry = child_boxes(reader.rest())? + .into_iter() + .next() + .ok_or_else(|| Error::InvalidMedia("stsd entry is missing".to_owned()))?; + Ok((entry.name, entry.payload.to_vec())) +} + fn track_id_of(track: &RawBox<'_>) -> Result { let mut reader = Reader::new(required_child(track.payload, *b"tkhd")?.payload); let version = reader.full_box()?; @@ -49,7 +106,11 @@ fn track_id_of(track: &RawBox<'_>) -> Result { } /// Rebuilds a `trak` with the same boxes but no sample locations, edit list, or user data. -fn track_box(track: &RawBox<'_>, track_id: u32) -> Result> { +fn track_box( + track: &RawBox<'_>, + track_id: u32, + protection: Option<&InitProtection<'_>>, +) -> Result> { let header = required_child(track.payload, *b"tkhd")?; let media = required_child(track.payload, *b"mdia")?; let mut output = Vec::new(); @@ -60,11 +121,19 @@ fn track_box(track: &RawBox<'_>, track_id: u32) -> Result> { )?; // The edit list is not copied: it was applied to the sample timestamps when the file was // parsed, and a player would otherwise apply it a second time. - write_box(&mut output, *b"mdia", &media_box(&media, track_id)?)?; + write_box( + &mut output, + *b"mdia", + &media_box(&media, track_id, protection)?, + )?; Ok(output) } -fn media_box(media: &RawBox<'_>, track_id: u32) -> Result> { +fn media_box( + media: &RawBox<'_>, + track_id: u32, + protection: Option<&InitProtection<'_>>, +) -> Result> { let mut output = Vec::new(); for child in child_boxes(media.payload)? { match &child.name { @@ -73,18 +142,30 @@ fn media_box(media: &RawBox<'_>, track_id: u32) -> Result> { *b"mdhd", &with_zero_duration(child.payload, DurationAt::MOVIE_OR_MEDIA)?, )?, - b"minf" => write_box(&mut output, *b"minf", &media_info_box(&child, track_id)?)?, + b"minf" => write_box( + &mut output, + *b"minf", + &media_info_box(&child, track_id, protection)?, + )?, _ => write_box(&mut output, child.name, child.payload)?, } } Ok(output) } -fn media_info_box(media_info: &RawBox<'_>, track_id: u32) -> Result> { +fn media_info_box( + media_info: &RawBox<'_>, + track_id: u32, + protection: Option<&InitProtection<'_>>, +) -> Result> { let mut output = Vec::new(); for child in child_boxes(media_info.payload)? { if child.name == *b"stbl" { - write_box(&mut output, *b"stbl", &sample_table_box(&child, track_id)?)?; + write_box( + &mut output, + *b"stbl", + &sample_table_box(&child, track_id, protection)?, + )?; } else { // `vmhd`/`smhd` and `dinf`, exactly as they were. write_box(&mut output, child.name, child.payload)?; @@ -94,13 +175,17 @@ fn media_info_box(media_info: &RawBox<'_>, track_id: u32) -> Result> { } /// The sample description, followed by the empty tables a fragmented file requires. -fn sample_table_box(sample_table: &RawBox<'_>, track_id: u32) -> Result> { +fn sample_table_box( + sample_table: &RawBox<'_>, + track_id: u32, + protection: Option<&InitProtection<'_>>, +) -> Result> { let description = required_child(sample_table.payload, *b"stsd")?; let mut output = Vec::new(); write_box( &mut output, *b"stsd", - &sample_description(description.payload, track_id)?, + &sample_description(description.payload, track_id, protection)?, )?; // `stts`, `stsc`, and `stco` are a version/flags word and an entry count of zero; `stsz` // adds a sample size before its count. @@ -115,7 +200,11 @@ fn sample_table_box(sample_table: &RawBox<'_>, track_id: u32) -> Result> /// index 1, and zero defaults for duration, size, and flags (every sample says its own). /// The `stsd` payload to write: the source's, verbatim, except for a QuickTime-style audio entry, /// which browsers refuse and which is rewritten in the standard layout. -fn sample_description(description: &[u8], track_id: u32) -> Result> { +fn sample_description( + description: &[u8], + track_id: u32, + protection: Option<&InitProtection<'_>>, +) -> Result> { let mut reader = Reader::new(description); reader.full_box()?; reader.skip(4)?; @@ -124,15 +213,68 @@ fn sample_description(description: &[u8], track_id: u32) -> Result> { .next() .ok_or_else(|| Error::InvalidMedia("stsd entry is missing".to_owned()))?; if entry.name != *b"mp4a" { - return Ok(description.to_vec()); + return protect(description.to_vec(), protection); } let Some(iso) = iso_audio_entry(entry.payload, track_id)? else { - return Ok(description.to_vec()); + return protect(description.to_vec(), protection); }; // The version, flags, and entry count of the source's `stsd`, then the rewritten entry. let mut rewritten = description[..8].to_vec(); write_box(&mut rewritten, *b"mp4a", &iso)?; - Ok(rewritten) + protect(rewritten, protection) +} + +fn protect(description: Vec, protection: Option<&InitProtection<'_>>) -> Result> { + let Some(protection) = protection else { + return Ok(description); + }; + let mut reader = Reader::new(&description); + reader.full_box()?; + reader.skip(4)?; + let entry = child_boxes(reader.rest())? + .into_iter() + .next() + .ok_or_else(|| Error::InvalidMedia("stsd entry is missing".to_owned()))?; + let protected_type = match &entry.name { + b"mp4a" | b"ac-3" | b"ec-3" => *b"enca", + _ => *b"encv", + }; + let mut payload = entry.payload.to_vec(); + write_box( + &mut payload, + *b"sinf", + &protection_info(entry.name, protection)?, + )?; + let mut protected = description[..8].to_vec(); + write_box(&mut protected, protected_type, &payload)?; + Ok(protected) +} + +/// `sinf`: the original format, the `cbcs` scheme, and `tenc` with a constant IV. +fn protection_info(original: [u8; 4], protection: &InitProtection<'_>) -> Result> { + let mut sinf = Vec::new(); + write_box(&mut sinf, *b"frma", &original)?; + let mut schm = vec![0; 4]; + schm.extend_from_slice(b"cbcs"); + schm.extend_from_slice(&0x0001_0000u32.to_be_bytes()); + write_box(&mut sinf, *b"schm", &schm)?; + let mut tenc = vec![ + 1, + 0, + 0, + 0, + 0, + (protection.crypt_byte_block << 4) | (protection.skip_byte_block & 0x0f), + 1, + 0, + ]; + tenc.extend_from_slice(&protection.key_id); + tenc.push(16); + tenc.extend_from_slice(&protection.constant_iv); + let mut scheme_info = Vec::new(); + write_box(&mut scheme_info, *b"tenc", &tenc)?; + write_box(&mut sinf, *b"schi", &scheme_info)?; + Ok(sinf) } fn track_extends(track_id: u32) -> Vec { @@ -393,4 +535,61 @@ mod tests { audio_entry(source_moov, 1) ); } + + fn init_for(name: &str, track_id: u32) -> (Vec, mp4::ParsedMedia) { + let media = parsed(name); + ( + write_init_segment(&media.metadata, track_id).unwrap(), + media, + ) + } + + #[test] + fn a_protected_init_segment_declares_cbcs_and_carries_the_pssh() { + let (_, media) = init_for("h264-aac.mp4", 1); + let pssh = vec![ + 0, 0, 0, 32, b'p', b's', b's', b'h', 0, 0, 0, 0, 0xed, 0xef, 0x8b, 0xa9, 0x79, 0xd6, + 0x4a, 0xce, 0xa3, 0xc8, 0x27, 0xdc, 0xd5, 0x1d, 0x21, 0xed, 0, 0, 0, 0, + ]; + let protection = InitProtection { + key_id: [1; 16], + constant_iv: [2; 16], + crypt_byte_block: 1, + skip_byte_block: 9, + pssh: std::slice::from_ref(&pssh), + }; + + let init = write_protected_init_segment(&media.metadata, 1, &protection).unwrap(); + + let find = |needle: &[u8]| { + init.windows(needle.len()) + .position(|window| window == needle) + }; + assert!( + find(b"encv").is_some() && find(b"avc1").is_some(), + "renamed, original in frma" + ); + assert!(find(b"frma").unwrap() < find(b"schm").unwrap()); + let schm = find(b"schm").unwrap(); + assert_eq!(&init[schm + 8..schm + 12], b"cbcs"); + let tenc = find(b"tenc").unwrap(); + assert_eq!(init[tenc + 4], 1, "tenc version 1"); + assert_eq!(init[tenc + 9], 0x19, "pattern 1:9"); + assert_eq!( + &init[tenc + 10..tenc + 12], + &[1, 0], + "protected, no per-sample IV" + ); + assert_eq!(&init[tenc + 12..tenc + 28], &[1; 16], "key ID"); + assert_eq!(init[tenc + 28], 16); + assert_eq!(&init[tenc + 29..tenc + 45], &[2; 16], "constant IV"); + assert!(find(&pssh).is_some(), "pssh copied into moov"); + // Every box size still adds up: the writer's own parser reads it back. + assert!(box_payload(&init[24..], 0).is_ok()); + let plain = write_init_segment(&media.metadata, 1).unwrap(); + assert!( + !plain.windows(4).any(|window| window == b"encv"), + "the clear writer is unchanged" + ); + } } diff --git a/src/fmp4/mod.rs b/src/fmp4/mod.rs index ce283cb..1684232 100644 --- a/src/fmp4/mod.rs +++ b/src/fmp4/mod.rs @@ -1,5 +1,10 @@ mod fragment; mod init; -pub(crate) use fragment::{PreparedSegment, prepare_media_segment, write_media_segment}; -pub(crate) use init::write_init_segment; +pub(crate) use fragment::{ + MAX_SUBSAMPLES, PreparedSegment, encrypted_fragment_header, prepare_media_segment, + write_media_segment, +}; +pub(crate) use init::{ + InitProtection, sample_entry, write_init_segment, write_protected_init_segment, +}; diff --git a/src/fuzzing.rs b/src/fuzzing.rs index 8466331..ee29711 100644 --- a/src/fuzzing.rs +++ b/src/fuzzing.rs @@ -67,3 +67,11 @@ async fn exercise(path: &Path) { pub fn max_input_bytes() -> u64 { LimitsConfig::default().max_metadata_bytes } + +/// Feeds arbitrary bytes to the H.264 parameter-set and slice-header parsers used for `cbcs`: +/// the first byte picks how many of the following bytes are an SPS, the rest is a PPS-then-slice +/// split at the midpoint. Errors are expected; panics and hangs are the bugs. +#[doc(hidden)] +pub fn exercise_avc_slice_header(data: &[u8]) { + crate::cenc::fuzz_avc(data); +} diff --git a/src/http/handlers/admin.rs b/src/http/handlers/admin.rs index 0219e73..9d6ac3b 100644 --- a/src/http/handlers/admin.rs +++ b/src/http/handlers/admin.rs @@ -25,7 +25,8 @@ struct ResolverJson { /// `readiness_probe_interval_ms` is `0`. healthy: bool, base_url: Option, - /// Every asset ID the resolver can name without being asked; only the static catalog can. + /// Asset IDs to offer: the static catalog's, or what a mapper lists at its optional + /// `GET /v1/assets`; `null` when there is no list. known_assets: Option>, } @@ -48,6 +49,9 @@ struct CachedAssetJson { /// One for anything but a sequence of clips. clips: usize, duration_seconds: f64, + encrypted: bool, + /// Key IDs only; content keys never leave the process. + key_ids: Vec, } impl From for StatusResponse { @@ -75,6 +79,8 @@ impl From for StatusResponse { subtitles: asset.subtitles, clips: asset.clips, duration_seconds: asset.duration_seconds, + encrypted: asset.key_ids.is_some(), + key_ids: asset.key_ids.unwrap_or_default(), }) .collect(), }, @@ -121,6 +127,8 @@ mod tests { subtitles: 1, clips: 1, duration_seconds: 120.5, + encrypted: false, + key_ids: Vec::new(), }], }, }; diff --git a/src/http/handlers/media.rs b/src/http/handlers/media.rs index 14e0ab1..a09793c 100644 --- a/src/http/handlers/media.rs +++ b/src/http/handlers/media.rs @@ -9,7 +9,7 @@ use axum::http::{HeaderMap, HeaderValue, Method, StatusCode}; use axum::response::Response; use bytes::Bytes; use serde::Deserialize; -use tokio::sync::mpsc; +use tokio::sync::{OwnedSemaphorePermit, mpsc}; use tokio_stream::wrappers::ReceiverStream; use super::parse_track; @@ -22,6 +22,7 @@ use crate::http::state::AppState; use crate::http::stream::StreamJob; use crate::http::validators::{entity_tag, not_modified, not_modified_response}; use crate::media::TrackKind; +use crate::source::ByteRange; /// The `v` query parameter carried by every init and media URL. #[derive(Debug, Deserialize)] @@ -73,9 +74,23 @@ pub(crate) async fn media_segment( let requested = parse_track(&track)?; let etag = entity_tag(asset.version(), &format!("{track}-segment-{segment_index}")); let kind = requested.key.kind; - serve_segment(&state, &method, &headers, asset, etag, kind, move |asset| { - asset.prepare_media_segment(requested.rendition.as_deref(), requested.key, segment_index) - }) + let what = format!("{asset_id}/{track}/segments/{segment_index}"); + serve_segment( + &state, + &method, + &headers, + asset, + etag, + kind, + what, + move |asset| { + asset.prepare_media_segment( + requested.rendition.as_deref(), + requested.key, + segment_index, + ) + }, + ) .await } @@ -147,6 +162,7 @@ pub(crate) async fn iframe_segment( let asset = state.asset(&asset_id).await?; version.require(asset.version())?; let etag = entity_tag(asset.version(), &format!("iframe-{frame_index}")); + let what = format!("{asset_id}/video/iframes/{frame_index}"); serve_segment( &state, &method, @@ -154,13 +170,77 @@ pub(crate) async fn iframe_segment( asset, etag, TrackKind::Video, + what, move |asset| asset.prepare_iframe(frame_index), ) .await } +/// Reads an encrypted track's samples, encrypts them off the async workers, and returns the +/// finished fragment as a header-only segment, so ranges, `HEAD`, and streaming from here on are +/// the clear path's (TDD 0009, "The encrypted segment path"). +/// +/// The returned job slot covers the encrypted bytes for as long as they live: the caller hands +/// it to the stream, which releases it when the response finishes or is aborted. +async fn encrypt_segment( + state: &AppState, + source: &PackagedAsset, + ranges: Vec, + pending: crate::cenc::PendingEncryption, + what: &str, +) -> HttpResult<(PreparedSegment, OwnedSemaphorePermit)> { + let started = Instant::now(); + let permit = state.segment_permit().await?; + let length = ranges + .iter() + .try_fold(0u64, |total, range| total.checked_add(range.length)) + .and_then(|total| usize::try_from(total).ok()) + .and_then(|total| total.checked_add(pending.header_room())) + .ok_or_else(|| { + HttpError::internal("encrypted segment does not fit in memory".to_owned()) + })?; + let mut payload = Vec::with_capacity(length); + for range in ranges { + match source.read_range(range).await { + Ok(bytes) => payload.extend_from_slice(&bytes), + Err(error) => { + state.metrics.encryption_failed(); + return Err(error.into()); + } + } + } + let finished = match tokio::task::spawn_blocking(move || pending.finish(payload)).await { + Ok(finished) => finished, + Err(error) => { + state.metrics.encryption_failed(); + return Err(HttpError::internal(error.to_string())); + } + }; + match finished { + Ok(bytes) => { + state.metrics.encrypted_segment(started.elapsed()); + let prepared = PreparedSegment { + content_length: bytes.len() as u64, + header: bytes, + ranges: Vec::new(), + encryption: None, + }; + Ok((prepared, permit)) + } + Err(error) => { + state.metrics.encryption_failed(); + tracing::error!(event = "segment_encryption_failed", segment = what, %error); + Err(error.into()) + } + } +} + /// Answers a request for a generated fragment: conditional, ranged, and streamed from the source /// through a bounded queue. +#[allow( + clippy::too_many_arguments, + reason = "one call shape for both segment routes; a parameter struct would only rename them" +)] async fn serve_segment( state: &AppState, method: &Method, @@ -168,6 +248,7 @@ async fn serve_segment( asset: Arc, etag: HeaderValue, kind: TrackKind, + what: String, prepare: impl FnOnce(&ServedAsset) -> crate::error::Result<(Arc, PreparedSegment)> + Send + 'static, @@ -179,12 +260,21 @@ async fn serve_segment( // Header generation is CPU work proportional to the segment's sample count, so it runs on // the blocking pool rather than an async worker. `source` is the specific underlying asset // (a composite has several) whose bytes the prepared fragment's offsets refer to. - let (source, prepared) = { + let (source, mut prepared) = { let asset = Arc::clone(&asset); tokio::task::spawn_blocking(move || prepare(&asset)) .await .map_err(|error| HttpError::internal(error.to_string()))?? }; + // An encrypted segment arrives with the job slot that covers its bytes in memory. + let (prepared, encrypted_permit) = match prepared.encryption.take() { + None => (prepared, None), + Some(pending) => { + let (prepared, permit) = + encrypt_segment(state, &source, prepared.ranges, *pending, &what).await?; + (prepared, Some(permit)) + } + }; let total_length = prepared.content_length; let requested_interval = match requested_range(headers, total_length, &etag) { Ok(range) => range.unwrap_or(ByteInterval { @@ -195,7 +285,9 @@ async fn serve_segment( }; let content_length = requested_interval.end - requested_interval.start; if method == Method::HEAD { - // Length and range are fully determined by metadata; no source read or job slot needed. + // Length and range are known without streaming. A clear segment needs no source read or + // job slot; an encrypted one was read and encrypted to learn its length, and its slot is + // released as this returns. return media_response_builder( total_length, requested_interval, @@ -206,7 +298,10 @@ async fn serve_segment( .map_err(|error| HttpError::internal(error.to_string())); } - let permit = state.segment_permit().await?; + let permit = match encrypted_permit { + Some(permit) => permit, + None => state.segment_permit().await?, + }; let (sender, receiver) = mpsc::channel(2); tokio::spawn( StreamJob { diff --git a/src/http/stream.rs b/src/http/stream.rs index fd2fb7a..aeff9ba 100644 --- a/src/http/stream.rs +++ b/src/http/stream.rs @@ -14,9 +14,12 @@ use crate::source::ByteRange; /// Streams one segment response as an async task. /// -/// A job slot is held only while a source read is in flight, never while waiting for the -/// client, so slow readers cannot exhaust `max_segment_jobs`. Each send is bounded by the idle -/// timeout, so a client that stops reading is dropped instead of pinning the task. +/// For a clear segment, a job slot is held only while a source read is in flight, never while +/// waiting for the client, so slow readers cannot exhaust `max_segment_jobs`. An encrypted +/// segment is already in memory: its slot arrives as `first_permit` and, with no source read to +/// consume it, is held until the stream ends, which bounds resident encrypted bytes. Each send is +/// bounded by the idle timeout, so a client that stops reading is dropped instead of pinning the +/// task. pub(crate) struct StreamJob { pub(crate) asset: Arc, pub(crate) prepared: fmp4::PreparedSegment, @@ -45,8 +48,16 @@ impl StreamJob { else { return self.fail("header range does not fit in memory").await; }; - let chunk = self.prepared.header.slice(start..end); - self.send(Ok(chunk)).await?; + // Sent in `chunk_size` pieces like the source ranges, so a header-only (encrypted) + // segment is also bounded by the idle timeout instead of sitting whole in the queue. + let chunk_size = self.chunk_size.max(1); + let mut at = start; + while at < end { + let next = end.min(at.saturating_add(chunk_size)); + let chunk = self.prepared.header.slice(at..next); + self.send(Ok(chunk)).await?; + at = next; + } } let mut virtual_offset = header_end; for source_range in std::mem::take(&mut self.prepared.ranges) { diff --git a/src/http/tests.rs b/src/http/tests.rs index 8480f4f..499ffb5 100644 --- a/src/http/tests.rs +++ b/src/http/tests.rs @@ -843,7 +843,7 @@ async fn rejects_connections_beyond_the_cap_and_recovers() { closed.is_ok(), "the over-limit connection should be closed at accept" ); - assert!(received.is_empty()); + assert_eq!(received, [] as [u8; 0]); assert_eq!( server .metrics diff --git a/src/lib.rs b/src/lib.rs index c4b01f6..e0f7a8b 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -10,6 +10,7 @@ use std::process::ExitCode; mod asset; +mod cenc; mod cli; mod clip; mod composite; diff --git a/src/mp4/codec.rs b/src/mp4/codec.rs index d72ff85..ab43b42 100644 --- a/src/mp4/codec.rs +++ b/src/mp4/codec.rs @@ -15,7 +15,7 @@ use crate::media::CodecConfig; const VISUAL_ENTRY_HEAD: usize = 78; /// The width, height, and child boxes of a visual sample entry payload. -fn visual_entry(entry: &[u8]) -> Result<(u16, u16, &[u8])> { +pub(crate) fn visual_entry(entry: &[u8]) -> Result<(u16, u16, &[u8])> { let mut reader = Reader::new(entry); // Reserved (6), data reference index (2), and 16 bytes of pre-defined fields. reader.skip(24)?; diff --git a/src/observability/metrics.rs b/src/observability/metrics.rs index bc952f9..3057147 100644 --- a/src/observability/metrics.rs +++ b/src/observability/metrics.rs @@ -32,6 +32,9 @@ pub(crate) struct Metrics { loads_ok: AtomicU64, loads_failed: AtomicU64, load_micros: AtomicU64, + encrypted_segments: AtomicU64, + encryption_micros: AtomicU64, + encryption_failures: AtomicU64, coalesced_waiters: AtomicU64, location_rotations: AtomicU64, loaded_assets: AtomicU64, @@ -68,6 +71,9 @@ impl Metrics { loads_ok: AtomicU64::new(0), loads_failed: AtomicU64::new(0), load_micros: AtomicU64::new(0), + encrypted_segments: AtomicU64::new(0), + encryption_micros: AtomicU64::new(0), + encryption_failures: AtomicU64::new(0), coalesced_waiters: AtomicU64::new(0), location_rotations: AtomicU64::new(0), loaded_assets: AtomicU64::new(0), @@ -187,6 +193,18 @@ impl Metrics { self.cache_events[event as usize].fetch_add(1, Relaxed); } + pub(crate) fn encrypted_segment(&self, elapsed: Duration) { + self.encrypted_segments.fetch_add(1, Relaxed); + self.encryption_micros.fetch_add( + u64::try_from(elapsed.as_micros()).unwrap_or(u64::MAX), + Relaxed, + ); + } + + pub(crate) fn encryption_failed(&self) { + self.encryption_failures.fetch_add(1, Relaxed); + } + pub(crate) fn asset_load(&self, succeeded: bool, elapsed: Duration) { if succeeded { &self.loads_ok @@ -398,10 +416,13 @@ impl Metrics { } let _ = writeln!( out, - "# HELP vod_asset_loads_total Asset loads by outcome.\n# TYPE vod_asset_loads_total counter\nvod_asset_loads_total{{outcome=\"ok\"}} {}\nvod_asset_loads_total{{outcome=\"failed\"}} {}\n# HELP vod_asset_load_seconds_total Time spent loading assets.\n# TYPE vod_asset_load_seconds_total counter\nvod_asset_load_seconds_total {}\n# HELP vod_registry_coalesced_waiters_total Requests that shared another request's resolve or load.\n# TYPE vod_registry_coalesced_waiters_total counter\nvod_registry_coalesced_waiters_total {}\n# HELP vod_location_rotations_total Signed URLs replaced in place on loaded assets.\n# TYPE vod_location_rotations_total counter\nvod_location_rotations_total {}\n# HELP vod_loaded_assets Assets currently held in memory.\n# TYPE vod_loaded_assets gauge\nvod_loaded_assets {}\n# HELP vod_loaded_bytes Estimated bytes held by loaded assets.\n# TYPE vod_loaded_bytes gauge\nvod_loaded_bytes {}", + "# HELP vod_asset_loads_total Asset loads by outcome.\n# TYPE vod_asset_loads_total counter\nvod_asset_loads_total{{outcome=\"ok\"}} {}\nvod_asset_loads_total{{outcome=\"failed\"}} {}\n# HELP vod_asset_load_seconds_total Time spent loading assets.\n# TYPE vod_asset_load_seconds_total counter\nvod_asset_load_seconds_total {}\n# HELP vod_encrypted_segments_total Segments encrypted on request.\n# TYPE vod_encrypted_segments_total counter\nvod_encrypted_segments_total {}\n# HELP vod_encryption_seconds_total Time spent reading and encrypting segments.\n# TYPE vod_encryption_seconds_total counter\nvod_encryption_seconds_total {}\n# HELP vod_encryption_failures_total Segments that could not be encrypted.\n# TYPE vod_encryption_failures_total counter\nvod_encryption_failures_total {}\n# HELP vod_registry_coalesced_waiters_total Requests that shared another request's resolve or load.\n# TYPE vod_registry_coalesced_waiters_total counter\nvod_registry_coalesced_waiters_total {}\n# HELP vod_location_rotations_total Signed URLs replaced in place on loaded assets.\n# TYPE vod_location_rotations_total counter\nvod_location_rotations_total {}\n# HELP vod_loaded_assets Assets currently held in memory.\n# TYPE vod_loaded_assets gauge\nvod_loaded_assets {}\n# HELP vod_loaded_bytes Estimated bytes held by loaded assets.\n# TYPE vod_loaded_bytes gauge\nvod_loaded_bytes {}", self.loads_ok.load(Relaxed), self.loads_failed.load(Relaxed), Duration::from_micros(self.load_micros.load(Relaxed)).as_secs_f64(), + self.encrypted_segments.load(Relaxed), + Duration::from_micros(self.encryption_micros.load(Relaxed)).as_secs_f64(), + self.encryption_failures.load(Relaxed), self.coalesced_waiters.load(Relaxed), self.location_rotations.load(Relaxed), self.loaded_assets.load(Relaxed), diff --git a/src/protocol/dash.rs b/src/protocol/dash.rs index e4ce334..afbe90c 100644 --- a/src/protocol/dash.rs +++ b/src/protocol/dash.rs @@ -26,16 +26,21 @@ impl Addressing { }; } -fn mpd_open(duration_seconds: &str) -> String { +fn mpd_open(duration_seconds: &str, encrypted: bool) -> String { + let namespaces = if encrypted { + " xmlns:cenc=\"urn:mpeg:cenc:2013\" xmlns:dashif=\"https://dashif.org/CPS\"" + } else { + "" + }; format!( - "\n\n" + "\n\n" ) } pub(crate) fn manifest(presentation: Presentation<'_>) -> Result { let duration = presentation_duration(presentation)?; let version = presentation.version(); - let mut manifest = mpd_open(&duration); + let mut manifest = mpd_open(&duration, presentation.encryption().is_some()); manifest.push_str(" \n"); if let Some(video) = presentation.video() { write_video_adaptation( @@ -85,6 +90,12 @@ pub(crate) fn write_video_adaptation( " " ) .expect("writing to a String cannot fail"); + if let Some(encryption) = presentation.encryption() { + manifest.push_str(&crate::cenc::dash_content_protection( + encryption, + encryption.key_for(track.kind), + )); + } writeln!(manifest, " ", presentation.bandwidth(track)?.peak) .expect("writing to a String cannot fail"); write_segment_template(manifest, presentation, track, version, addressing); @@ -114,6 +125,12 @@ pub(crate) fn write_audio_adaptation( " " ) .expect("writing to a String cannot fail"); + if let Some(encryption) = presentation.encryption() { + manifest.push_str(&crate::cenc::dash_content_protection( + encryption, + encryption.key_for(track.kind), + )); + } writeln!(manifest, " ", presentation.bandwidth(track)?.peak) .expect("writing to a String cannot fail"); writeln!(manifest, " ") @@ -125,10 +142,10 @@ pub(crate) fn write_audio_adaptation( /// A static MPD header and footer around adaptation sets the caller has already written, for an /// adaptive asset whose renditions come from several `Presentation`s. -pub(crate) fn wrap_manifest(duration_seconds: &str, body: &str) -> String { +pub(crate) fn wrap_manifest(duration_seconds: &str, body: &str, encrypted: bool) -> String { format!( "{} \n{body} \n\n", - mpd_open(duration_seconds) + mpd_open(duration_seconds, encrypted) ) } @@ -215,7 +232,11 @@ pub(crate) fn sequence_manifest( total_nanos: u64, version: &str, ) -> Result { - let mut manifest = mpd_open(&nanos_as_seconds(total_nanos)); + let encrypted = clips + .first() + .and_then(|clip| clip.presentation.encryption()) + .is_some(); + let mut manifest = mpd_open(&nanos_as_seconds(total_nanos), encrypted); for (position, clip) in clips.iter().enumerate() { writeln!( manifest, @@ -295,6 +316,31 @@ mod tests { use super::*; use crate::protocol::fixtures::Loaded; + #[test] + fn an_encrypted_manifest_declares_its_namespaces_and_protection() { + let loaded = Loaded::h264_aac(); + let encryption = crate::cenc::tests_support::sample_encryption(); + + let manifest = manifest(loaded.presentation().with_encryption(Some(&encryption))).unwrap(); + + assert!( + manifest.contains( + r#"xmlns:cenc="urn:mpeg:cenc:2013" xmlns:dashif="https://dashif.org/CPS""# + ), + "{manifest}" + ); + assert_eq!( + manifest.matches(r#"value="cbcs""#).count(), + 2, + "one per adaptation set: {manifest}" + ); + assert!( + !self::manifest(loaded.presentation()) + .unwrap() + .contains("ContentProtection") + ); + } + #[test] fn renders_static_manifest_with_both_representations() { let loaded = Loaded::h264_aac(); diff --git a/src/protocol/hls.rs b/src/protocol/hls.rs index bf56cc2..814ee70 100644 --- a/src/protocol/hls.rs +++ b/src/protocol/hls.rs @@ -29,6 +29,7 @@ pub(crate) fn adaptive_master_playlist( audio: &[AdaptiveAudio<'_>], subtitles: &[Subtitle], iframe_stream_line: Option<&str>, + encryption: Option<&crate::cenc::Encryption>, ) -> Result { if video.is_empty() { return Err(Error::InvalidMedia( @@ -36,6 +37,11 @@ pub(crate) fn adaptive_master_playlist( )); } let mut playlist = String::from("#EXTM3U\n#EXT-X-VERSION:7\n"); + playlist.push_str( + &encryption + .map(crate::cenc::hls_session_keys) + .unwrap_or_default(), + ); for (index, entry) in audio.iter().enumerate() { let language = track_language(entry.track); let name = language.map_or_else( @@ -115,6 +121,12 @@ pub(crate) fn master_playlist(presentation: Presentation<'_>) -> Result } let mut playlist = String::from("#EXTM3U\n#EXT-X-VERSION:7\n"); + playlist.push_str( + &presentation + .encryption() + .map(crate::cenc::hls_session_keys) + .unwrap_or_default(), + ); // Audio joins the variant as a rendition group whenever there is something to choose // between: video plus audio, or several audio tracks. A lone audio track is just the variant. let audio_group = audio.is_some() && (video.is_some() || audio_tracks.len() > 1); @@ -170,13 +182,26 @@ pub(crate) fn media_playlist(presentation: Presentation<'_>, key: TrackKey) -> R let version = presentation.version(); let target_duration = target_duration(presentation, track); let mut playlist = format!( - "#EXTM3U\n#EXT-X-VERSION:7\n#EXT-X-TARGETDURATION:{target_duration}\n#EXT-X-MEDIA-SEQUENCE:0\n#EXT-X-PLAYLIST-TYPE:VOD\n#EXT-X-INDEPENDENT-SEGMENTS\n#EXT-X-MAP:URI=\"init.mp4?v={version}\"\n" + "#EXTM3U\n#EXT-X-VERSION:7\n#EXT-X-TARGETDURATION:{target_duration}\n#EXT-X-MEDIA-SEQUENCE:0\n#EXT-X-PLAYLIST-TYPE:VOD\n#EXT-X-INDEPENDENT-SEGMENTS\n" ); + playlist.push_str(&key_lines(presentation, track)); + writeln!(playlist, "#EXT-X-MAP:URI=\"init.mp4?v={version}\"") + .expect("writing to a String cannot fail"); write_segment_entries(&mut playlist, presentation, track, 0, version)?; playlist.push_str("#EXT-X-ENDLIST\n"); Ok(playlist) } +/// The `#EXT-X-KEY` lines for `track`, empty when the presentation is clear. +fn key_lines(presentation: Presentation<'_>, track: &Track) -> String { + presentation + .encryption() + .map(|encryption| { + crate::cenc::hls_key_lines(encryption, encryption.key_for(track.kind), "EXT-X-KEY") + }) + .unwrap_or_default() +} + /// The longest segment of `track`, in whole seconds rounded up, as `#EXT-X-TARGETDURATION` needs. fn target_duration(presentation: Presentation<'_>, track: &Track) -> u64 { presentation @@ -232,6 +257,12 @@ pub(crate) fn sequence_media_playlist( let mut playlist = format!( "#EXTM3U\n#EXT-X-VERSION:7\n#EXT-X-TARGETDURATION:{target}\n#EXT-X-MEDIA-SEQUENCE:0\n#EXT-X-PLAYLIST-TYPE:VOD\n#EXT-X-INDEPENDENT-SEGMENTS\n" ); + if let Some(first) = clips.first() { + playlist.push_str(&key_lines( + first.presentation, + first.presentation.track(key)?, + )); + } for (position, clip) in clips.iter().enumerate() { let track = clip.presentation.track(key)?; if position > 0 { @@ -280,6 +311,12 @@ pub(crate) fn sequence_master_playlist( let audio_group = !audio_keys.is_empty() && (video_key.is_some() || audio_keys.len() > 1); let mut playlist = String::from("#EXTM3U\n#EXT-X-VERSION:7\n"); + playlist.push_str( + &first + .encryption() + .map(crate::cenc::hls_session_keys) + .unwrap_or_default(), + ); if audio_group { for (index, key) in audio_keys.iter().enumerate() { // The first clip whose track names a language labels the rendition, so an unlabelled @@ -406,8 +443,11 @@ pub(crate) fn iframe_playlist(presentation: Presentation<'_>) -> Result { plan: &'a SegmentPlan, version: &'a str, subtitles: &'a [Subtitle], + encryption: Option<&'a crate::cenc::Encryption>, } impl<'a> Presentation<'a> { @@ -31,9 +32,22 @@ impl<'a> Presentation<'a> { plan, version, subtitles: &[], + encryption: None, } } + /// The same view, encrypted with `encryption` (TDD 0009). + pub(crate) const fn with_encryption( + self, + encryption: Option<&'a crate::cenc::Encryption>, + ) -> Self { + Self { encryption, ..self } + } + + pub(crate) const fn encryption(&self) -> Option<&'a crate::cenc::Encryption> { + self.encryption + } + /// The same view with the asset's sidecar subtitles. pub(crate) const fn with_subtitles(self, subtitles: &'a [Subtitle]) -> Self { Self { subtitles, ..self } diff --git a/src/registry/cache.rs b/src/registry/cache.rs index 5ad36be..ba3473f 100644 --- a/src/registry/cache.rs +++ b/src/registry/cache.rs @@ -20,6 +20,8 @@ pub(crate) struct CachedAsset { pub(crate) subtitles: usize, pub(crate) clips: usize, pub(crate) duration_seconds: f64, + /// The key IDs (as UUIDs) of an encrypted asset; never the keys. + pub(crate) key_ids: Option>, } type Key = (String, String); @@ -140,6 +142,7 @@ impl LoadedCache { subtitles: entry.asset.subtitle_count(), clips: entry.asset.clip_count(), duration_seconds: entry.asset.duration_seconds(), + key_ids: entry.asset.key_ids(), }, ) }) diff --git a/src/registry/mod.rs b/src/registry/mod.rs index 248bb01..2861d00 100644 --- a/src/registry/mod.rs +++ b/src/registry/mod.rs @@ -25,7 +25,7 @@ use reqwest::Url; use tokio::sync::{Mutex as AsyncMutex, OwnedMutexGuard, Semaphore}; use tokio::time::{Duration, timeout}; -use crate::asset::PackagedAsset; +use crate::asset::{Extras, PackagedAsset}; use crate::composite::{self, ServedAsset}; use crate::config::{Config, LimitsConfig, is_valid_asset_id}; use crate::error::{Error, Result}; @@ -70,7 +70,7 @@ pub(crate) struct ResolverStatus { pub(crate) healthy: bool, /// The mapper's base URL; `None` for the static catalog. pub(crate) base_url: Option, - /// Asset IDs the resolver knows without asking anything: only the static catalog has them. + /// Asset IDs to offer in a control panel: the static catalog's, or a mapper's optional listing. pub(crate) known_assets: Option>, } @@ -307,8 +307,10 @@ impl AssetRegistry { /// A live snapshot for the admin status endpoint: resolver health, checked right now rather /// than from the background probe, and every asset currently held in the loaded-asset cache. pub(crate) async fn status(&self) -> RegistryStatus { - let healthy = self.resolver.healthy().await; - let known_assets = (self.resolver.kind() == "static").then(|| self.resolver.known_ids()); + let (healthy, known_assets) = tokio::join!( + self.resolver.healthy(), + self.resolver.listed_ids(self.limits.max_assets) + ); let cache = lock(&self.loaded); RegistryStatus { resolver: ResolverStatus { @@ -357,6 +359,7 @@ impl AssetRegistry { media.tracks = asset.track_count(), media.segments = asset.segment_count(), media.clips = asset.clip_count(), + media.encrypted = asset.key_ids().is_some(), index.bytes = asset.index_bytes(), elapsed_ms = started.elapsed().as_millis(), ); @@ -554,6 +557,7 @@ impl AssetRegistry { let old_locations = old.locations(); let new_locations = new.locations(); let same_objects = old.version == new.version + && old.encryption == new.encryption && old_locations.len() == new_locations.len() && old_locations.iter().zip(&new_locations).all( |((old_id, old_loc), (new_id, new_loc))| { @@ -710,6 +714,7 @@ impl AssetRegistry { media.tracks = asset.track_count(), media.segments = asset.segment_count(), media.clips = asset.clip_count(), + media.encrypted = asset.key_ids().is_some(), index.bytes = asset.index_bytes(), elapsed_ms = started.elapsed().as_millis(), ); @@ -749,9 +754,13 @@ impl AssetRegistry { ) .await?; let subtitles = self.fetch_subtitles(&resolved.subtitles).await?; - let asset = PackagedAsset::load_with_subtitles( + let asset = PackagedAsset::load_with( source, - subtitles, + Extras { + subtitles, + version: None, + encryption: resolved.encryption.clone(), + }, self.settings.segment_duration_ms, &self.limits, ) @@ -781,6 +790,7 @@ impl AssetRegistry { for rendition in resolved.renditions.clone() { let registry = Arc::clone(self); let asset_id = asset_id.to_owned(); + let encryption = resolved.encryption.clone(); tasks.spawn(async move { let outcome: Result<(PackagedAsset, Arc)> = async { let refresher = Arc::new(AssetRefresher { @@ -796,8 +806,12 @@ impl AssetRegistry { Arc::clone(&refresher) as Arc, ) .await?; - let asset = PackagedAsset::load( + let asset = PackagedAsset::load_with( source, + Extras { + encryption, + ..Extras::default() + }, registry.settings.segment_duration_ms, ®istry.limits, ) @@ -908,6 +922,7 @@ impl AssetRegistry { let version = resolved.version.clone(); let segment_duration_ms = self.settings.segment_duration_ms; let limits = self.limits.clone(); + let encryption = resolved.encryption.clone(); let asset = tokio::task::spawn_blocking(move || { sequence::build( &asset_id, @@ -916,6 +931,7 @@ impl AssetRegistry { &version, segment_duration_ms, &limits, + encryption.as_ref(), ) }) .await diff --git a/src/registry/tests.rs b/src/registry/tests.rs index aa67b03..04f0ed4 100644 --- a/src/registry/tests.rs +++ b/src/registry/tests.rs @@ -663,7 +663,7 @@ async fn remote_ranges_reach_the_origin_as_ranges() { .await; let ranges = origin.state.ranges.lock().unwrap().clone(); - assert!(!ranges.is_empty()); + assert_ne!(ranges, Vec::::new()); assert!( ranges.iter().all(|range| range.starts_with("bytes=")), "{ranges:?}" @@ -1031,7 +1031,7 @@ async fn a_rotated_signature_is_applied_in_place_without_reloading() { .await; assert_eq!(status_code, StatusCode::OK); - assert!(!segment.is_empty()); + assert_ne!(segment, Vec::::new()); let used = origin.state.queries.lock().unwrap()[before..].to_vec(); assert!( !used.is_empty() && used.iter().all(|query| query == "sig=2"), @@ -1793,7 +1793,7 @@ async fn a_whole_file_clip_serves_the_same_media_bytes_as_the_file() { ) .await .2; - assert!(!plain.is_empty()); + assert_ne!(plain, Vec::::new()); assert_eq!(plain, whole, "{track} segment {segment}"); } } @@ -2096,6 +2096,7 @@ async fn decode_each( base: &str, clips: &[(String, Vec)], directory: &std::path::Path, + key: Option<&str>, ) -> Vec { std::fs::create_dir_all(directory).unwrap(); let mut counts = Vec::new(); @@ -2106,14 +2107,18 @@ async fn decode_each( } let path = directory.join(format!("clip-{position}.mp4")); std::fs::write(&path, bytes).unwrap(); - let count = std::process::Command::new("ffprobe") - .args(["-v", "error", "-count_frames", "-select_streams", "v:0"]) - .args(["-show_entries", "stream=nb_read_frames", "-of", "csv=p=0"]) - .arg(&path) - .output() - .unwrap(); - let decode = std::process::Command::new("ffmpeg") - .args(["-v", "error", "-i"]) + let mut probe = std::process::Command::new("ffprobe"); + probe.args(["-v", "error", "-count_frames", "-select_streams", "v:0"]); + probe.args(["-show_entries", "stream=nb_read_frames", "-of", "csv=p=0"]); + let mut ffmpeg = std::process::Command::new("ffmpeg"); + ffmpeg.args(["-v", "error"]); + if let Some(key) = key { + probe.args(["-decryption_key", key]); + ffmpeg.args(["-decryption_key", key]); + } + let count = probe.arg("-i").arg(&path).output().unwrap(); + let decode = ffmpeg + .arg("-i") .arg(&path) .args(["-f", "null", "-"]) .output() @@ -2180,7 +2185,14 @@ async fn every_clip_of_a_sequence_decodes_from_the_served_hls_and_dash() { .push(line.to_owned()); } } - let frames = decode_each(&h.app, "/hls/seq/video/", &clips, &directory.join("hls")).await; + let frames = decode_each( + &h.app, + "/hls/seq/video/", + &clips, + &directory.join("hls"), + None, + ) + .await; assert_eq!( frames, [90, 90, 60], @@ -2225,6 +2237,450 @@ async fn every_clip_of_a_sequence_decodes_from_the_served_hls_and_dash() { assert_eq!(tfdt(&fragment), offset, "period {position}"); clips.push((format!("clips/{position}/init.mp4?v={version}"), segments)); } - let frames = decode_each(&h.app, "/dash/seq/video/", &clips, &directory.join("dash")).await; + let frames = decode_each( + &h.app, + "/dash/seq/video/", + &clips, + &directory.join("dash"), + None, + ) + .await; assert_eq!(frames, [90, 90, 60]); } + +// --------------------------------------------------------------------------------------------- +// The optional asset listing (`GET /v1/assets`) +// --------------------------------------------------------------------------------------------- + +async fn known_assets(app: &Router) -> serde_json::Value { + let json: serde_json::Value = + serde_json::from_slice(&fetch(app, "/admin/status").await.2).unwrap(); + json["resolver"]["known_assets"].clone() +} + +#[tokio::test] +async fn admin_status_lists_what_the_mapper_lists_sorted_and_checked() { + let h = harness().await; + *h.mapper.state.listing.lock().unwrap() = + Some(r#"{"assets":["preroll","movie","movie","../etc","","has space"]}"#.to_owned()); + + assert_eq!( + known_assets(&h.app).await, + serde_json::json!(["movie", "preroll"]), + "sorted, once each, and only IDs a request could name" + ); +} + +#[tokio::test] +async fn a_malformed_listing_is_no_listing() { + let h = harness().await; + *h.mapper.state.listing.lock().unwrap() = Some(r#"{"assets":"movie"}"#.to_owned()); + + assert_eq!(known_assets(&h.app).await, serde_json::Value::Null); +} + +#[tokio::test] +async fn a_listing_is_capped_at_max_assets() { + let h = harness_with(|config| config.limits.max_assets = 2).await; + *h.mapper.state.listing.lock().unwrap() = Some(r#"{"assets":["c","b","a"]}"#.to_owned()); + + assert_eq!(known_assets(&h.app).await, serde_json::json!(["a", "b"])); +} + +// --------------------------------------------------------------------------------------------- +// Encryption (TDD 0009) +// --------------------------------------------------------------------------------------------- + +const KEY_ID: &str = "0123456789abcdef0123456789abcdef"; +const KEY: &str = "00112233445566778899aabbccddeeff"; + +fn clear_key_encryption(key: &str) -> serde_json::Value { + serde_json::json!({ + "scheme": "cbcs", + "keys": [{ "key_id": KEY_ID, "key": key }], + "systems": [{ "system_id": "e2719d58-a985-b3c9-781a-b030af78d30e", "license_url": "https://l.example.net/ck" }], + }) +} + +#[tokio::test] +async fn an_encrypted_asset_serves_protected_init_and_fragments() { + let h = harness().await; + h.mapper.state.set( + "drm", + Answer::file("v1", "h264-aac.mp4").with_encryption(clear_key_encryption(KEY)), + ); + let version = version_in(&fetch(&h.app, "/hls/drm/master.m3u8").await.2); + + let init = fetch(&h.app, &format!("/hls/drm/video/init.mp4?v={version}")) + .await + .2; + let segment = fetch( + &h.app, + &format!("/hls/drm/video/segments/1/media.m4s?v={version}"), + ) + .await; + let audio = fetch(&h.app, &format!("/dash/drm/audio-1/init.mp4?v={version}")) + .await + .2; + + assert!(init.windows(4).any(|w| w == b"encv") && init.windows(4).any(|w| w == b"tenc")); + assert!(audio.windows(4).any(|w| w == b"enca")); + assert_eq!(segment.0, StatusCode::OK); + assert!(segment.2.windows(4).any(|w| w == b"senc")); + let playlist = text(&fetch(&h.app, "/hls/drm/video/index.m3u8").await.2); + assert!( + playlist.contains(r#"KEYFORMAT="org.w3.clearkey""#), + "{playlist}" + ); + assert!(metric(&h.state, "vod_encrypted_segments_total 1")); +} + +#[tokio::test] +async fn range_and_head_requests_work_on_an_encrypted_segment() { + let h = harness().await; + h.mapper.state.set( + "drm", + Answer::file("v1", "h264-aac.mp4").with_encryption(clear_key_encryption(KEY)), + ); + let version = version_in(&fetch(&h.app, "/hls/drm/master.m3u8").await.2); + let uri = format!("/hls/drm/video/segments/0/media.m4s?v={version}"); + let whole = fetch(&h.app, &uri).await.2; + + let ranged = h + .app + .clone() + .oneshot( + Request::get(&uri) + .header(axum::http::header::RANGE, "bytes=8-15") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + let head = h + .app + .clone() + .oneshot(Request::head(&uri).body(Body::empty()).unwrap()) + .await + .unwrap(); + + assert_eq!(ranged.status(), StatusCode::PARTIAL_CONTENT); + assert_eq!( + &to_bytes(ranged.into_body(), usize::MAX).await.unwrap()[..], + &whole[8..16] + ); + assert_eq!( + head.headers()[axum::http::header::CONTENT_LENGTH], + whole.len().to_string().as_str() + ); + assert_eq!( + fetch(&h.app, &uri).await.2, + whole, + "deterministic: the same bytes again" + ); +} + +/// An encrypted segment is streamed in `stream_chunk_bytes` pieces like a clear one, so the idle +/// timeout applies to it, and its job slot is back once the response finishes. +#[tokio::test] +async fn an_encrypted_segment_streams_in_chunks_and_frees_its_job_slot() { + use tokio_stream::StreamExt as _; + let h = harness_with(|config| { + config.limits.stream_chunk_bytes = 1024; + config.limits.max_segment_jobs = 2; + }) + .await; + h.mapper.state.set( + "drm", + Answer::file("v1", "h264-aac.mp4").with_encryption(clear_key_encryption(KEY)), + ); + let version = version_in(&fetch(&h.app, "/hls/drm/master.m3u8").await.2); + let uri = format!("/hls/drm/video/segments/0/media.m4s?v={version}"); + + let response = h + .app + .clone() + .oneshot(Request::get(&uri).body(Body::empty()).unwrap()) + .await + .unwrap(); + let mut stream = response.into_body().into_data_stream(); + let (mut chunks, mut total) = (0usize, 0usize); + while let Some(chunk) = stream.next().await { + let chunk = chunk.unwrap(); + assert!(chunk.len() <= 1024, "a chunk of {} bytes", chunk.len()); + chunks += 1; + total += chunk.len(); + } + + assert!(total > 1024, "the fixture segment is larger than one chunk"); + assert!(chunks > 1, "{chunks} chunk(s) for {total} bytes"); + tokio::time::sleep(Duration::from_millis(20)).await; + assert_eq!(h.state.segment_jobs.available_permits(), 2); +} + +/// A client that stops reading an encrypted segment is cut off after the idle timeout, and the +/// segment's job slot is released with it. +#[tokio::test] +async fn a_stalled_encrypted_response_is_dropped_and_frees_its_job_slot() { + let h = harness_with(|config| { + config.limits.stream_chunk_bytes = 64; + config.limits.response_idle_timeout_ms = 50; + config.limits.max_segment_jobs = 1; + }) + .await; + h.mapper.state.set( + "drm", + Answer::file("v1", "h264-aac.mp4").with_encryption(clear_key_encryption(KEY)), + ); + let version = version_in(&fetch(&h.app, "/hls/drm/master.m3u8").await.2); + let uri = format!("/hls/drm/video/segments/0/media.m4s?v={version}"); + + let stalled = h + .app + .clone() + .oneshot(Request::get(&uri).body(Body::empty()).unwrap()) + .await + .unwrap(); + assert_eq!(stalled.status(), StatusCode::OK); + tokio::time::sleep(Duration::from_millis(300)).await; + + assert!(metric(&h.state, "vod_segment_stream_aborts_idle_total 1")); + assert_eq!(h.state.segment_jobs.available_permits(), 1); + assert_eq!(status(&h.app, &uri).await, StatusCode::OK); + drop(stalled); +} + +#[tokio::test] +async fn an_unsupported_codec_with_encryption_fails_the_asset() { + let h = harness().await; + h.mapper.state.set( + "drm", + Answer::file("v1", "hevc-aac.mp4").with_encryption(clear_key_encryption(KEY)), + ); + + assert_eq!( + status(&h.app, "/hls/drm/master.m3u8").await, + StatusCode::INTERNAL_SERVER_ERROR + ); +} + +#[tokio::test] +async fn the_key_changes_the_url_version() { + let h = harness().await; + h.mapper.state.set( + "a", + Answer::file("v1", "h264-aac.mp4").with_encryption(clear_key_encryption(KEY)), + ); + h.mapper.state.set( + "b", + Answer::file("v1", "h264-aac.mp4") + .with_encryption(clear_key_encryption("ffeeddccbbaa99887766554433221100")), + ); + h.mapper.state.set("c", Answer::file("v1", "h264-aac.mp4")); + + let version = |id: &'static str| { + let app = h.app.clone(); + async move { version_in(&fetch(&app, &format!("/hls/{id}/master.m3u8")).await.2) } + }; + let (a, b, c) = (version("a").await, version("b").await, version("c").await); + assert!(a != b && a != c && b != c); +} + +#[tokio::test] +async fn rekeying_under_the_same_version_reloads_with_the_new_key() { + let h = harness().await; + let answer = |key: &str| { + let mut answer = + Answer::file("v1", "h264-aac.mp4").with_encryption(clear_key_encryption(key)); + answer.ttl_seconds = Some(0); + answer + }; + h.mapper.state.always_full.store(true, Ordering::SeqCst); + h.mapper.state.set("drm", answer(KEY)); + let before = version_in(&fetch(&h.app, "/hls/drm/master.m3u8").await.2); + + h.mapper + .state + .set("drm", answer("ffeeddccbbaa99887766554433221100")); + tokio::time::sleep(Duration::from_millis(40)).await; + let after = version_in(&fetch(&h.app, "/hls/drm/master.m3u8").await.2); + + assert_ne!(before, after, "new key, new URLs"); + assert!( + metric(&h.state, "vod_asset_loads_total{outcome=\"ok\"} 2"), + "reloaded with the new key" + ); +} + +#[tokio::test] +async fn a_rejected_encryption_answer_never_echoes_the_key() { + let h = harness().await; + *h.mapper.state.raw_body.lock().unwrap() = Some(format!( + r#"{{"asset_id":"drm","version":"v1","location":{{"type":"file","path":"h264-aac.mp4"}},"encryption":{{"scheme":"cbcs","keys":[{{"key_id":"{KEY_ID}","key":"{KEY}zz"}}]}}}}"# + )); + + let (code, _, body) = fetch(&h.app, "/hls/drm/master.m3u8").await; + + assert_eq!(code, StatusCode::BAD_GATEWAY); + assert!(!String::from_utf8_lossy(&body).contains(KEY)); +} + +#[tokio::test] +async fn a_mistyped_encryption_answer_is_a_bad_gateway() { + let h = harness().await; + *h.mapper.state.raw_body.lock().unwrap() = Some(format!( + r#"{{"asset_id":"drm","version":"v1","location":{{"type":"file","path":"h264-aac.mp4"}},"encryption":{{"scheme":"cbcs","keys":"{KEY}"}}}}"# + )); + + let (code, _, body) = fetch(&h.app, "/hls/drm/master.m3u8").await; + + assert_eq!(code, StatusCode::BAD_GATEWAY); + assert!(!String::from_utf8_lossy(&body).contains(KEY)); +} + +#[tokio::test] +async fn admin_status_reports_key_ids_never_keys() { + let h = harness().await; + h.mapper.state.set( + "drm", + Answer::file("v1", "h264-aac.mp4").with_encryption(clear_key_encryption(KEY)), + ); + assert_eq!(status(&h.app, "/hls/drm/master.m3u8").await, StatusCode::OK); + + let body = fetch(&h.app, "/admin/status").await.2; + let json: serde_json::Value = serde_json::from_slice(&body).unwrap(); + + assert_eq!(json["cache"]["assets"][0]["encrypted"], true); + assert_eq!( + json["cache"]["assets"][0]["key_ids"][0], + "01234567-89ab-cdef-0123-456789abcdef" + ); + assert!(!String::from_utf8_lossy(&body).contains(KEY)); +} + +/// `FFmpeg`'s error output decoding `path`, optionally with a decryption key. +fn decode_errors(path: &std::path::Path, key: Option<&str>) -> String { + let mut command = std::process::Command::new("ffmpeg"); + command.args(["-v", "error"]); + if let Some(key) = key { + command.args(["-decryption_key", key]); + } + let output = command + .arg("-i") + .arg(path) + .args(["-f", "null", "-"]) + .output() + .unwrap(); + String::from_utf8_lossy(&output.stderr).into_owned() +} + +/// An independent implementation (`FFmpeg`'s `cbcs` decryptor) must recover every frame with the +/// right key, and must not with a wrong one (TDD 0009, "Testing"). +#[tokio::test] +async fn ffmpeg_decrypts_every_encrypted_track_we_serve() { + if std::process::Command::new("ffprobe") + .arg("-version") + .output() + .is_err() + { + eprintln!("skipping: ffprobe is not installed"); + return; + } + let h = harness().await; + let encryption = clear_key_encryption(KEY); + h.mapper.state.set( + "one", + Answer::file("v1", "h264-aac.mp4").with_encryption(encryption.clone()), + ); + h.mapper.state.set( + "seq", + Answer::clips( + "v1", + &[ + clip("h264-aac.mp4", None, None), + clip("rendition-720p.mp4", None, None), + clip("h264-aac.mp4", Some(1500), None), + ], + ) + .with_encryption(encryption), + ); + let directory = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("target/drm-decode"); + + // A single file: video and audio, each reassembled from its init and segments. + let version = version_in(&fetch(&h.app, "/hls/one/master.m3u8").await.2); + for (track, expected) in [("video", 90u64)] { + let segments = (0..3) + .map(|n| format!("segments/{n}/media.m4s?v={version}")) + .collect(); + let clips = vec![(format!("init.mp4?v={version}"), segments)]; + let frames = decode_each( + &h.app, + &format!("/hls/one/{track}/"), + &clips, + &directory.join("one"), + Some(KEY), + ) + .await; + assert_eq!(frames, [expected]); + } + let audio_path = { + let mut bytes = fetch(&h.app, &format!("/hls/one/audio-1/init.mp4?v={version}")) + .await + .2 + .to_vec(); + for n in 0..3 { + bytes.extend_from_slice( + &fetch( + &h.app, + &format!("/hls/one/audio-1/segments/{n}/media.m4s?v={version}"), + ) + .await + .2, + ); + } + let path = directory.join("one-audio.mp4"); + std::fs::write(&path, bytes).unwrap(); + path + }; + assert_eq!( + decode_errors(&audio_path, Some(KEY)), + "", + "audio decrypts cleanly" + ); + assert_ne!( + decode_errors(&audio_path, Some("ffeeddccbbaa99887766554433221100")), + "", + "a wrong key does not" + ); + + // A sequence: every clip's video, from the served playlist, as in the clips decode test. + let playlist = text(&fetch(&h.app, "/hls/seq/video/index.m3u8").await.2); + let mut clips: Vec<(String, Vec)> = Vec::new(); + for line in playlist.lines() { + if let Some(uri) = line + .strip_prefix("#EXT-X-MAP:URI=\"") + .and_then(|rest| rest.strip_suffix('"')) + { + clips.push((uri.to_owned(), Vec::new())); + } else if !line.is_empty() && !line.starts_with('#') { + clips.last_mut().unwrap().1.push(line.to_owned()); + } + } + let frames = decode_each( + &h.app, + "/hls/seq/video/", + &clips, + &directory.join("seq"), + Some(KEY), + ) + .await; + assert_eq!(frames, [90, 90, 60]); + + // Without the key, the video does not decode cleanly: it really is encrypted. + let video = directory.join("one").join("clip-0.mp4"); + assert_ne!( + decode_errors(&video, None), + "", + "clear decoding of encrypted video fails" + ); +} diff --git a/src/resolver/catalog.rs b/src/resolver/catalog.rs index 566baf3..6cc041f 100644 --- a/src/resolver/catalog.rs +++ b/src/resolver/catalog.rs @@ -35,6 +35,7 @@ impl StaticResolver { version: STATIC_VERSION.to_owned(), valid_until: Instant::now() + FOREVER, hard_expiry: None, + encryption: None, })) } } diff --git a/src/resolver/mapper.rs b/src/resolver/mapper.rs index 07acc00..83fd516 100644 --- a/src/resolver/mapper.rs +++ b/src/resolver/mapper.rs @@ -3,6 +3,7 @@ //! `GET {base_url}/v1/assets/{asset_id}` returns a JSON document naming the media's location and //! version. Everything in the answer is validated before it is trusted. +use std::sync::Arc; use std::time::{Duration, Instant}; use reqwest::header::{ACCEPT, AUTHORIZATION, CACHE_CONTROL, IF_NONE_MATCH, RETRY_AFTER}; @@ -18,8 +19,8 @@ use super::{ SubtitleLocation, }; use crate::clip::{ClipWindow, MAX_CLIP_MS}; -use crate::config::MapperConfig; use crate::config::Secret; +use crate::config::{MapperConfig, is_valid_asset_id}; use crate::observability::request_id; const MAX_VERSION_BYTES: usize = 256; @@ -58,6 +59,9 @@ struct Wire { clips: Vec, #[serde(default)] subtitles: Vec, + /// Content keys and DRM systems (TDD 0009). Validated before the answer is used. + #[serde(default)] + encryption: Option, } #[derive(Debug, Deserialize)] @@ -175,6 +179,38 @@ impl HttpResolver { .is_ok_and(|response| response.status().is_success()) } + /// `GET {base}/v1/assets`: the asset IDs the mapper chooses to list, for status reporting + /// only (see `docs/mapper-api.md#list-assets`). The endpoint is optional, so any failure, + /// including the `404` of a mapper that does not implement it, is `None` rather than an + /// error. IDs a request could not name are dropped; the rest are sorted, deduplicated, and + /// capped at `max`. + pub(crate) async fn list(&self, max: usize) -> Option> { + #[derive(Deserialize)] + struct Listing { + assets: Vec, + } + let response = self + .authorized(self.client.get(format!("{}/v1/assets", self.base_url))) + .header(ACCEPT, "application/json") + .send() + .await + .ok()?; + if !response.status().is_success() { + return None; + } + let body = self.read_body(response).await.ok()?; + let listing: Listing = serde_json::from_slice(&body).ok()?; + let mut ids = listing + .assets + .into_iter() + .filter(|id| is_valid_asset_id(id)) + .collect::>(); + ids.sort(); + ids.dedup(); + ids.truncate(max); + Some(ids) + } + fn authorized(&self, request: reqwest::RequestBuilder) -> reqwest::RequestBuilder { match &self.token { Some(token) => request.header(AUTHORIZATION, format!("Bearer {}", token.expose())), @@ -207,9 +243,7 @@ impl HttpResolver { StatusCode::OK => { let max_age = max_age(&response); let body = self.read_body(response).await?; - let wire: Wire = serde_json::from_slice(&body).map_err(|error| { - ResolveError::Rejected(format!("mapper answer is not valid: {error}")) - })?; + let wire = parse_answer(&body)?; Ok(Resolution::Resolved( self.interpret(asset_id, wire, max_age)?, )) @@ -463,6 +497,16 @@ impl HttpResolver { let refresh_after = remaining.saturating_sub(margin).max(remaining / 2); valid_until = valid_until.min(now + refresh_after); } + let encryption = wire + .encryption + .as_ref() + .map(|encryption| { + encryption + .validate() + .map(Arc::new) + .map_err(ResolveError::Rejected) + }) + .transpose()?; Ok(ResolvedAsset { location, renditions, @@ -471,6 +515,7 @@ impl HttpResolver { version: wire.version, valid_until, hard_expiry, + encryption, }) } } @@ -506,3 +551,35 @@ fn is_rendition_id(id: &str) -> bool { .bytes() .all(|byte| byte.is_ascii_alphanumeric() || byte == b'-') } + +/// Parses a `200` body. Only the location of a failure is reported: serde's type-mismatch +/// messages can echo a value from the body, and the body may hold content keys. +fn parse_answer(body: &[u8]) -> Result { + serde_json::from_slice(body).map_err(|error| { + ResolveError::Rejected(format!( + "mapper answer is not valid JSON of the expected shape (line {}, column {})", + error.line(), + error.column() + )) + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_mistyped_answer_reports_its_position_but_never_a_value() { + let key = "00112233445566778899aabbccddeeff"; + let body = format!( + r#"{{"asset_id":"a","version":"v","encryption":{{"scheme":"cbcs","keys":"{key}"}}}}"# + ); + + let Err(ResolveError::Rejected(message)) = parse_answer(body.as_bytes()) else { + panic!("a string where an array belongs must be rejected"); + }; + + assert!(!message.contains(key), "{message}"); + assert!(message.contains("line"), "{message}"); + } +} diff --git a/src/resolver/mod.rs b/src/resolver/mod.rs index e5e4c39..07df1eb 100644 --- a/src/resolver/mod.rs +++ b/src/resolver/mod.rs @@ -114,6 +114,8 @@ pub(crate) struct ResolvedAsset { /// After this instant the location itself is dead (for example a signed URL) and must never /// be used, even as a stale fallback. pub(crate) hard_expiry: Option, + /// Content keys and DRM systems for the whole asset (TDD 0009); `None` serves it clear. + pub(crate) encryption: Option>, } impl ResolvedAsset { @@ -168,6 +170,10 @@ pub(crate) enum ResolveError { } #[derive(Debug)] +#[allow( + clippy::large_enum_variant, + reason = "one short-lived value per lookup; boxing would only add an allocation" +)] pub(crate) enum Resolution { /// A new or changed answer. Resolved(ResolvedAsset), @@ -218,7 +224,17 @@ impl AssetResolver { } } - /// Asset IDs known without asking a remote service (only the static catalog has them). + /// Asset IDs a control panel can offer: the static catalog's, or whatever a mapper lists at + /// its optional `GET /v1/assets` (`None` when it lists nothing). At most `max` of them. + pub(crate) async fn listed_ids(&self, max: usize) -> Option> { + match self { + Self::Static(resolver) => Some(resolver.ids()), + Self::Http(resolver) => resolver.list(max).await, + } + } + + /// Asset IDs known without asking a remote service (only the static catalog has them); these + /// are what startup preloads, so a mapper's listing is never among them. pub(crate) fn known_ids(&self) -> Vec { match self { Self::Static(resolver) => resolver.ids(), diff --git a/src/sequence.rs b/src/sequence.rs index 2943d11..5d208e6 100644 --- a/src/sequence.rs +++ b/src/sequence.rs @@ -10,7 +10,7 @@ use std::sync::Arc; use bytes::Bytes; -use crate::asset::PackagedAsset; +use crate::asset::{Extras, PackagedAsset}; use crate::clip::{self, ClipWindow, TimelinePosition, Trailing}; use crate::composite::ServedAsset; use crate::config::LimitsConfig; @@ -54,6 +54,7 @@ pub(crate) fn build( mapper_version: &str, segment_duration_ms: u64, limits: &LimitsConfig, + encryption: Option<&Arc>, ) -> Result { let named = |position: usize| { move |error: Error| Error::InvalidMedia(format!("clip {position}: {error}")) @@ -85,6 +86,7 @@ pub(crate) fn build( clips .iter() .map(|&(file, window)| (&files[file].parsed.index.source, window)), + encryption.map(AsRef::as_ref), ); let mut assets = Vec::with_capacity(trimmed.len()); for (position, (file, clip_start, index)) in trimmed.into_iter().enumerate() { @@ -92,10 +94,13 @@ pub(crate) fn build( files[file].source.clone(), index, &files[file].parsed.metadata, - Vec::new(), segment_duration_ms, limits, - Some(version.clone()), + Extras { + subtitles: Vec::new(), + version: Some(version.clone()), + encryption: encryption.map(Arc::clone), + }, ) .map_err(named(position))?; assets.push((asset, clip_start)); @@ -231,6 +236,11 @@ fn render( } impl SequenceAsset { + /// The encryption every clip shares, if any. + pub(crate) fn encryption(&self) -> Option<&crate::cenc::Encryption> { + self.clips[0].encryption() + } + pub(crate) fn version(&self) -> &str { &self.version } @@ -342,7 +352,15 @@ mod tests { }; fn build_from(files: &[ClipFile], clips: &[(usize, ClipWindow)]) -> Result { - build("test", files, clips, "v1", 1000, &LimitsConfig::default()) + build( + "test", + files, + clips, + "v1", + 1000, + &LimitsConfig::default(), + None, + ) } fn sequence(asset: &ServedAsset) -> &SequenceAsset { diff --git a/src/testutil.rs b/src/testutil.rs index f39a94c..df0d8ab 100644 --- a/src/testutil.rs +++ b/src/testutil.rs @@ -58,9 +58,17 @@ pub(crate) struct Answer { pub(crate) ttl_seconds: Option, pub(crate) expires_at: Option, pub(crate) subtitles: Vec, + /// Content keys and DRM systems (TDD 0009). + pub(crate) encryption: Option, } impl Answer { + /// Adds an `encryption` object to the answer. + pub(crate) fn with_encryption(mut self, encryption: Value) -> Self { + self.encryption = Some(encryption); + self + } + /// Adds a `file` subtitle entry. pub(crate) fn with_subtitle(mut self, language: &str, path: &str) -> Self { self.subtitles.push(json!({ @@ -80,6 +88,7 @@ impl Answer { ttl_seconds: Some(300), expires_at: None, subtitles: Vec::new(), + encryption: None, } } @@ -92,6 +101,7 @@ impl Answer { ttl_seconds: Some(300), expires_at: None, subtitles: Vec::new(), + encryption: None, } } @@ -108,6 +118,7 @@ impl Answer { ttl_seconds: Some(300), expires_at: None, subtitles: Vec::new(), + encryption: None, } } @@ -133,6 +144,7 @@ impl Answer { ttl_seconds: Some(300), expires_at: None, subtitles: Vec::new(), + encryption: None, } } } @@ -161,6 +173,9 @@ pub(crate) struct MapperState { pub(crate) delay_ms: AtomicU64, /// When set, a `304` is never sent, so every lookup gets a full answer (a fresh signature). pub(crate) always_full: AtomicBool, + /// The body `GET /v1/assets` answers with; `None` answers `404`, as a mapper that does not + /// implement the optional listing would. + pub(crate) listing: Mutex>, } impl MapperState { @@ -191,6 +206,7 @@ impl MockMapper { let app = Router::new() .route("/v1/assets/{id}", get(mapper_asset)) .route("/v1/health", get(mapper_health)) + .route("/v1/assets", get(mapper_listing)) .with_state(Arc::clone(&state)); Self { address: serve(app).await, @@ -211,6 +227,13 @@ async fn mapper_health(State(state): State>) -> StatusCode { } } +async fn mapper_listing(State(state): State>) -> Response { + match state.listing.lock().unwrap().clone() { + Some(body) => ([(CONTENT_TYPE, "application/json")], body).into_response(), + None => StatusCode::NOT_FOUND.into_response(), + } +} + async fn mapper_asset( State(state): State>, Path(id): Path, @@ -269,6 +292,9 @@ async fn mapper_asset( if !answer.subtitles.is_empty() { body["subtitles"] = json!(answer.subtitles); } + if let Some(encryption) = answer.encryption { + body["encryption"] = encryption; + } if let Some(ttl) = answer.ttl_seconds { body["ttl_seconds"] = json!(ttl); } diff --git a/tests/conformance.rs b/tests/conformance.rs index d3da73b..9325fd9 100644 --- a/tests/conformance.rs +++ b/tests/conformance.rs @@ -778,7 +778,7 @@ fn audit_dash(server: &Server, asset: &str) -> HashMap { if tag.name == "Representation" { representation = Some(tag); assert!(tag.attributes["bandwidth"].parse::().unwrap() > 0); - assert!(!tag.attributes["codecs"].is_empty()); + assert_ne!(tag.attributes["codecs"], ""); } if tag.name == "SegmentTemplate" { let rep = representation.expect("SegmentTemplate must sit inside a Representation"); @@ -903,7 +903,7 @@ fn media_objects_support_conditional_and_range_requests() { let unchanged = get_with(&server, &init_url, &[("If-None-Match", &etag)]); assert_eq!(unchanged.status, 304); - assert!(unchanged.body.is_empty()); + assert_eq!(unchanged.body, [] as [u8; 0]); // A stale If-Range validator must yield the complete representation. let stale = get_with(