From e58599c453782d33d8c6b954d52d2c2e3e3e7de9 Mon Sep 17 00:00:00 2001 From: includeamin Date: Thu, 1 Oct 2026 00:17:44 +0200 Subject: [PATCH 01/19] feat(resolver): offer a mapper's optional asset listing in /admin/status --- docs/mapper-api.md | 17 +++++++++++++++++ docs/operations.md | 4 ++-- examples/mapper/mapper.py | 19 ++++++++++++++++++- src/http/handlers/admin.rs | 3 ++- src/registry/mod.rs | 8 +++++--- src/registry/tests.rs | 39 ++++++++++++++++++++++++++++++++++++++ src/resolver/mapper.rs | 34 ++++++++++++++++++++++++++++++++- src/resolver/mod.rs | 12 +++++++++++- src/testutil.rs | 11 +++++++++++ 9 files changed, 138 insertions(+), 9 deletions(-) diff --git a/docs/mapper-api.md b/docs/mapper-api.md index 3c80206..91ba354 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. @@ -83,6 +84,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: diff --git a/docs/operations.md b/docs/operations.md index 57440a0..87bf364 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -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 diff --git a/examples/mapper/mapper.py b/examples/mapper/mapper.py index 1c0ad25..e50fc38 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) diff --git a/src/http/handlers/admin.rs b/src/http/handlers/admin.rs index 0219e73..e6d1a91 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>, } diff --git a/src/registry/mod.rs b/src/registry/mod.rs index 248bb01..43c2f8b 100644 --- a/src/registry/mod.rs +++ b/src/registry/mod.rs @@ -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 { diff --git a/src/registry/tests.rs b/src/registry/tests.rs index aa67b03..220703b 100644 --- a/src/registry/tests.rs +++ b/src/registry/tests.rs @@ -2228,3 +2228,42 @@ async fn every_clip_of_a_sequence_decodes_from_the_served_hls_and_dash() { let frames = decode_each(&h.app, "/dash/seq/video/", &clips, &directory.join("dash")).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"])); +} diff --git a/src/resolver/mapper.rs b/src/resolver/mapper.rs index 07acc00..94195c5 100644 --- a/src/resolver/mapper.rs +++ b/src/resolver/mapper.rs @@ -18,8 +18,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; @@ -175,6 +175,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())), diff --git a/src/resolver/mod.rs b/src/resolver/mod.rs index e5e4c39..cd035a0 100644 --- a/src/resolver/mod.rs +++ b/src/resolver/mod.rs @@ -218,7 +218,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/testutil.rs b/src/testutil.rs index f39a94c..8069e8d 100644 --- a/src/testutil.rs +++ b/src/testutil.rs @@ -161,6 +161,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 +194,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 +215,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, From 24dc1cfed2dafde8dfbab4a7015bc33c8388ed2a Mon Sep 17 00:00:00 2001 From: includeamin Date: Thu, 1 Oct 2026 00:17:44 +0200 Subject: [PATCH 02/19] =?UTF-8?q?feat(demo,admin):=20pick=20the=20asset=20?= =?UTF-8?q?from=20a=20dropdown,=20with=20Custom=E2=80=A6=20for=20any=20oth?= =?UTF-8?q?er=20name?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- admin/index.html | 62 +++++++++++++++++++++++++++++-------------- demo/index.html | 68 ++++++++++++++++++++++++++++++++---------------- docs/usage.md | 4 +-- 3 files changed, 90 insertions(+), 44 deletions(-) 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/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 From b312fd040168d913ad12775fe9cb99a729503f19 Mon Sep 17 00:00:00 2001 From: includeamin Date: Thu, 1 Oct 2026 00:45:56 +0200 Subject: [PATCH 03/19] docs(design): add TDD 0009, common encryption and DRM --- docs/SUMMARY.md | 1 + .../0009-common-encryption-and-drm.md | 182 ++++++++++++++++++ docs/technical-design/README.md | 1 + 3 files changed, 184 insertions(+) create mode 100644 docs/technical-design/0009-common-encryption-and-drm.md 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/technical-design/0009-common-encryption-and-drm.md b/docs/technical-design/0009-common-encryption-and-drm.md new file mode 100644 index 0000000..c6a7ab3 --- /dev/null +++ b/docs/technical-design/0009-common-encryption-and-drm.md @@ -0,0 +1,182 @@ +# TDD 0009: Common encryption and DRM + +- Status: Draft +- 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": "video", "key_id": "0123456789abcdef0123456789abcdef", "key": "…32 hex…", "iv": "…32 hex, optional…" }, + { "tracks": "audio", "key_id": "…", "key": "…" } + ], + "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 | +| `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. +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. Implementation starts by checking the Clear Key line in hls.js; if hls.js cannot play it, Clear Key is left out of HLS (DASH stays the Clear Key test path) and this section is updated to say so. + +### 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. `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, so encrypted serving can hold up to `limits.max_segment_jobs × limits.max_segment_bytes` (512 MiB with the defaults). 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..f7e7255 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 | Draft | ## Workflow From de62037c51aba544a1bdcb852c9fc87391be2df9 Mon Sep 17 00:00:00 2001 From: includeamin Date: Thu, 1 Oct 2026 01:12:35 +0200 Subject: [PATCH 04/19] feat(cenc): validate the mapper's encryption keys and DRM systems --- Cargo.lock | 1 + Cargo.toml | 1 + src/cenc/keys.rs | 562 +++++++++++++++++++++++++++++++++++++++++++++++ src/cenc/mod.rs | 14 ++ src/lib.rs | 1 + 5 files changed, 579 insertions(+) create mode 100644 src/cenc/keys.rs create mode 100644 src/cenc/mod.rs diff --git a/Cargo.lock b/Cargo.lock index e158896..a94ad63 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1370,6 +1370,7 @@ name = "segmentor" version = "0.1.0" dependencies = [ "axum", + "base64", "bytes", "hyper-util", "mp4", diff --git a/Cargo.toml b/Cargo.toml index 9383be7..6dd6ef2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -17,6 +17,7 @@ exclude = ["/.github", "/docs", "/demo", "/fuzz", "/tests/fixtures", "/Dockerfil [dependencies] 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/src/cenc/keys.rs b/src/cenc/keys.rs new file mode 100644 index 0000000..b78cd2d --- /dev/null +++ b/src/cenc/keys.rs @@ -0,0 +1,562 @@ +//! 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, + } + } + + pub(crate) fn distinct_keys(&self) -> Vec<&ContentKey> { + match &self.keys { + Keys::All(key) => vec![key], + 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(Debug, Deserialize)] +struct WireKey { + tracks: Option, + key_id: String, + key: String, + iv: Option, +} + +#[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()); + } + Ok(Encryption { + keys: self.keys()?, + systems: self.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); + } + + #[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 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..2d80dec --- /dev/null +++ b/src/cenc/mod.rs @@ -0,0 +1,14 @@ +//! 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`. +#![allow( + dead_code, + unused_imports, + reason = "TEMPORARY: wired in by later tasks of the DRM plan" +)] + +mod keys; + +pub(crate) use keys::{ + CLEARKEY, ContentKey, DrmSystem, Encryption, FAIRPLAY, KeyBytes, Keys, PLAYREADY, WIDEVINE, + WireEncryption, hex_string, pssh_data, uuid_string, +}; 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; From 375306fdfa8c9ad14aba6af24d3f93ff4cc3be0c Mon Sep 17 00:00:00 2001 From: includeamin Date: Thu, 1 Oct 2026 01:14:49 +0200 Subject: [PATCH 05/19] fix(cenc): keep the wire key out of Debug output --- src/cenc/keys.rs | 23 ++++++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/src/cenc/keys.rs b/src/cenc/keys.rs index b78cd2d..51a108f 100644 --- a/src/cenc/keys.rs +++ b/src/cenc/keys.rs @@ -133,7 +133,7 @@ pub(crate) struct WireEncryption { systems: Vec, } -#[derive(Debug, Deserialize)] +#[derive(Deserialize)] struct WireKey { tracks: Option, key_id: String, @@ -141,6 +141,19 @@ struct WireKey { 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, @@ -548,6 +561,14 @@ mod tests { ); } + #[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!( From ae0dc88c93ed9aadf34e37fae70aac78962b1fd1 Mon Sep 17 00:00:00 2001 From: includeamin Date: Thu, 1 Oct 2026 01:19:44 +0200 Subject: [PATCH 06/19] feat(cenc): add the cbcs pattern cipher --- Cargo.lock | 37 ++++++++++++++ Cargo.toml | 1 + src/cenc/cipher.rs | 123 +++++++++++++++++++++++++++++++++++++++++++++ src/cenc/mod.rs | 2 + 4 files changed, 163 insertions(+) create mode 100644 src/cenc/cipher.rs diff --git a/Cargo.lock b/Cargo.lock index a94ad63..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,6 +1405,7 @@ dependencies = [ name = "segmentor" version = "0.1.0" dependencies = [ + "aes", "axum", "base64", "bytes", diff --git a/Cargo.toml b/Cargo.toml index 6dd6ef2..942d418 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -16,6 +16,7 @@ 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" 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/mod.rs b/src/cenc/mod.rs index 2d80dec..d216115 100644 --- a/src/cenc/mod.rs +++ b/src/cenc/mod.rs @@ -6,8 +6,10 @@ reason = "TEMPORARY: wired in by later tasks of the DRM plan" )] +mod cipher; mod keys; +pub(crate) use cipher::{Cipher, Pattern}; pub(crate) use keys::{ CLEARKEY, ContentKey, DrmSystem, Encryption, FAIRPLAY, KeyBytes, Keys, PLAYREADY, WIDEVINE, WireEncryption, hex_string, pssh_data, uuid_string, From 318a1f4c1ab28e475c7897dcab9fd3959c5b42e4 Mon Sep 17 00:00:00 2001 From: includeamin Date: Thu, 1 Oct 2026 01:25:30 +0200 Subject: [PATCH 07/19] feat(cenc): find where each H.264 slice header ends --- src/cenc/avc.rs | 522 +++++++++++++++++++++++++++++++++++++++++++++++ src/cenc/bits.rs | 160 +++++++++++++++ src/cenc/mod.rs | 3 + src/fmp4/init.rs | 23 +++ src/fmp4/mod.rs | 5 + src/mp4/codec.rs | 2 +- 6 files changed, 714 insertions(+), 1 deletion(-) create mode 100644 src/cenc/avc.rs create mode 100644 src/cenc/bits.rs diff --git a/src/cenc/avc.rs b/src/cenc/avc.rs new file mode 100644 index 0000000..8e80807 --- /dev/null +++ b/src/cenc/avc.rs @@ -0,0 +1,522 @@ +//! 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, +} + +impl AvcParameters { + /// 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(), + }; + 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); + } + Some(8) => { + let (id, pps) = parse_pps(nal)?; + self.pps.insert(id, pps); + } + _ => {} + } + 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)] +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. + 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/mod.rs b/src/cenc/mod.rs index d216115..b0a619a 100644 --- a/src/cenc/mod.rs +++ b/src/cenc/mod.rs @@ -6,9 +6,12 @@ reason = "TEMPORARY: wired in by later tasks of the DRM plan" )] +mod avc; +mod bits; mod cipher; mod keys; +pub(crate) use avc::AvcParameters; pub(crate) use cipher::{Cipher, Pattern}; pub(crate) use keys::{ CLEARKEY, ContentKey, DrmSystem, Encryption, FAIRPLAY, KeyBytes, Keys, PLAYREADY, WIDEVINE, diff --git a/src/fmp4/init.rs b/src/fmp4/init.rs index e547928..76b326e 100644 --- a/src/fmp4/init.rs +++ b/src/fmp4/init.rs @@ -41,6 +41,29 @@ 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()?; diff --git a/src/fmp4/mod.rs b/src/fmp4/mod.rs index ce283cb..ddf2d8b 100644 --- a/src/fmp4/mod.rs +++ b/src/fmp4/mod.rs @@ -2,4 +2,9 @@ mod fragment; mod init; pub(crate) use fragment::{PreparedSegment, prepare_media_segment, write_media_segment}; +#[allow( + unused_imports, + reason = "TEMPORARY: used by the DRM plan's later tasks" +)] +pub(crate) use init::sample_entry; pub(crate) use init::write_init_segment; 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)?; From 91fa3764ca61896626c4fbc1f0813dc39a70cd9c Mon Sep 17 00:00:00 2001 From: includeamin Date: Thu, 1 Oct 2026 01:30:00 +0200 Subject: [PATCH 08/19] feat(fmp4): write protected init segments and senc/saiz/saio fragment headers --- src/fmp4/fragment.rs | 196 +++++++++++++++++++++++++++++++++++++++-- src/fmp4/init.rs | 205 ++++++++++++++++++++++++++++++++++++++++--- src/fmp4/mod.rs | 10 +++ 3 files changed, 393 insertions(+), 18 deletions(-) diff --git a/src/fmp4/fragment.rs b/src/fmp4/fragment.rs index a6858f6..4334a04 100644 --- a/src/fmp4/fragment.rs +++ b/src/fmp4/fragment.rs @@ -53,15 +53,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 @@ -111,11 +121,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 +135,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 +150,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 +163,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 +196,176 @@ 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. +#[allow(dead_code, reason = "TEMPORARY: used by the DRM plan's later tasks")] +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 76b326e..30e75c7 100644 --- a/src/fmp4/init.rs +++ b/src/fmp4/init.rs @@ -14,7 +14,35 @@ 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). +#[allow(dead_code, reason = "TEMPORARY: used by the DRM plan's later tasks")] +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) +} + +#[allow(dead_code, reason = "TEMPORARY: used by the DRM plan's later tasks")] +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,8 +61,17 @@ pub(crate) fn write_init_segment(metadata: &Metadata, track_id: u32) -> Result) -> 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(); @@ -83,11 +124,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 { @@ -96,18 +145,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)?; @@ -117,13 +178,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. @@ -138,7 +203,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)?; @@ -147,15 +216,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 { @@ -416,4 +538,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 ddf2d8b..1adadae 100644 --- a/src/fmp4/mod.rs +++ b/src/fmp4/mod.rs @@ -1,6 +1,11 @@ mod fragment; mod init; +#[allow( + unused_imports, + reason = "TEMPORARY: used by the DRM plan's later tasks" +)] +pub(crate) use fragment::encrypted_fragment_header; pub(crate) use fragment::{PreparedSegment, prepare_media_segment, write_media_segment}; #[allow( unused_imports, @@ -8,3 +13,8 @@ pub(crate) use fragment::{PreparedSegment, prepare_media_segment, write_media_se )] pub(crate) use init::sample_entry; pub(crate) use init::write_init_segment; +#[allow( + unused_imports, + reason = "TEMPORARY: used by the DRM plan's later tasks" +)] +pub(crate) use init::{InitProtection, write_protected_init_segment}; From 94db202546d2a7ed14404848555f9c0eaf7cd245 Mon Sep 17 00:00:00 2001 From: includeamin Date: Thu, 1 Oct 2026 01:34:16 +0200 Subject: [PATCH 09/19] feat(cenc): encrypt an H.264 or audio segment into a cbcs fragment --- src/cenc/avc.rs | 55 +++++++- src/cenc/mod.rs | 2 + src/cenc/segment.rs | 325 +++++++++++++++++++++++++++++++++++++++++++ src/fmp4/fragment.rs | 6 +- src/fmp4/init.rs | 2 - src/fmp4/mod.rs | 19 +-- 6 files changed, 391 insertions(+), 18 deletions(-) create mode 100644 src/cenc/segment.rs diff --git a/src/cenc/avc.rs b/src/cenc/avc.rs index 8e80807..395778f 100644 --- a/src/cenc/avc.rs +++ b/src/cenc/avc.rs @@ -48,6 +48,10 @@ 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 { @@ -62,6 +66,10 @@ impl AvcParameters { 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 { @@ -86,10 +94,14 @@ impl AvcParameters { 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()); } _ => {} } @@ -409,7 +421,46 @@ fn small(value: u32, max: u32) -> Result { } #[cfg(test)] -mod tests { +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::*; @@ -419,7 +470,7 @@ mod tests { use crate::testutil::fixture; /// The fixture's parameters and every video sample's bytes. - fn samples(name: &str) -> (AvcParameters, Vec>) { + pub(crate) fn samples(name: &str) -> (AvcParameters, Vec>) { let runtime = tokio::runtime::Builder::new_current_thread() .enable_all() .build() diff --git a/src/cenc/mod.rs b/src/cenc/mod.rs index b0a619a..0f650b3 100644 --- a/src/cenc/mod.rs +++ b/src/cenc/mod.rs @@ -10,6 +10,7 @@ mod avc; mod bits; mod cipher; mod keys; +mod segment; pub(crate) use avc::AvcParameters; pub(crate) use cipher::{Cipher, Pattern}; @@ -17,3 +18,4 @@ pub(crate) use keys::{ CLEARKEY, ContentKey, DrmSystem, Encryption, FAIRPLAY, KeyBytes, Keys, PLAYREADY, WIDEVINE, WireEncryption, hex_string, pssh_data, uuid_string, }; +pub(crate) use segment::{AssetProtection, PendingEncryption, TrackProtection}; diff --git a/src/cenc/segment.rs b/src/cenc/segment.rs new file mode 100644 index 0000000..42ff870 --- /dev/null +++ b/src/cenc/segment.rs @@ -0,0 +1,325 @@ +//! 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 { + /// 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 mut fragment = fmp4::encrypted_fragment_header( + self.track_id, + self.kind, + &self.samples, + self.decode_time, + self.sequence_number, + &maps, + payload.len(), + )?; + fragment.extend_from_slice(&payload); + Ok(Bytes::from(fragment)) + } +} + +/// 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/fmp4/fragment.rs b/src/fmp4/fragment.rs index 4334a04..0d384b0 100644 --- a/src/fmp4/fragment.rs +++ b/src/fmp4/fragment.rs @@ -16,6 +16,10 @@ 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. + #[allow(dead_code, reason = "TEMPORARY: used by the DRM plan's later tasks")] + pub(crate) encryption: Option>, } pub(crate) async fn write_media_segment( @@ -99,6 +103,7 @@ pub(crate) fn prepare_media_segment( header: Bytes::from(header), ranges: coalesced_ranges(samples)?, content_length, + encryption: None, }) } @@ -203,7 +208,6 @@ 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. -#[allow(dead_code, reason = "TEMPORARY: used by the DRM plan's later tasks")] pub(crate) fn encrypted_fragment_header( track_id: u32, kind: TrackKind, diff --git a/src/fmp4/init.rs b/src/fmp4/init.rs index 30e75c7..40fa2a3 100644 --- a/src/fmp4/init.rs +++ b/src/fmp4/init.rs @@ -15,7 +15,6 @@ use crate::source::Metadata; 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). -#[allow(dead_code, reason = "TEMPORARY: used by the DRM plan's later tasks")] pub(crate) struct InitProtection<'a> { pub(crate) key_id: [u8; 16], pub(crate) constant_iv: [u8; 16], @@ -79,7 +78,6 @@ fn write_init( } /// The first sample entry of `track_id`: its type and payload, as the file has them. -#[allow(dead_code, reason = "TEMPORARY: used by the DRM plan's later tasks")] pub(crate) fn sample_entry(metadata: &Metadata, track_id: u32) -> Result<([u8; 4], Vec)> { let moov = box_payload(metadata.moov_bytes(), 0)?; let track = child_boxes(moov.payload)? diff --git a/src/fmp4/mod.rs b/src/fmp4/mod.rs index 1adadae..facd8ed 100644 --- a/src/fmp4/mod.rs +++ b/src/fmp4/mod.rs @@ -1,20 +1,13 @@ mod fragment; mod init; +pub(crate) use fragment::{ + MAX_SUBSAMPLES, PreparedSegment, encrypted_fragment_header, prepare_media_segment, + write_media_segment, +}; #[allow( unused_imports, reason = "TEMPORARY: used by the DRM plan's later tasks" )] -pub(crate) use fragment::encrypted_fragment_header; -pub(crate) use fragment::{PreparedSegment, prepare_media_segment, write_media_segment}; -#[allow( - unused_imports, - reason = "TEMPORARY: used by the DRM plan's later tasks" -)] -pub(crate) use init::sample_entry; -pub(crate) use init::write_init_segment; -#[allow( - unused_imports, - reason = "TEMPORARY: used by the DRM plan's later tasks" -)] -pub(crate) use init::{InitProtection, write_protected_init_segment}; +pub(crate) use init::write_protected_init_segment; +pub(crate) use init::{InitProtection, sample_entry, write_init_segment}; From d89e3a49bbe8c7df74b9682f03294b587db15c4f Mon Sep 17 00:00:00 2001 From: includeamin Date: Thu, 1 Oct 2026 01:37:45 +0200 Subject: [PATCH 10/19] feat(protocol): signal cbcs and DRM systems in HLS and DASH --- src/cenc/mod.rs | 19 ++++ src/cenc/signal.rs | 175 +++++++++++++++++++++++++++++++++++ src/composite.rs | 5 +- src/protocol/dash.rs | 58 ++++++++++-- src/protocol/hls.rs | 63 ++++++++++++- src/protocol/presentation.rs | 14 +++ 6 files changed, 324 insertions(+), 10 deletions(-) create mode 100644 src/cenc/signal.rs diff --git a/src/cenc/mod.rs b/src/cenc/mod.rs index 0f650b3..f515f54 100644 --- a/src/cenc/mod.rs +++ b/src/cenc/mod.rs @@ -11,6 +11,7 @@ mod bits; mod cipher; mod keys; mod segment; +mod signal; pub(crate) use avc::AvcParameters; pub(crate) use cipher::{Cipher, Pattern}; @@ -19,3 +20,21 @@ pub(crate) use keys::{ WireEncryption, hex_string, pssh_data, uuid_string, }; pub(crate) use segment::{AssetProtection, PendingEncryption, TrackProtection}; +pub(crate) use signal::{dash_content_protection, hls_key_lines, hls_session_keys}; + +#[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/signal.rs b/src/cenc/signal.rs new file mode 100644 index 0000000..f307ed1 --- /dev/null +++ b/src/cenc/signal.rs @@ -0,0 +1,175 @@ +//! 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, 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 { + let attributes = 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| { + format!( + r#"URI="data:text/plain;base64,{}",KEYID=0x{},KEYFORMAT="urn:uuid:edef8ba9-79d6-4ace-a3c8-27dcd51d21ed",KEYFORMATVERSIONS="1""#, + base64(pssh), + hex_string(&key.key_id) + ) + }), + 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, + }; + if let Some(attributes) = attributes { + writeln!(lines, "#{tag}:METHOD=SAMPLE-AES,{attributes}") + .expect("writing to a String cannot fail"); + } + } + lines +} + +/// `EXT-X-SESSION-KEY` lines for every distinct key, so players can request licences early. +pub(crate) fn hls_session_keys(encryption: &Encryption) -> String { + encryption + .distinct_keys() + .into_iter() + .map(|key| hls_key_lines(encryption, key, "EXT-X-SESSION-KEY")) + .collect() +} + +/// 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::{CLEARKEY, FAIRPLAY, PLAYREADY, WIDEVINE, WireEncryption}; + + 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); + } + + #[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/composite.rs b/src/composite.rs index aaf5206..3f0b526 100644 --- a/src/composite.rs +++ b/src/composite.rs @@ -710,7 +710,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(None) } /// Which underlying asset supplies one member of the shared audio group. @@ -840,6 +840,7 @@ fn render( &adaptive_audio, subtitles, iframe_stream_line.as_deref(), + None, )?); let hls_video = video @@ -873,7 +874,7 @@ 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, false)); Ok(CompositeManifests { hls_master, 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 } From c4e6f18edb05d8af64437efb83b227e3df8c0620 Mon Sep 17 00:00:00 2001 From: includeamin Date: Thu, 1 Oct 2026 01:44:59 +0200 Subject: [PATCH 11/19] feat(cenc): encrypt assets the mapper supplies keys for --- src/asset.rs | 119 +++++++++++++++++---- src/clip.rs | 20 ++-- src/composite.rs | 24 ++++- src/fmp4/fragment.rs | 1 - src/fmp4/init.rs | 1 - src/fmp4/mod.rs | 9 +- src/http/handlers/admin.rs | 7 ++ src/http/handlers/media.rs | 78 +++++++++++++- src/observability/metrics.rs | 23 +++- src/registry/cache.rs | 3 + src/registry/mod.rs | 22 +++- src/registry/tests.rs | 201 +++++++++++++++++++++++++++++++++++ src/resolver/catalog.rs | 1 + src/resolver/mapper.rs | 23 +++- src/resolver/mod.rs | 6 ++ src/sequence.rs | 26 ++++- src/testutil.rs | 15 +++ 17 files changed, 525 insertions(+), 54 deletions(-) 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/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 3f0b526..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).with_encryption(None) + 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,7 +854,7 @@ fn render( &adaptive_audio, subtitles, iframe_stream_line.as_deref(), - None, + encryption, )?); let hls_video = video @@ -874,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, false)); + let dash = Bytes::from(dash::wrap_manifest( + &duration, + &dash_body, + encryption.is_some(), + )); Ok(CompositeManifests { hls_master, diff --git a/src/fmp4/fragment.rs b/src/fmp4/fragment.rs index 0d384b0..8165f1d 100644 --- a/src/fmp4/fragment.rs +++ b/src/fmp4/fragment.rs @@ -18,7 +18,6 @@ pub(crate) struct PreparedSegment { 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. - #[allow(dead_code, reason = "TEMPORARY: used by the DRM plan's later tasks")] pub(crate) encryption: Option>, } diff --git a/src/fmp4/init.rs b/src/fmp4/init.rs index 40fa2a3..c95c198 100644 --- a/src/fmp4/init.rs +++ b/src/fmp4/init.rs @@ -28,7 +28,6 @@ pub(crate) fn write_init_segment(metadata: &Metadata, track_id: u32) -> Result, } impl From for StatusResponse { @@ -76,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(), }, @@ -122,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..d146134 100644 --- a/src/http/handlers/media.rs +++ b/src/http/handlers/media.rs @@ -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,62 @@ 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"). +async fn encrypt_segment( + state: &AppState, + source: &PackagedAsset, + ranges: Vec, + pending: crate::cenc::PendingEncryption, + what: &str, +) -> HttpResult { + let started = Instant::now(); + let permit = state.segment_permit().await?; + let mut payload = Vec::new(); + 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()); + } + } + } + drop(permit); + let finished = tokio::task::spawn_blocking(move || pending.finish(payload)) + .await + .map_err(|error| HttpError::internal(error.to_string()))?; + match finished { + Ok(bytes) => { + state.metrics.encrypted_segment(started.elapsed()); + Ok(PreparedSegment { + content_length: bytes.len() as u64, + header: bytes, + ranges: Vec::new(), + encryption: None, + }) + } + 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 +233,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 +245,16 @@ 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()))?? }; + let prepared = match prepared.encryption.take() { + None => prepared, + Some(pending) => encrypt_segment(state, &source, prepared.ranges, *pending, &what).await?, + }; let total_length = prepared.content_length; let requested_interval = match requested_range(headers, total_length, &etag) { Ok(range) => range.unwrap_or(ByteInterval { 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/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 43c2f8b..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}; @@ -359,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(), ); @@ -556,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))| { @@ -712,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(), ); @@ -751,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, ) @@ -783,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 { @@ -798,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, ) @@ -910,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, @@ -918,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 220703b..1f4878d 100644 --- a/src/registry/tests.rs +++ b/src/registry/tests.rs @@ -2267,3 +2267,204 @@ async fn a_listing_is_capped_at_max_assets() { 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" + ); +} + +#[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_never_logs_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}"}}}}"# + )); + + 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)); +} 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 94195c5..5e2bb8d 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}; @@ -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)] @@ -239,8 +243,14 @@ impl HttpResolver { StatusCode::OK => { let max_age = max_age(&response); let body = self.read_body(response).await?; + // Only the location is reported: serde's type-mismatch messages can echo a value + // from the body, and the body may hold content keys. let wire: Wire = serde_json::from_slice(&body).map_err(|error| { - ResolveError::Rejected(format!("mapper answer is not valid: {error}")) + ResolveError::Rejected(format!( + "mapper answer is not valid JSON of the expected shape (line {}, column {})", + error.line(), + error.column() + )) })?; Ok(Resolution::Resolved( self.interpret(asset_id, wire, max_age)?, @@ -495,6 +505,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, @@ -503,6 +523,7 @@ impl HttpResolver { version: wire.version, valid_until, hard_expiry, + encryption, }) } } diff --git a/src/resolver/mod.rs b/src/resolver/mod.rs index cd035a0..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), 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 8069e8d..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, } } } @@ -280,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); } From 00a284c53eb55f4f4cdd2283fe2e43ee4b2fc891 Mon Sep 17 00:00:00 2001 From: includeamin Date: Thu, 1 Oct 2026 01:47:56 +0200 Subject: [PATCH 12/19] test(resolver): check the parse error itself never echoes a key --- src/registry/tests.rs | 2 +- src/resolver/mapper.rs | 42 +++++++++++++++++++++++++++++++++--------- 2 files changed, 34 insertions(+), 10 deletions(-) diff --git a/src/registry/tests.rs b/src/registry/tests.rs index 1f4878d..9cfa43f 100644 --- a/src/registry/tests.rs +++ b/src/registry/tests.rs @@ -2437,7 +2437,7 @@ async fn a_rejected_encryption_answer_never_echoes_the_key() { } #[tokio::test] -async fn a_mistyped_encryption_answer_never_logs_the_key() { +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}"}}}}"# diff --git a/src/resolver/mapper.rs b/src/resolver/mapper.rs index 5e2bb8d..83fd516 100644 --- a/src/resolver/mapper.rs +++ b/src/resolver/mapper.rs @@ -243,15 +243,7 @@ impl HttpResolver { StatusCode::OK => { let max_age = max_age(&response); let body = self.read_body(response).await?; - // Only the location is reported: serde's type-mismatch messages can echo a value - // from the body, and the body may hold content keys. - let wire: Wire = 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() - )) - })?; + let wire = parse_answer(&body)?; Ok(Resolution::Resolved( self.interpret(asset_id, wire, max_age)?, )) @@ -559,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}"); + } +} From 9dbb4734dece856546cda202de63604c71066c6c Mon Sep 17 00:00:00 2001 From: includeamin Date: Thu, 1 Oct 2026 01:52:39 +0200 Subject: [PATCH 13/19] test(cenc): FFmpeg decrypts what we serve; fuzz the H.264 slice-header parser --- fuzz/Cargo.lock | 38 ++++++ fuzz/Cargo.toml | 7 ++ fuzz/fuzz_targets/avc_slice_header.rs | 7 ++ src/cenc/avc.rs | 13 ++ src/cenc/mod.rs | 29 +++-- src/cenc/signal.rs | 3 +- src/fuzzing.rs | 8 ++ src/registry/tests.rs | 166 ++++++++++++++++++++++++-- 8 files changed, 248 insertions(+), 23 deletions(-) create mode 100644 fuzz/fuzz_targets/avc_slice_header.rs 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/cenc/avc.rs b/src/cenc/avc.rs index 395778f..76bdbb8 100644 --- a/src/cenc/avc.rs +++ b/src/cenc/avc.rs @@ -55,6 +55,19 @@ pub(crate) struct AvcParameters { } 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)?; diff --git a/src/cenc/mod.rs b/src/cenc/mod.rs index f515f54..8d23baf 100644 --- a/src/cenc/mod.rs +++ b/src/cenc/mod.rs @@ -1,10 +1,5 @@ //! 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`. -#![allow( - dead_code, - unused_imports, - reason = "TEMPORARY: wired in by later tasks of the DRM plan" -)] mod avc; mod bits; @@ -13,15 +8,25 @@ mod keys; mod segment; mod signal; -pub(crate) use avc::AvcParameters; -pub(crate) use cipher::{Cipher, Pattern}; -pub(crate) use keys::{ - CLEARKEY, ContentKey, DrmSystem, Encryption, FAIRPLAY, KeyBytes, Keys, PLAYREADY, WIDEVINE, - WireEncryption, hex_string, pssh_data, uuid_string, -}; -pub(crate) use segment::{AssetProtection, PendingEncryption, TrackProtection}; +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}; diff --git a/src/cenc/signal.rs b/src/cenc/signal.rs index f307ed1..dfa791b 100644 --- a/src/cenc/signal.rs +++ b/src/cenc/signal.rs @@ -89,7 +89,8 @@ pub(crate) fn dash_content_protection(encryption: &Encryption, key: &ContentKey) #[cfg(test)] mod tests { use super::*; - use crate::cenc::{CLEARKEY, FAIRPLAY, PLAYREADY, WIDEVINE, WireEncryption}; + use crate::cenc::WireEncryption; + use crate::cenc::keys::{CLEARKEY, FAIRPLAY, PLAYREADY, WIDEVINE}; fn pssh(system: &[u8; 16], data: &[u8]) -> String { use base64::Engine; 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/registry/tests.rs b/src/registry/tests.rs index 9cfa43f..fbb5193 100644 --- a/src/registry/tests.rs +++ b/src/registry/tests.rs @@ -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,7 +2237,14 @@ 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]); } @@ -2468,3 +2487,130 @@ async fn admin_status_reports_key_ids_never_keys() { ); 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" + ); +} From b6a0cdaefc25d03ed37f4b7fa5c741fc5fc94794 Mon Sep 17 00:00:00 2001 From: includeamin Date: Thu, 1 Oct 2026 02:05:40 +0200 Subject: [PATCH 14/19] docs(cenc): document encryption for mapper authors and operators --- README.md | 7 ++- docs/mapper-api.md | 60 +++++++++++++++++++ docs/operations.md | 16 +++++ .../0009-common-encryption-and-drm.md | 4 +- docs/technical-design/README.md | 2 +- examples/mapper/README.md | 4 ++ examples/mapper/catalog.json | 9 +++ examples/mapper/mapper.py | 5 +- 8 files changed, 99 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index d84d1f1..20ff297 100644 --- a/README.md +++ b/README.md @@ -39,8 +39,9 @@ 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)). - **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 +52,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/docs/mapper-api.md b/docs/mapper-api.md index 91ba354..25f6fb9 100644 --- a/docs/mapper-api.md +++ b/docs/mapper-api.md @@ -62,6 +62,7 @@ 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` @@ -224,6 +225,64 @@ 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": "video", "key_id": "0123456789abcdef0123456789abcdef", "key": "00112233445566778899aabbccddeeff" }, + { "tracks": "audio", "key_id": "fedcba9876543210fedcba9876543210", "key": "ffeeddccbbaa99887766554433221100" } + ], + "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. Every track of the asset must end up with exactly one key | +| `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` | +| 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. + +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`, and a FairPlay entry without `hls_uri`. 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. Re-keying an asset under an unchanged mapper `version` therefore reloads it and hands out new URLs, so no player mixes old and new segments. + +**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. @@ -299,3 +358,4 @@ Then `curl http://127.0.0.1:3000/hls/movie/master.m3u8`. - `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 87bf364..a731924 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -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 sent, so it is not streamed. Memory for in-flight encrypted segments is bounded by `limits.max_segment_jobs` times `limits.max_segment_bytes`, 512 MiB by default; count it in the container's memory limit next to the index budget above. + +| 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 index c6a7ab3..fe11918 100644 --- a/docs/technical-design/0009-common-encryption-and-drm.md +++ b/docs/technical-design/0009-common-encryption-and-drm.md @@ -1,6 +1,6 @@ # TDD 0009: Common encryption and DRM -- Status: Draft +- 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) @@ -130,7 +130,7 @@ One `#EXT-X-KEY` per system goes in every media playlist, before the first `#EXT | 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. Implementation starts by checking the Clear Key line in hls.js; if hls.js cannot play it, Clear Key is left out of HLS (DASH stays the Clear Key test path) and this section is updated to say so. +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 diff --git a/docs/technical-design/README.md b/docs/technical-design/README.md index f7e7255..c98027b 100644 --- a/docs/technical-design/README.md +++ b/docs/technical-design/README.md @@ -14,7 +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 | Draft | +| [0009](0009-common-encryption-and-drm.md) | Common encryption and DRM | Accepted; H.264 and audio implemented, HEVC pending | ## Workflow 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 e50fc38..72136cf 100644 --- a/examples/mapper/mapper.py +++ b/examples/mapper/mapper.py @@ -80,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() From a6e158879a97a9ab703cc84cd7f175feacfe5b12 Mon Sep 17 00:00:00 2001 From: includeamin Date: Thu, 1 Oct 2026 02:17:47 +0200 Subject: [PATCH 15/19] fix(cenc): hold an encrypted segment's job slot until its response ends The encrypted bytes now stay under the segment-job slot taken to read them, handed to the stream instead of a second slot, so resident encrypted segments are bounded by max_segment_jobs. The header region is streamed in stream_chunk_bytes pieces, so a client that stops reading an encrypted segment hits the idle timeout. The payload buffer is allocated once and the header is spliced in front of it instead of copying the payload. A failed encryption task counts as an encryption failure. Docs state the real bound: max_segment_jobs x max_segment_bytes. --- docs/internals/http-server.md | 8 +-- docs/operations.md | 4 +- .../0009-common-encryption-and-drm.md | 4 +- src/cenc/segment.rs | 17 ++++- src/http/handlers/media.rs | 53 ++++++++++---- src/http/stream.rs | 21 ++++-- src/registry/tests.rs | 70 +++++++++++++++++++ 7 files changed, 147 insertions(+), 30 deletions(-) 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/operations.md b/docs/operations.md index a731924..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). @@ -92,7 +92,7 @@ Each loaded asset keeps its full sample index in memory, about 40 bytes per samp ## 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 sent, so it is not streamed. Memory for in-flight encrypted segments is bounded by `limits.max_segment_jobs` times `limits.max_segment_bytes`, 512 MiB by default; count it in the container's memory limit next to the index budget above. +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 | | --- | --- | --- | diff --git a/docs/technical-design/0009-common-encryption-and-drm.md b/docs/technical-design/0009-common-encryption-and-drm.md index fe11918..efabbd7 100644 --- a/docs/technical-design/0009-common-encryption-and-drm.md +++ b/docs/technical-design/0009-common-encryption-and-drm.md @@ -99,7 +99,7 @@ The cipher is AES-128 from the RustCrypto `aes` crate (pure Rust, constant time, `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. +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. @@ -140,7 +140,7 @@ The URL version of an encrypted asset additionally hashes each key ID, a SHA-256 - 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, so encrypted serving can hold up to `limits.max_segment_jobs × limits.max_segment_bytes` (512 MiB with the defaults). The operations guide says so and suggests lowering either limit on small hosts. +- 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. diff --git a/src/cenc/segment.rs b/src/cenc/segment.rs index 42ff870..72cbbcc 100644 --- a/src/cenc/segment.rs +++ b/src/cenc/segment.rs @@ -119,6 +119,14 @@ pub(crate) struct PendingEncryption { } 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()); @@ -157,7 +165,7 @@ impl PendingEncryption { maps.push(map); offset += size; } - let mut fragment = fmp4::encrypted_fragment_header( + let header = fmp4::encrypted_fragment_header( self.track_id, self.kind, &self.samples, @@ -166,8 +174,11 @@ impl PendingEncryption { &maps, payload.len(), )?; - fragment.extend_from_slice(&payload); - Ok(Bytes::from(fragment)) + // 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)) } } diff --git a/src/http/handlers/media.rs b/src/http/handlers/media.rs index d146134..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; @@ -179,16 +179,27 @@ pub(crate) async fn iframe_segment( /// 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 { +) -> HttpResult<(PreparedSegment, OwnedSemaphorePermit)> { let started = Instant::now(); let permit = state.segment_permit().await?; - let mut payload = Vec::new(); + 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), @@ -198,19 +209,23 @@ async fn encrypt_segment( } } } - drop(permit); - let finished = tokio::task::spawn_blocking(move || pending.finish(payload)) - .await - .map_err(|error| HttpError::internal(error.to_string()))?; + 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()); - Ok(PreparedSegment { + 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(); @@ -251,9 +266,14 @@ async fn serve_segment( .await .map_err(|error| HttpError::internal(error.to_string()))?? }; - let prepared = match prepared.encryption.take() { - None => prepared, - Some(pending) => encrypt_segment(state, &source, prepared.ranges, *pending, &what).await?, + // 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) { @@ -265,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, @@ -276,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/registry/tests.rs b/src/registry/tests.rs index fbb5193..97ca390 100644 --- a/src/registry/tests.rs +++ b/src/registry/tests.rs @@ -2380,6 +2380,76 @@ async fn range_and_head_requests_work_on_an_encrypted_segment() { ); } +/// 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; From b6e94844b3689d706860132fd422d6dab2f9c0f3 Mon Sep 17 00:00:00 2001 From: includeamin Date: Thu, 1 Oct 2026 02:19:50 +0200 Subject: [PATCH 16/19] fix(cenc): give each EXT-X-SESSION-KEY line once for split keys RFC 8216 forbids two session keys with the same METHOD, URI, IV, KEYFORMAT and KEYFORMATVERSIONS, and KEYID does not tell them apart. Split keys repeated every system's line; each now appears once, for the first key. Identical split keys also count as one distinct key. --- src/cenc/keys.rs | 2 + src/cenc/signal.rs | 149 +++++++++++++++++++++++++++++++++++---------- 2 files changed, 119 insertions(+), 32 deletions(-) diff --git a/src/cenc/keys.rs b/src/cenc/keys.rs index 51a108f..a546061 100644 --- a/src/cenc/keys.rs +++ b/src/cenc/keys.rs @@ -85,9 +85,11 @@ impl Encryption { } } + /// 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], } } diff --git a/src/cenc/signal.rs b/src/cenc/signal.rs index dfa791b..2d8f595 100644 --- a/src/cenc/signal.rs +++ b/src/cenc/signal.rs @@ -5,8 +5,8 @@ use std::fmt::Write; use base64::Engine; use super::keys::{ - CLEARKEY, ContentKey, Encryption, FAIRPLAY, PLAYREADY, WIDEVINE, hex_string, pssh_data, - uuid_string, + CLEARKEY, ContentKey, DrmSystem, Encryption, FAIRPLAY, PLAYREADY, WIDEVINE, hex_string, + pssh_data, uuid_string, }; fn base64(bytes: &[u8]) -> String { @@ -17,43 +17,70 @@ fn base64(bytes: &[u8]) -> String { pub(crate) fn hls_key_lines(encryption: &Encryption, key: &ContentKey, tag: &str) -> String { let mut lines = String::new(); for system in &encryption.systems { - let attributes = 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| { - format!( - r#"URI="data:text/plain;base64,{}",KEYID=0x{},KEYFORMAT="urn:uuid:edef8ba9-79d6-4ace-a3c8-27dcd51d21ed",KEYFORMATVERSIONS="1""#, - base64(pssh), - hex_string(&key.key_id) - ) - }), - 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, - }; - if let Some(attributes) = attributes { - writeln!(lines, "#{tag}:METHOD=SAMPLE-AES,{attributes}") - .expect("writing to a String cannot fail"); + 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 { - encryption - .distinct_keys() - .into_iter() - .map(|key| hls_key_lines(encryption, key, "EXT-X-SESSION-KEY")) - .collect() + 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. @@ -147,6 +174,64 @@ mod tests { 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(); From 7af54be8c2d53ab1ee1257c51f70be5af1b2eb11 Mon Sep 17 00:00:00 2001 From: includeamin Date: Thu, 1 Oct 2026 02:21:27 +0200 Subject: [PATCH 17/19] fix(cenc): reject FairPlay with separate video and audio keys FairPlay's HLS key line carries no key ID, so a player could not ask for the audio key apart from the video one. validate() refuses the combination naming the field, and the mapper API and TDD 0009 examples now use one key for all tracks. --- docs/mapper-api.md | 9 +++--- .../0009-common-encryption-and-drm.md | 5 ++-- src/cenc/keys.rs | 30 ++++++++++++++++--- 3 files changed, 32 insertions(+), 12 deletions(-) diff --git a/docs/mapper-api.md b/docs/mapper-api.md index 25f6fb9..60aaf39 100644 --- a/docs/mapper-api.md +++ b/docs/mapper-api.md @@ -237,8 +237,7 @@ An answer may carry an `encryption` object. segmentor then encrypts what it serv "encryption": { "scheme": "cbcs", "keys": [ - { "tracks": "video", "key_id": "0123456789abcdef0123456789abcdef", "key": "00112233445566778899aabbccddeeff" }, - { "tracks": "audio", "key_id": "fedcba9876543210fedcba9876543210", "key": "ffeeddccbbaa99887766554433221100" } + { "tracks": "all", "key_id": "0123456789abcdef0123456789abcdef", "key": "00112233445566778899aabbccddeeff" } ], "systems": [ { "system_id": "edef8ba9-79d6-4ace-a3c8-27dcd51d21ed", "pssh": "", "license_url": "https://license.example.net/widevine" }, @@ -252,7 +251,7 @@ An answer may carry an `encryption` object. segmentor then encrypts what it serv | 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 | +| `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) | @@ -265,7 +264,7 @@ An answer may carry an `encryption` object. segmentor then encrypts what it serv | 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` | +| 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 | @@ -277,7 +276,7 @@ Any other system ID is accepted and signalled in DASH only (its `pssh` and `lice **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`, and a FairPlay entry without `hls_uri`. Error messages and logs never contain key material. +**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. Re-keying an asset under an unchanged mapper `version` therefore reloads it and hands out new URLs, so no player mixes old and new segments. diff --git a/docs/technical-design/0009-common-encryption-and-drm.md b/docs/technical-design/0009-common-encryption-and-drm.md index efabbd7..47a20f9 100644 --- a/docs/technical-design/0009-common-encryption-and-drm.md +++ b/docs/technical-design/0009-common-encryption-and-drm.md @@ -50,8 +50,7 @@ The mapper answer gains an optional `encryption` object. It applies to the whole "encryption": { "scheme": "cbcs", "keys": [ - { "tracks": "video", "key_id": "0123456789abcdef0123456789abcdef", "key": "…32 hex…", "iv": "…32 hex, optional…" }, - { "tracks": "audio", "key_id": "…", "key": "…" } + { "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" }, @@ -65,7 +64,7 @@ The mapper answer gains an optional `encryption` object. It applies to the whole | 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 | +| `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) | diff --git a/src/cenc/keys.rs b/src/cenc/keys.rs index a546061..8e27279 100644 --- a/src/cenc/keys.rs +++ b/src/cenc/keys.rs @@ -171,10 +171,17 @@ impl WireEncryption { if self.scheme != "cbcs" { return Err("encryption scheme is not supported; only cbcs is".to_owned()); } - Ok(Encryption { - keys: self.keys()?, - systems: self.systems()?, - }) + 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 { @@ -431,6 +438,21 @@ mod tests { 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 = [ From de0aaa5c333d8a45c8a39e1c961ebf13e6cd58f7 Mon Sep 17 00:00:00 2001 From: includeamin Date: Thu, 1 Oct 2026 02:22:47 +0200 Subject: [PATCH 18/19] docs(cenc): re-key with a 200, and note pssh-only and size limits A 304 means nothing in the answer changed, encryption included, so a re-keyed asset must be answered with 200 and a new version. Also say that Widevine and PlayReady without a pssh are DASH-only, that large pssh boxes need a larger resolver.http.max_response_bytes, and that video encryption is H.264 for now. --- README.md | 3 ++- docs/mapper-api.md | 11 +++++++++-- .../0009-common-encryption-and-drm.md | 2 +- 3 files changed, 12 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 20ff297..6410f93 100644 --- a/README.md +++ b/README.md @@ -41,7 +41,8 @@ proportional to the segment asked for, not to the length of the video. joining files is done on keyframes, not by re-encoding ([clips](docs/mapper-api.md#clips)). - **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)). + ([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. diff --git a/docs/mapper-api.md b/docs/mapper-api.md index 60aaf39..0ff96ad 100644 --- a/docs/mapper-api.md +++ b/docs/mapper-api.md @@ -68,6 +68,8 @@ A file on an HTTP origin: 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 | @@ -270,6 +272,8 @@ An answer may carry an `encryption` object. segmentor then encrypts what it serv **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. @@ -278,7 +282,9 @@ Any other system ID is accepted and signalled in DASH only (its `pssh` and `lice **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. Re-keying an asset under an unchanged mapper `version` therefore reloads it and hands out new URLs, so no player mixes old and new segments. +**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. @@ -350,7 +356,8 @@ 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. diff --git a/docs/technical-design/0009-common-encryption-and-drm.md b/docs/technical-design/0009-common-encryption-and-drm.md index 47a20f9..4b0a93c 100644 --- a/docs/technical-design/0009-common-encryption-and-drm.md +++ b/docs/technical-design/0009-common-encryption-and-drm.md @@ -133,7 +133,7 @@ Other system IDs have no standard HLS form and are signalled in DASH only; the m ### 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. `FORMAT_REVISION` does not change, because nothing served for an unencrypted asset changes. +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 From 015d3427ea675c8112aaa0c15fff8f9d946b13af Mon Sep 17 00:00:00 2001 From: includeamin Date: Thu, 1 Oct 2026 19:20:41 +0200 Subject: [PATCH 19/19] fix(lint): fix linting errors --- src/config/mod.rs | 2 +- src/http/tests.rs | 2 +- src/registry/tests.rs | 6 +++--- tests/conformance.rs | 4 ++-- 4 files changed, 7 insertions(+), 7 deletions(-) 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/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/registry/tests.rs b/src/registry/tests.rs index 97ca390..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}"); } } 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(