From 4af5b0cdb81254980a1c4f31be89484fcd579a43 Mon Sep 17 00:00:00 2001 From: includeamin Date: Thu, 1 Oct 2026 22:36:48 +0200 Subject: [PATCH 1/2] feat(encryption): add encryption --- src/cenc/mod.rs | 17 +- src/cenc/signal.rs | 42 +++-- src/clip.rs | 86 +++++++--- src/composite.rs | 2 +- src/protocol/dash.rs | 10 +- src/protocol/hls.rs | 59 ++++--- src/registry/mod.rs | 19 ++- src/registry/tests.rs | 358 ++++++++++++++++++++++++++++++++++++++++- src/resolver/mapper.rs | 27 ++-- src/resolver/mod.rs | 4 + src/sequence.rs | 64 +++++--- src/testutil.rs | 22 ++- 12 files changed, 617 insertions(+), 93 deletions(-) diff --git a/src/cenc/mod.rs b/src/cenc/mod.rs index 8d23baf..f697be9 100644 --- a/src/cenc/mod.rs +++ b/src/cenc/mod.rs @@ -10,7 +10,10 @@ mod signal; pub(crate) use keys::{Encryption, WireEncryption}; pub(crate) use segment::{AssetProtection, PendingEncryption}; -pub(crate) use signal::{dash_content_protection, hls_key_lines, hls_session_keys}; +pub(crate) use signal::{ + dash_content_protection, hls_key_lines, hls_key_none, hls_session_keys, + hls_session_keys_for_sequence, +}; /// See `fuzzing::exercise_avc_slice_header`. pub(crate) fn fuzz_avc(data: &[u8]) { @@ -42,4 +45,16 @@ pub(crate) mod tests_support { .validate() .unwrap() } + + /// Like [`sample_encryption`], under a different key ID and key, with Clear Key only, so two + /// clips (or assets) can be given distinguishable, independently valid encryption. + pub(crate) fn rekeyed_encryption() -> Encryption { + serde_json::from_str::( + r#"{"scheme":"cbcs","keys":[{"key_id":"fedcba9876543210fedcba9876543210","key":"ffeeddccbbaa99887766554433221100"}], + "systems":[{"system_id":"e2719d58-a985-b3c9-781a-b030af78d30e","license_url":"https://l.example.net/ck"}]}"#, + ) + .unwrap() + .validate() + .unwrap() + } } diff --git a/src/cenc/signal.rs b/src/cenc/signal.rs index 2d8f595..3842bf0 100644 --- a/src/cenc/signal.rs +++ b/src/cenc/signal.rs @@ -30,25 +30,45 @@ pub(crate) fn hls_key_lines(encryption: &Encryption, key: &ContentKey, tag: &str /// 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 { + hls_session_keys_for_sequence(std::iter::once(encryption)) +} + +/// Like [`hls_session_keys`], for every encryption used by any clip of a sequence (TDD 0009, +/// "Different keys per clip"), deduped by RFC 8216 identity across the whole sequence rather than +/// within one clip, so a clip that repeats an earlier clip's key and system does not repeat its +/// line, and a player sees every licence the sequence might need from the master playlist alone. +pub(crate) fn hls_session_keys_for_sequence<'a>( + encryptions: impl Iterator, +) -> String { let mut lines = String::new(); let mut seen: Vec = Vec::new(); - for key in encryption.distinct_keys() { - for system in &encryption.systems { - let Some(identity) = hls_attributes(system, None) else { - continue; - }; - if seen.contains(&identity) { - continue; - } - seen.push(identity); - if let Some(attributes) = hls_attributes(system, Some(&key.key_id)) { - push_key_line(&mut lines, "EXT-X-SESSION-KEY", &attributes); + for encryption in encryptions { + 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 } +/// `#EXT-X-KEY:METHOD=NONE`, RFC 8216's way to say that the Media Segments from here on are not +/// encrypted, needed when a sequence's clips move from encrypted to clear (TDD 0009, "Different +/// keys per clip"): an `EXT-X-KEY` tag otherwise applies to everything until the next one, so +/// without this a player would keep decrypting clear segments with the previous clip's key. +pub(crate) fn hls_key_none(tag: &str) -> String { + format!("#{tag}:METHOD=NONE\n") +} + 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"); diff --git a/src/clip.rs b/src/clip.rs index 2ebd360..49643b4 100644 --- a/src/clip.rs +++ b/src/clip.rs @@ -300,13 +300,21 @@ fn moved_track(track: &Track, range: (usize, usize), origin: u64, base: u64) -> }) } -/// The URL version of an asset built from clips: the mapper's `version`, every clip's content -/// and window, and the format revision. The window is included even for a single clip, so a -/// trimmed copy never shares a version, and so cached immutable segments, with the whole file. +/// The URL version of an asset built from clips: the mapper's `version`, every clip's content, +/// window, and own encryption, and the format revision. The window is included even for a single +/// clip, so a trimmed copy never shares a version, and so cached immutable segments, with the +/// whole file. Each clip's encryption is hashed on its own, not once for the whole sequence, so +/// re-keying one clip (or turning its encryption on or off) changes the URL even if no other clip +/// changed (TDD 0009, "Different keys per clip"). pub(crate) fn version_of<'a>( mapper_version: &str, - clips: impl IntoIterator, - encryption: Option<&crate::cenc::Encryption>, + clips: impl IntoIterator< + Item = ( + &'a SourceIdentity, + ClipWindow, + Option<&'a crate::cenc::Encryption>, + ), + >, ) -> String { use std::fmt::Write; @@ -317,7 +325,7 @@ pub(crate) fn version_of<'a>( hasher.update(crate::asset::FORMAT_REVISION.to_be_bytes()); hasher.update((mapper_version.len() as u64).to_be_bytes()); hasher.update(mapper_version.as_bytes()); - for (source, window) in clips { + for (source, window, encryption) in clips { hasher.update( source .metadata_sha256 @@ -331,10 +339,13 @@ pub(crate) fn version_of<'a>( } None => hasher.update([0]), } - } - if let Some(encryption) = encryption { - hasher.update(b"cbcs"); - hasher.update(encryption.fingerprint()); + match encryption { + Some(encryption) => { + hasher.update(b"cbcs"); + hasher.update(encryption.fingerprint()); + } + None => hasher.update(b"clear"), + } } hasher .finalize() @@ -716,32 +727,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)))], None); + 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)))], None), + version_of("v1", [(source, window(0, Some(2000)), None)]), "stable" ); assert_ne!( base, - version_of("v1", [(source, window(0, Some(3000)))], None), + version_of("v1", [(source, window(0, Some(3000)), None)]), "the window" ); assert_ne!( base, - version_of("v1", [(source, window(0, None))], None), + version_of("v1", [(source, window(0, None), None)]), "an open end" ); assert_ne!( base, - version_of("v2", [(source, window(0, Some(2000)))], None), + version_of("v2", [(source, window(0, Some(2000)), None)]), "the mapper version" ); assert_ne!( base, - version_of("v1", [(&other.source, window(0, Some(2000)))], None), + version_of("v1", [(&other.source, window(0, Some(2000)), None)]), "the content" ); assert_ne!( @@ -749,15 +760,52 @@ mod tests { version_of( "v1", [ - (source, window(0, Some(2000))), - (source, window(0, Some(2000))) + (source, window(0, Some(2000)), None), + (source, window(0, Some(2000)), None) ], - None ), "the clip count" ); } + #[test] + fn the_version_covers_each_clips_own_encryption() { + let index = parse("h264-aac.mp4"); + let source = &index.source; + let one = crate::cenc::tests_support::sample_encryption(); + let other = crate::cenc::tests_support::rekeyed_encryption(); + let clear = version_of("v1", [(source, window(0, None), None)]); + let encrypted = version_of("v1", [(source, window(0, None), Some(&one))]); + let rekeyed = version_of("v1", [(source, window(0, None), Some(&other))]); + + assert_ne!( + clear, encrypted, + "turning encryption on changes the version" + ); + assert_ne!(encrypted, rekeyed, "a different key changes the version"); + + // Two clips: only the second is encrypted. Changing just that clip's key must still + // change the whole sequence's version, even though clip 0 and the window list match. + let base = version_of( + "v1", + [ + (source, window(0, Some(1000)), None), + (source, window(1000, None), Some(&one)), + ], + ); + let rekeyed_second = version_of( + "v1", + [ + (source, window(0, Some(1000)), None), + (source, window(1000, None), Some(&other)), + ], + ); + assert_ne!( + base, rekeyed_second, + "re-keying one clip changes the version even when the other clip is unchanged" + ); + } + #[test] fn only_the_last_clip_keeps_audio_that_outlasts_its_video() { let index = parse("h264-aac.mp4"); diff --git a/src/composite.rs b/src/composite.rs index 693f35b..dfb6813 100644 --- a/src/composite.rs +++ b/src/composite.rs @@ -39,7 +39,7 @@ impl ServedAsset { .asset .encryption() .map(crate::cenc::Encryption::key_ids), - Self::Sequence(asset) => asset.encryption().map(crate::cenc::Encryption::key_ids), + Self::Sequence(asset) => asset.key_ids(), } } diff --git a/src/protocol/dash.rs b/src/protocol/dash.rs index afbe90c..e477241 100644 --- a/src/protocol/dash.rs +++ b/src/protocol/dash.rs @@ -232,10 +232,14 @@ pub(crate) fn sequence_manifest( total_nanos: u64, version: &str, ) -> Result { + // A clip's own `ContentProtection` is written per Period by `write_video_adaptation` and + // `write_audio_adaptation` below, each reading that clip's own `Presentation`, so per-clip + // keys (TDD 0009) need no further change here. The namespaces must be declared whenever *any* + // clip is encrypted, not only the first, or a later Period's `cenc:`/`dashif:` elements would + // use undeclared prefixes. let encrypted = clips - .first() - .and_then(|clip| clip.presentation.encryption()) - .is_some(); + .iter() + .any(|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!( diff --git a/src/protocol/hls.rs b/src/protocol/hls.rs index 814ee70..10ccbdd 100644 --- a/src/protocol/hls.rs +++ b/src/protocol/hls.rs @@ -194,12 +194,25 @@ pub(crate) fn media_playlist(presentation: Presentation<'_>, key: TrackKey) -> R /// 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() + key_transition_lines(None, presentation.encryption(), track.kind, "EXT-X-KEY") +} + +/// What to write when moving from `previous` clip's encryption to `current` clip's, for `tag` +/// (TDD 0009, "Different keys per clip"). An `EXT-X-KEY` tag applies to every segment until the +/// next one, so a transition to clear after an encrypted clip needs an explicit +/// `METHOD=NONE` line; a transition into or within encryption needs the new key's lines; staying +/// clear needs nothing, since a playlist with no preceding key is clear by default. +fn key_transition_lines( + previous: Option<&crate::cenc::Encryption>, + current: Option<&crate::cenc::Encryption>, + kind: crate::media::TrackKind, + tag: &str, +) -> String { + match current { + Some(encryption) => crate::cenc::hls_key_lines(encryption, encryption.key_for(kind), tag), + None if previous.is_some() => crate::cenc::hls_key_none(tag), + None => String::new(), + } } /// The longest segment of `track`, in whole seconds rounded up, as `#EXT-X-TARGETDURATION` needs. @@ -240,8 +253,10 @@ fn write_segment_entries( Ok(()) } -/// One track's playlist across every clip of a sequence: each clip names its own init segment, -/// and a discontinuity separates it from the one before, since the encoding may change there. +/// One track's playlist across every clip of a sequence: each clip names its own init segment, a +/// discontinuity separates it from the one before, since the encoding may change there, and an +/// `#EXT-X-KEY` (or `METHOD=NONE`) is given whenever a clip's encryption differs from the one +/// before it (TDD 0009, "Different keys per clip"). pub(crate) fn sequence_media_playlist( clips: &[SequenceClip<'_>], key: TrackKey, @@ -257,17 +272,22 @@ 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)?, - )); - } + let mut previous_encryption: Option<&crate::cenc::Encryption> = None; for (position, clip) in clips.iter().enumerate() { let track = clip.presentation.track(key)?; if position > 0 { playlist.push_str("#EXT-X-DISCONTINUITY\n"); } + let encryption = clip.presentation.encryption(); + if encryption != previous_encryption { + playlist.push_str(&key_transition_lines( + previous_encryption, + encryption, + track.kind, + "EXT-X-KEY", + )); + previous_encryption = encryption; + } writeln!( playlist, "#EXT-X-MAP:URI=\"clips/{position}/init.mp4?v={version}\"" @@ -311,12 +331,11 @@ 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(), - ); + playlist.push_str(&crate::cenc::hls_session_keys_for_sequence( + clips + .iter() + .filter_map(|clip| clip.presentation.encryption()), + )); 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 diff --git a/src/registry/mod.rs b/src/registry/mod.rs index 2861d00..7185958 100644 --- a/src/registry/mod.rs +++ b/src/registry/mod.rs @@ -558,6 +558,15 @@ impl AssetRegistry { let new_locations = new.locations(); let same_objects = old.version == new.version && old.encryption == new.encryption + // Each clip's own window and encryption override (TDD 0009, "Different keys per + // clip"); empty for non-clip assets, so this is a no-op there. Deliberately not a + // location comparison: that is `old_locations`/`new_locations` below, compared + // loosely by `same_object` so a re-signed URL still counts as unchanged. + && old.clips.len() == new.clips.len() + && old.clips.iter().zip(&new.clips).all(|(old_clip, new_clip)| { + old_clip.window == new_clip.window + && old_clip.encryption == new_clip.encryption + }) && old_locations.len() == new_locations.len() && old_locations.iter().zip(&new_locations).all( |((old_id, old_loc), (new_id, new_loc))| { @@ -873,7 +882,13 @@ impl AssetRegistry { files.push((position, clip.location.clone())); files.len() - 1 }); - clips.push((file, clip.window)); + // This clip's own encryption (TDD 0009, "Different keys per clip"), falling back to + // the answer's own `encryption` when the clip did not name one. + let encryption = clip + .encryption + .clone() + .or_else(|| resolved.encryption.clone()); + clips.push((file, clip.window, encryption)); } let mut tasks = tokio::task::JoinSet::new(); @@ -922,7 +937,6 @@ 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, @@ -931,7 +945,6 @@ impl AssetRegistry { &version, segment_duration_ms, &limits, - encryption.as_ref(), ) }) .await diff --git a/src/registry/tests.rs b/src/registry/tests.rs index 04f0ed4..6e6ca0c 100644 --- a/src/registry/tests.rs +++ b/src/registry/tests.rs @@ -1707,6 +1707,21 @@ fn clip( (file_location(path), from_ms, to_ms) } +/// Like [`clip`], with an own `encryption` override for [`Answer::clips_with_encryption`]. +fn clip_encrypted( + path: &str, + from_ms: Option, + to_ms: Option, + encryption: Option, +) -> ( + serde_json::Value, + Option, + Option, + Option, +) { + (file_location(path), from_ms, to_ms, encryption) +} + #[tokio::test] async fn a_single_clip_trims_one_file_and_keeps_its_urls() { let h = harness().await; @@ -1839,20 +1854,45 @@ async fn malformed_clip_answers_are_rejected() { format!(r#""clips":[{clip},{clip},{clip}]"#), // A location the path rules refuse. r#""clips":[{"location":{"type":"file","path":"../h264-aac.mp4"}}]"#.to_owned(), + // A clip's own `encryption` (TDD 0009, "Different keys per clip") that fails the same + // rules as the answer's own: a key that is not 32 hex digits. + format!( + r#""clips":[{{"location":{{"type":"file","path":"h264-aac.mp4"}},"encryption":{{"scheme":"cbcs","keys":[{{"key_id":"{KEY_ID}","key":"{KEY}zz"}}]}}}}]"# + ), ]; for (index, fields) in cases.iter().enumerate() { // A different ID per case, so no case is answered from another's cached failure. let id = format!("bad{index}"); *h.mapper.state.raw_body.lock().unwrap() = Some(format!(r#"{{"asset_id":"{id}","version":"v1",{fields}}}"#)); - assert_eq!( - status(&h.app, &format!("/hls/{id}/master.m3u8")).await, - StatusCode::BAD_GATEWAY, - "{fields}" + let (code, _, body) = fetch(&h.app, &format!("/hls/{id}/master.m3u8")).await; + assert_eq!(code, StatusCode::BAD_GATEWAY, "{fields}"); + assert!( + !String::from_utf8_lossy(&body).contains(KEY), + "a clip's own rejected key must not leak either: {fields}" ); } } +/// A bad per-clip `encryption` is rejected naming the clip, not silently applied or ignored. +#[tokio::test] +async fn a_bad_clip_level_encryption_names_the_clip() { + let h = harness().await; + *h.mapper.state.raw_body.lock().unwrap() = Some(format!( + r#"{{"asset_id":"movie","version":"v1","clips":[ + {{"location":{{"type":"file","path":"h264-aac.mp4"}},"to_ms":1500}}, + {{"location":{{"type":"file","path":"h264-aac.mp4"}},"from_ms":1500, + "encryption":{{"scheme":"cbcs","keys":[{{"key_id":"{KEY_ID}","key":"{KEY}zz"}}]}}}} + ]}}"# + )); + + let (code, _, body) = fetch(&h.app, "/hls/movie/master.m3u8").await; + + assert_eq!(code, StatusCode::BAD_GATEWAY); + let text = String::from_utf8_lossy(&body); + assert!(!text.contains(KEY)); +} + #[tokio::test] async fn one_bad_clip_fails_the_whole_asset() { let h = harness().await; @@ -2558,6 +2598,316 @@ async fn admin_status_reports_key_ids_never_keys() { assert!(!String::from_utf8_lossy(&body).contains(KEY)); } +// --------------------------------------------------------------------------------------------- +// Different keys per clip (TDD 0009, "Deferred" -> implemented) +// --------------------------------------------------------------------------------------------- + +const KEY_ID_B: &str = "fedcba9876543210fedcba9876543210"; +const KEY_B: &str = "ffeeddccbbaa99887766554433221100"; + +fn clear_key_encryption_b() -> serde_json::Value { + serde_json::json!({ + "scheme": "cbcs", + "keys": [{ "key_id": KEY_ID_B, "key": KEY_B }], + "systems": [{ "system_id": "e2719d58-a985-b3c9-781a-b030af78d30e", "license_url": "https://l.example.net/ck-b" }], + }) +} + +/// A clear pre-roll clip, then an encrypted one, exactly TDD 0009's "Different keys per clip" +/// motivating example. No asset-level `encryption` is set; clip 1 carries its own. +#[tokio::test] +async fn a_clear_clip_can_precede_an_encrypted_one() { + let h = harness().await; + h.mapper.state.set( + "mixed", + Answer::clips_with_encryption( + "v1", + &[ + clip_encrypted("h264-aac.mp4", None, Some(1500), None), + clip_encrypted( + "h264-aac.mp4", + Some(1500), + None, + Some(clear_key_encryption(KEY)), + ), + ], + ), + ); + let version = version_in(&fetch(&h.app, "/hls/mixed/master.m3u8").await.2); + + let playlist = text(&fetch(&h.app, "/hls/mixed/video/index.m3u8").await.2); + let map0 = playlist + .find("clips/0/init.mp4") + .expect("clip 0's EXT-X-MAP"); + let map1 = playlist + .find("clips/1/init.mp4") + .expect("clip 1's EXT-X-MAP"); + assert!( + playlist[..map0].find("#EXT-X-KEY:").is_none(), + "clip 0 is clear, so no key precedes it: {playlist}" + ); + let key1 = playlist[map0..map1] + .find("#EXT-X-KEY:") + .expect("a key line between clip 0's MAP and clip 1's"); + assert!( + playlist[map0..map0 + key1].contains("#EXT-X-DISCONTINUITY"), + "the key line follows the discontinuity: {playlist}" + ); + assert!( + playlist[map0..map1].contains(r#"KEYFORMAT="org.w3.clearkey""#), + "{playlist}" + ); + + // Clip 0's segment is untouched; clip 1's is protected. + let clear_segment = fetch( + &h.app, + &format!("/hls/mixed/video/segments/0/media.m4s?v={version}"), + ) + .await + .2; + let encrypted_segment = fetch( + &h.app, + &format!("/hls/mixed/video/segments/2/media.m4s?v={version}"), + ) + .await + .2; + assert!(!clear_segment.windows(4).any(|w| w == b"senc")); + assert!(encrypted_segment.windows(4).any(|w| w == b"senc")); + + // DASH: only clip 1's Period is protected, but the namespaces cover the whole MPD. + let manifest = text(&fetch(&h.app, "/dash/mixed/manifest.mpd").await.2); + assert!(manifest.contains("xmlns:cenc="), "{manifest}"); + let period0 = &manifest[manifest.find(">(); + assert_eq!(key_ids.len(), 2, "{key_ids:?}"); + assert!(key_ids.contains(&"01234567-89ab-cdef-0123-456789abcdef")); + assert!(key_ids.contains(&"fedcba98-7654-3210-fedc-ba9876543210")); +} + +/// Changing only the second clip's key changes the whole sequence's version (`clip::version_of` +/// hashes each clip's own encryption), and the mapper is never asked to echo a key back. +#[tokio::test] +async fn rekeying_one_clip_changes_the_sequence_version() { + let h = harness().await; + let make = |key: &str| { + Answer::clips_with_encryption( + "v1", + &[ + clip_encrypted("h264-aac.mp4", None, Some(1500), None), + clip_encrypted( + "h264-aac.mp4", + Some(1500), + None, + Some(clear_key_encryption(key)), + ), + ], + ) + }; + h.mapper.state.set("a", make(KEY)); + h.mapper.state.set("b", make(KEY_B)); + + let version_a = version_in(&fetch(&h.app, "/hls/a/master.m3u8").await.2); + let version_b = version_in(&fetch(&h.app, "/hls/b/master.m3u8").await.2); + + assert_ne!(version_a, version_b); +} + +/// Independent decryption of a mixed sequence: the clear clip plays with no key, the encrypted +/// one only with its own, and a differently keyed clip is refused both the wrong key and the +/// other clip's key (TDD 0009, "Testing"). +#[tokio::test] +async fn ffmpeg_decrypts_a_mixed_sequence_clip_by_clip() { + if std::process::Command::new("ffprobe") + .arg("-version") + .output() + .is_err() + { + eprintln!("skipping: ffprobe is not installed"); + return; + } + let h = harness().await; + h.mapper.state.set( + "mixed", + Answer::clips_with_encryption( + "v1", + &[ + clip_encrypted("h264-aac.mp4", None, Some(1500), None), + clip_encrypted( + "h264-aac.mp4", + Some(1500), + None, + Some(clear_key_encryption(KEY)), + ), + clip_encrypted( + "h264-aac.mp4", + None, + Some(1500), + Some(clear_key_encryption_b()), + ), + ], + ), + ); + let directory = + std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("target/mixed-key-decode"); + std::fs::create_dir_all(&directory).unwrap(); + + // Each clip's init and media URIs, as the playlist actually names them: segment numbers run + // across the whole sequence, not per clip (see `every_clip_of_a_sequence_decodes_...`). + let playlist = text(&fetch(&h.app, "/hls/mixed/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() + .expect("a map before any segment") + .1 + .push(line.to_owned()); + } + } + assert_eq!(clips.len(), 3); + + let keys: [Option<&str>; 3] = [None, Some(KEY), Some(KEY_B)]; + for (position, ((init, segments), key)) in clips.iter().zip(keys).enumerate() { + let mut bytes = fetch(&h.app, &format!("/hls/mixed/video/{init}")) + .await + .2 + .to_vec(); + for segment in segments { + bytes.extend_from_slice( + &fetch(&h.app, &format!("/hls/mixed/video/{segment}")) + .await + .2, + ); + } + let path = directory.join(format!("clip-{position}.mp4")); + std::fs::write(&path, &bytes).unwrap(); + + assert!( + decode_errors(&path, key).is_empty(), + "clip {position} with its own key" + ); + if let Some(wrong) = keys.iter().find(|candidate| **candidate != key).copied() { + let errors = decode_errors(&path, wrong); + assert!( + key.is_none() || !errors.is_empty(), + "clip {position} must not decode cleanly under a different clip's 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"); diff --git a/src/resolver/mapper.rs b/src/resolver/mapper.rs index 83fd516..6be8ded 100644 --- a/src/resolver/mapper.rs +++ b/src/resolver/mapper.rs @@ -69,6 +69,10 @@ struct WireClip { location: WireLocation, from_ms: Option, to_ms: Option, + /// Overrides the answer's own `encryption` for this clip only (TDD 0009, "Different keys per + /// clip"). Absent means "use the answer's `encryption`", which may itself be absent (clear). + #[serde(default)] + encryption: Option, } #[derive(Debug, Deserialize)] @@ -435,11 +439,22 @@ impl HttpResolver { from_ms, to_ms: clip.to_ms, }, + encryption: Self::interpret_encryption(clip.encryption.as_ref()).map_err( + |message| ResolveError::Rejected(format!("clip {position}: {message}")), + )?, }) }) .collect() } + /// Validates an `encryption` object, from either the answer or one clip. `None` stays `None`. + fn interpret_encryption( + wire: Option<&crate::cenc::WireEncryption>, + ) -> Result>, String> { + wire.map(|encryption| encryption.validate().map(Arc::new)) + .transpose() + } + /// Validates a `200` answer against the request and the location policy. fn interpret( &self, @@ -497,16 +512,8 @@ 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()?; + let encryption = + Self::interpret_encryption(wire.encryption.as_ref()).map_err(ResolveError::Rejected)?; Ok(ResolvedAsset { location, renditions, diff --git a/src/resolver/mod.rs b/src/resolver/mod.rs index 07df1eb..fc574d7 100644 --- a/src/resolver/mod.rs +++ b/src/resolver/mod.rs @@ -74,6 +74,10 @@ pub(crate) struct RenditionLocation { pub(crate) struct ClipLocation { pub(crate) location: AssetLocation, pub(crate) window: ClipWindow, + /// This clip's own content keys and DRM systems (TDD 0009, "Different keys per clip"); falls + /// back to the answer's own `encryption` when absent, so a clear pre-roll before an encrypted + /// movie sets this to `None` on its own clip rather than on the whole answer. + pub(crate) encryption: Option>, } /// Which of an answer's locations something refers to: the single file, one rendition, or one diff --git a/src/sequence.rs b/src/sequence.rs index 5d208e6..57b66c3 100644 --- a/src/sequence.rs +++ b/src/sequence.rs @@ -47,21 +47,25 @@ struct SequenceManifests { /// Trims every clip from its already parsed file, in order, each starting where the one before /// ended, and builds what is served (TDD 0008, "Loading"). One clip is an ordinary asset; two or /// more are a sequence. Errors name the clip by its position in the mapper's list. +/// +/// Each clip carries its own, already-resolved encryption (TDD 0009, "Different keys per clip"): +/// the registry has already applied the answer's own `encryption` as the fallback for a clip that +/// did not name its own, so `None` here means that clip is genuinely served clear. pub(crate) fn build( asset_id: &str, files: &[ClipFile], - clips: &[(usize, ClipWindow)], + clips: &[(usize, ClipWindow, Option>)], 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}")) }; let mut start = TimelinePosition::ZERO; let mut trimmed = Vec::with_capacity(clips.len()); - for (position, &(file, window)) in clips.iter().enumerate() { + for (position, (file, window, encryption)) in clips.iter().enumerate() { + let (file, window) = (*file, *window); let trailing = if position + 1 == clips.len() { Trailing::Keep } else { @@ -77,19 +81,23 @@ pub(crate) fn build( requested.to_ms = ?window.to_ms, served.from_ms = cut.served_from_ms, served.to_ms = cut.served_to_ms, + encrypted = encryption.is_some(), ); - trimmed.push((file, start, cut.index)); + trimmed.push((file, start, cut.index, encryption.clone())); start = cut.end; } let version = clip::version_of( mapper_version, - clips - .iter() - .map(|&(file, window)| (&files[file].parsed.index.source, window)), - encryption.map(AsRef::as_ref), + clips.iter().map(|(file, window, encryption)| { + ( + &files[*file].parsed.index.source, + *window, + encryption.as_deref(), + ) + }), ); let mut assets = Vec::with_capacity(trimmed.len()); - for (position, (file, clip_start, index)) in trimmed.into_iter().enumerate() { + for (position, (file, clip_start, index, encryption)) in trimmed.into_iter().enumerate() { let asset = PackagedAsset::assemble( files[file].source.clone(), index, @@ -99,7 +107,7 @@ pub(crate) fn build( Extras { subtitles: Vec::new(), version: Some(version.clone()), - encryption: encryption.map(Arc::clone), + encryption, }, ) .map_err(named(position))?; @@ -236,9 +244,20 @@ fn render( } impl SequenceAsset { - /// The encryption every clip shares, if any. - pub(crate) fn encryption(&self) -> Option<&crate::cenc::Encryption> { - self.clips[0].encryption() + /// Every key ID any clip is encrypted with, deduped and in clip order, or `None` if every + /// clip is served clear (TDD 0009, "Different keys per clip": clips may differ). + pub(crate) fn key_ids(&self) -> Option> { + let mut ids: Vec = Vec::new(); + for clip in &self.clips { + if let Some(encryption) = clip.encryption() { + for id in encryption.key_ids() { + if !ids.contains(&id) { + ids.push(id); + } + } + } + } + (!ids.is_empty()).then_some(ids) } pub(crate) fn version(&self) -> &str { @@ -352,17 +371,22 @@ mod tests { }; fn build_from(files: &[ClipFile], clips: &[(usize, ClipWindow)]) -> Result { - build( - "test", + build_encrypted( files, - clips, - "v1", - 1000, - &LimitsConfig::default(), - None, + &clips + .iter() + .map(|&(file, window)| (file, window, None)) + .collect::>(), ) } + fn build_encrypted( + files: &[ClipFile], + clips: &[(usize, ClipWindow, Option>)], + ) -> Result { + build("test", files, clips, "v1", 1000, &LimitsConfig::default()) + } + fn sequence(asset: &ServedAsset) -> &SequenceAsset { match asset { ServedAsset::Sequence(sequence) => sequence, diff --git a/src/testutil.rs b/src/testutil.rs index df0d8ab..0288d45 100644 --- a/src/testutil.rs +++ b/src/testutil.rs @@ -47,6 +47,10 @@ async fn serve(app: Router) -> SocketAddr { // Mapper // --------------------------------------------------------------------------------------------- +/// One clip for [`Answer::clips_with_encryption`]: a location, its window, and its own optional +/// `encryption` override. +pub(crate) type EncryptedClip = (Value, Option, Option, Option); + #[derive(Debug, Clone)] pub(crate) struct Answer { pub(crate) version: String, @@ -124,13 +128,26 @@ impl Answer { /// A sequence (TDD 0008): each entry is a location with an optional `from_ms` and `to_ms`. pub(crate) fn clips(version: &str, clips: &[(Value, Option, Option)]) -> Self { + Self::clips_with_encryption( + version, + &clips + .iter() + .map(|&(ref location, from_ms, to_ms)| (location.clone(), from_ms, to_ms, None)) + .collect::>(), + ) + } + + /// Like [`Self::clips`], with each clip's own `encryption` override (TDD 0009, "Different + /// keys per clip"); `None` leaves that clip without one, so it falls back to the answer's own + /// `encryption` (set separately with [`Self::with_encryption`]). + pub(crate) fn clips_with_encryption(version: &str, clips: &[EncryptedClip]) -> Self { Self { version: version.to_owned(), location: Value::Null, renditions: Vec::new(), clips: clips .iter() - .map(|(location, from_ms, to_ms)| { + .map(|(location, from_ms, to_ms, encryption)| { let mut clip = json!({ "location": location }); if let Some(from_ms) = from_ms { clip["from_ms"] = json!(from_ms); @@ -138,6 +155,9 @@ impl Answer { if let Some(to_ms) = to_ms { clip["to_ms"] = json!(to_ms); } + if let Some(encryption) = encryption { + clip["encryption"] = encryption.clone(); + } clip }) .collect(), From 6ca360f8d7dda302ee48ce31ad829f777fd57ba8 Mon Sep 17 00:00:00 2001 From: includeamin Date: Thu, 1 Oct 2026 22:37:22 +0200 Subject: [PATCH 2/2] feat(roadmap): add roadmap --- docs/README.md | 1 + docs/SUMMARY.md | 1 + docs/mapper-api.md | 30 ++++- docs/roadmap.md | 112 ++++++++++++++++++ .../0009-common-encryption-and-drm.md | 25 +++- 5 files changed, 162 insertions(+), 7 deletions(-) create mode 100644 docs/roadmap.md diff --git a/docs/README.md b/docs/README.md index 6270cad..f85251c 100644 --- a/docs/README.md +++ b/docs/README.md @@ -53,6 +53,7 @@ Architectural decision records (ADRs) capture durable choices, their context, an ## Guides +- [Roadmap](roadmap.md) lists every step needed for segmentor to replace nginx-vod-module, by phase and status. - [Operating the origin](operations.md) covers probes, shutdown, CORS, metrics, and limits for production deployments. - [Performance budgets](benchmarks.md) records how the budgets are measured and the current results. - [Releasing](releasing.md) explains the commit convention, automatic version tags, and the manual release workflow. diff --git a/docs/SUMMARY.md b/docs/SUMMARY.md index dd3a5ec..d178806 100644 --- a/docs/SUMMARY.md +++ b/docs/SUMMARY.md @@ -5,6 +5,7 @@ # Design - [Architecture](architecture.md) +- [Roadmap](roadmap.md) - [Using segmentor](usage.md) - [Supported input](supported-input.md) - [Deploying](deployment.md) diff --git a/docs/mapper-api.md b/docs/mapper-api.md index 0ff96ad..441d321 100644 --- a/docs/mapper-api.md +++ b/docs/mapper-api.md @@ -214,6 +214,7 @@ Instead of a single `location`, an answer may list clips: each a file and an opt | `location` | Yes | A `file` or `http` location with the same rules as a single asset's | | `from_ms` | No | Default `0`. A time on the file's own clock, the one subtitle cues are read against | | `to_ms` | No | Default: the end of the file. Must be later than `from_ms`; a value past the end is clamped to the end | +| `encryption` | No | This clip's own [`encryption`](#encryption) object, with the same rules as the answer's. Absent means the clip uses the answer's own `encryption` (or is clear if the answer has none). See [Different keys per clip](#different-keys-per-clip) | Both times are at most 4294967295 ms, and an answer may list at most `limits.max_clips` (default 64) clips. @@ -229,7 +230,7 @@ Both times are at most 4294967295 ms, and an answer may list at most `limits.max ## 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. +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 that does not carry its own (see [Different keys per clip](#different-keys-per-clip)). ```json { @@ -288,6 +289,33 @@ Any other system ID is accepted and signalled in DASH only (its `pssh` and `lice **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. +### Different keys per clip + +In a [clips](#clips) answer, each clip may carry its own `encryption` object. A clip without one uses the answer's own `encryption`; if the answer has none either, that clip is served clear. This covers a clear pre-roll before an encrypted movie, or two programmes under different keys: + +```json +{ + "asset_id": "movie-with-preroll", + "version": "2026-10-02-a", + "clips": [ + { "location": { "type": "file", "path": "ads/preroll.mp4" } }, + { "location": { "type": "file", "path": "movies/movie.mp4" }, + "encryption": { + "scheme": "cbcs", + "keys": [{ "key_id": "0123456789abcdef0123456789abcdef", "key": "00112233445566778899aabbccddeeff" }], + "systems": [{ "system_id": "94ce86fb-07ff-4f43-adb8-93d2fa968ca2", "hls_uri": "skd://movie" }] + } } + ] +} +``` + +- **HLS.** Each media playlist gives an `#EXT-X-KEY` line set wherever a clip's encryption differs from the clip before it, after the `#EXT-X-DISCONTINUITY` and before that clip's `#EXT-X-MAP`. Moving from an encrypted clip to a clear one writes `#EXT-X-KEY:METHOD=NONE`, because a key line otherwise applies to every later segment. The master playlist's `#EXT-X-SESSION-KEY` lines cover every key used by any clip, each system line given once. +- **DASH.** Each clip's `Period` carries its own `ContentProtection` elements, or none if that clip is clear. +- **Versioning.** Each clip's own keys are hashed into the URL version, so re-keying one clip, or turning its encryption on or off, gives the whole sequence new URLs. +- **Validation.** A clip's `encryption` follows every rule above; a bad one makes the whole answer malformed (`502`), with the error naming the clip. +- **Limitation.** A clip cannot opt out of the answer's own `encryption`: absent and `null` both mean "use the answer's". To mix clear and encrypted clips, leave the answer's `encryption` out and set it on the encrypted clips. +- `/admin/status` lists the key IDs of every clip. + ## 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. diff --git a/docs/roadmap.md b/docs/roadmap.md new file mode 100644 index 0000000..256de82 --- /dev/null +++ b/docs/roadmap.md @@ -0,0 +1,112 @@ +# Roadmap + +This page lists everything segmentor still needs before it can replace [nginx-vod-module](research/nginx-vod-module.md) in production. Each step is grouped into a phase, with its status and the design it belongs to. + +- **Feature parity** means segmentor does everything nginx-vod-module does that someone actually relies on. It does not mean copying nginx-vod-module's configuration syntax or URL scheme. Migrating an existing deployment is a separate step (Phase 5). +- **Out of scope by design** lists features that conflict with segmentor's architecture. They would need an ADR before any code. + +The baseline is nginx-vod-module's [published feature list](https://github.com/kaltura/nginx-vod-module#features), compared with the status lines of TDDs 0001 to 0009. + +## Where segmentor already matches + +| nginx-vod-module feature | segmentor | Design | +| --- | --- | --- | +| On-the-fly repackaging of MP4 to HLS and DASH | Done | [TDD 0001](technical-design/0001-on-demand-mp4-packaging-core.md) | +| Local, remote (HTTP range), and mapped working modes | Done; mapped mode uses segmentor's own mapper API | [TDD 0002](technical-design/0002-asset-map-interface.md) | +| Adaptive bitrate, alternative audio renditions | Done | [TDD 0006](technical-design/0006-trick-play-subtitles-and-renditions.md) | +| HLS I-frame playlists | Done, for clear assets and encrypted ones | [TDD 0006](technical-design/0006-trick-play-subtitles-and-renditions.md) | +| WebVTT subtitles (HLS and DASH) | Done, as sidecar files | [TDD 0006](technical-design/0006-trick-play-subtitles-and-renditions.md) | +| H.264, HEVC, VP9, AV1 video; AAC, AC-3, E-AC-3, Opus, FLAC audio | Done (clear) | [TDD 0004](technical-design/0004-broader-mp4-input-support.md) | +| Audio-only and video-only files | Done | [TDD 0004](technical-design/0004-broader-mp4-input-support.md) | +| Fragmented MP4 input | Done | [TDD 0005](technical-design/0005-fragmented-mp4-input.md) | +| Source clipping; playing several files back to back | Done | [TDD 0008](technical-design/0008-clipping-and-concatenation.md) | +| Variable segment lengths | Done, inherent to keyframe-aligned planning | [TDD 0001](technical-design/0001-on-demand-mp4-packaging-core.md) | +| DRM: CENC `cbcs` for DASH, `SAMPLE-AES` for HLS (Widevine, FairPlay, PlayReady, Clear Key) | Done for H.264 and audio, including different keys per clip | [TDD 0009](technical-design/0009-common-encryption-and-drm.md) | +| Metadata caching, asynchronous I/O, CDN-friendly output | Done | [TDD 0001](technical-design/0001-on-demand-mp4-packaging-core.md), [TDD 0003](technical-design/0003-production-grade-http-api.md) | + +## Phase 1: Finish DRM + +DRM is the most common reason a studio-licensed catalog cannot move. These steps extend the accepted [TDD 0009](technical-design/0009-common-encryption-and-drm.md) and need no new design document. + +| Step | Status | Notes | +| --- | --- | --- | +| Different keys per clip | Done | Clear pre-roll before encrypted content; `METHOD=NONE` transitions | +| HEVC encryption | To do | Slice-header parser for HEVC (the `hvcC` path); same `cbcs` pattern as H.264 | +| Whole-segment HLS AES-128 | To do | nginx-vod-module's "HLS AES-128": simple protection without a DRM vendor. Whole-fragment AES-128-CBC with a key URI, independent of `cbcs` | +| Key rotation within an asset | To do | Key periods over the timeline. HLS: new `EXT-X-KEY` at period boundaries. DASH: per-Period or `pssh` in fragments | +| Decrypting CENC-encrypted source files | To do | nginx-vod-module repackages already-encrypted MP4s. Needs `encv`/`enca` parsing and a source-key field in the mapper answer | +| AV1 and VP9 encryption | To do | OBU-aware and superframe-aware subsample rules | +| Opus and FLAC encryption | To do | Whole-sample protection, as for AAC | +| `cenc` (AES-CTR) scheme | Decide first | Only needed for players that cannot do `cbcs`. Confirm a real target device needs it before building | +| SPEKE v2 key exchange | To do, mapper side | segmentor never talks to a licence server, so this is a reference mapper adapter and guide, not server code | +| Widevine, FairPlay, PlayReady playback on real devices | To do | Manual release check with an operator's DRM vendor account; only Clear Key is verified in browsers today | + +## Phase 2: Captions and codecs + +| Step | Status | Notes | +| --- | --- | --- | +| SRT input converted to WebVTT | To do | The most common format in source libraries ([TDD 0006](technical-design/0006-trick-play-subtitles-and-renditions.md) open question) | +| TTML/DFXP input | To do | Needs an XML parser; keep styling out of scope as nginx-vod-module does | +| CAP (Cheetah) input | Decide first | Rare; build only for a known catalog that uses it | +| Segmented subtitles | To do | Segmented WebVTT for HLS, and WebVTT or SMPTE-TT segments for DASH, instead of one sidecar file | +| Text tracks inside the MP4 (`tx3g`, `wvtt`) | To do | Today only sidecar files are served | +| Subtitles together with clips | To do | Rejected today because cues would drift by up to a GOP ([TDD 0008](technical-design/0008-clipping-and-concatenation.md)) | +| MP3 audio | To do | HLS only in nginx-vod-module; today rejected inside `mp4a` | +| DTS audio | Decide first | HLS only in nginx-vod-module | +| Vorbis audio | Decide first | DASH only, and mostly a WebM concern | +| Muxing audio and video from separate files into one stream | To do | nginx-vod-module serves a separate audio file as part of the video's stream, without client support for rendition groups | +| Track selection for files with several audio or video tracks | To do | Let the mapper choose which tracks of a file to serve | + +## Phase 3: Delivery modes and protocols + +These change what segmentor is, so each starts with a new TDD. + +| Step | Status | Notes | +| --- | --- | --- | +| Simulated live from VOD files | New TDD | A live playlist, a sliding window, and a wall-clock timeline over the clip sequences that already exist. [TDD 0008](technical-design/0008-clipping-and-concatenation.md) defers it explicitly | +| Fallback on file not found | New TDD | nginx-vod-module retries another datacenter on a miss. In segmentor this is a mapper concern (a second location) or an origin failover list | +| Ad markers (`EXT-X-DATERANGE`, DASH events) | New TDD | Deferred by [TDD 0008](technical-design/0008-clipping-and-concatenation.md), together with linear channels | +| Microsoft Smooth Streaming (MSS) | Decide first | A third manifest and fragment layout, plus PlayReady for MSS. Only worth it for a known Xbox or legacy smart-TV audience | +| Adobe HDS | Not recommended | Requires Flash, which no current player supports | +| MPEG-TS segments for HLS | Decide first | nginx-vod-module serves TS; segmentor serves fMP4 ([ADR 0001](adr/0001-use-fragmented-mp4-for-media-segments.md)). Needed only for very old HLS players | +| Clipped MP4 for progressive download | Decide first | A whole-file MP4 response for a window, for players that cannot do HLS or DASH | + +## Phase 4: Production readiness + +| Step | Status | Notes | +| --- | --- | --- | +| Viewer authorization (signed tokens, optional live check) | Draft | [TDD 0007](technical-design/0007-viewer-authorization.md). nginx-vod-module relies on NGINX modules for this | +| Apple Media Stream Validator on served HLS | To do | Release-time check ([conformance](conformance.md)) | +| DASH-IF conformance tool on served MPDs | To do | Release-time check | +| Browser playback suite (hls.js, dash.js, Safari native) | To do | Start, seek, and play, across the codecs and DRM modes above | +| Benchmarks on the reference host | To do | Budgets are met on a laptop ([benchmarks](benchmarks.md)); record them on the documented four-core host | +| Load test against a production-like catalog | To do | Cold storage, many assets, remote origins, and CDN miss traffic, not one warm file | +| Mapper bearer-token rotation without restart | To do | Re-read the token file periodically | +| Tests against a real HTTPS mapper and origin | To do | Today only in-process mocks | + +## Phase 5: Migration from nginx-vod-module + +| Step | Status | Notes | +| --- | --- | --- | +| Mapping-JSON compatibility layer | New TDD | An adapter that turns nginx-vod-module's mapped-mode JSON into a segmentor mapper answer, so existing mapping services keep working. [TDD 0008](technical-design/0008-clipping-and-concatenation.md) defers "vod-module mapping compatibility" | +| URL compatibility, or a redirect layer | Decide first | nginx-vod-module URLs differ from segmentor's. Players that load URLs from the mapper need nothing; hard-coded URLs need a rewrite at the proxy | +| Migration guide | To do | Feature mapping, configuration translation, and a shadow-traffic comparison procedure | +| Shadow comparison in production | To do | Run both behind the same CDN on a sample of traffic, and compare manifests, segment decodes, and error rates before cutover | + +## Out of scope by design + +segmentor never decodes media. The following nginx-vod-module features need decoding (it uses FFmpeg libraries for them), so they would need an ADR that changes that principle: + +- thumbnail capture and resizing; +- volume maps; +- playback-rate change, gain, and audio mixing filters. + +If one of them is needed, the usual approach is a separate service next to segmentor (for example a thumbnail service reading the same storage), keeping the packaging path decode-free. + +## order + +1. **Phase 1** (finish DRM): it is the most common blocker for premium catalogs, and it needs no new designs. +2. **Phase 4**, done in parallel: the vendor validators, browser suite, and reference benchmarks give the evidence a production cutover needs, whatever features are added. +3. **Phase 2**: SRT input, segmented subtitles, and MP3 cover most remaining catalogs. +4. **Phase 5**: the migration path, once the features a given deployment uses are covered. +5. **Phase 3**: only the items a real deployment needs; simulated live is the most likely. diff --git a/docs/technical-design/0009-common-encryption-and-drm.md b/docs/technical-design/0009-common-encryption-and-drm.md index 4b0a93c..9d3019a 100644 --- a/docs/technical-design/0009-common-encryption-and-drm.md +++ b/docs/technical-design/0009-common-encryption-and-drm.md @@ -1,8 +1,8 @@ # TDD 0009: Common encryption and DRM -- Status: Accepted; H.264 and audio implemented, HEVC pending +- Status: Accepted; H.264 and audio implemented, different keys per clip implemented, HEVC pending - Created: 2026-10-01 -- Updated: 2026-10-01 +- Updated: 2026-10-02 - 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) @@ -12,7 +12,7 @@ segmentor encrypts what it serves with Common Encryption (ISO/IEC 23001-7) in th 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)). +This is the first stage of DRM: the shared core. It deliberately leaves out AV1 and VP9, key rotation, whole-segment HLS AES-128, encrypted source files, and a SPEKE adapter (see [Deferred](#deferred)). Different keys per clip was added afterwards (see [Different keys per clip](#different-keys-per-clip)). ## Context @@ -38,7 +38,7 @@ The mapper already carries per-asset secrets under HTTPS and a bearer token, has - 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). +- Key rotation, whole-segment HLS AES-128, encrypted source passthrough, and SPEKE. See [Deferred](#deferred). ## Design @@ -107,7 +107,21 @@ Nothing encrypted is cached inside segmentor; a CDN caches it, as it caches ever ### 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. +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, each clip is encrypted with its own keys (see [Different keys per clip](#different-keys-per-clip)); when every clip shares the answer's keys, the HLS `#EXT-X-KEY` lines appear once before the first `#EXT-X-MAP` and stay in force across discontinuities. + +### Different keys per clip + +Added after the first stage. Each entry of a mapper answer's `clips` list may carry its own `encryption` object, validated by the same rules as the answer's. The registry resolves each clip's encryption as the clip's own, else the answer's, else none, and passes it to that clip's `PackagedAsset::assemble`. Each clip is already its own `PackagedAsset`, so init segments, fragments, and the segment path need no change. + +- **HLS media playlists** compare each clip's encryption with the previous clip's. When they differ, the new clip's `#EXT-X-KEY` lines go after the `#EXT-X-DISCONTINUITY` and before its `#EXT-X-MAP`. A move from encrypted to clear writes `#EXT-X-KEY:METHOD=NONE`, because RFC 8216 applies a key tag to every later segment until the next one. +- **HLS master playlist.** `#EXT-X-SESSION-KEY` lines are the union over every clip's encryption, deduplicated by RFC 8216 identity across the whole sequence (`hls_session_keys_for_sequence`). +- **DASH.** Each Period's `ContentProtection` already came from its own clip's `Presentation`, so it needed no change. The `cenc` and `dashif` namespaces are now declared when any clip is encrypted, not only the first. +- **Versioning.** `clip::version_of` hashes each clip's own encryption fingerprint, or a "clear" marker, alongside its source and window. +- **Reload detection.** A re-resolved answer is treated as changed if any clip's window or encryption changed, even under the same `version`. Locations are still compared loosely, so a re-signed URL keeps its in-place rotation. +- **Status.** `/admin/status` reports the deduplicated key IDs of every clip. +- **Not supported.** A clip cannot opt out of the answer's own `encryption` (serde reads absent and `null` alike). Mixed sequences leave the answer-level object out instead. + +Tested by: clear-then-encrypted and encrypted-then-clear sequences (key placement, `METHOD=NONE`, per-Period protection, namespace declaration); two clips under two keys (session-key union, `/admin/status`); re-keying one clip changing the version; a malformed clip-level object rejected without echoing its key; and FFmpeg decrypting each clip of a three-clip mixed sequence with only its own key. The key-transition and session-key logic were mutation-checked. ### Signalling: DASH @@ -170,7 +184,6 @@ Additive: a mapper that sends no `encryption` gets byte-identical output, and th ## 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.