use core::fmt;
use alloc::collections::BTreeMap;
use alloc::vec::Vec;
use broadcast_common::{Decrypt, Parse};
use crate::box_types::{BOX_HEADER_MIN_SIZE, box_slices, find_top_box, parse_box};
pub use crate::cenc::CencScheme;
use crate::cenc::{SampleEncryptionEntry, TrackEncryptionBox};
use crate::cenc_crypto::{self, CbcsOp};
use crate::error::{Error, Result};
use crate::init_segment::{ChunkLargeOffsetBox, ChunkOffsetBox, SampleSizeBox, SampleToChunkBox};
use crate::media::Media;
use crate::movie_fragment::{
MovieFragmentBox, SAMPLE_FLAG_IS_NON_SYNC, TrackFragmentHeaderBox, TrackFragmentRunBox,
};
use crate::progressive_demux::sample_layout;
use crate::sample_groups::{GROUPING_TYPE_SEIG, SampleGroupDescriptionBox};
const KEY_LEN: usize = 16;
#[derive(Clone, Default, PartialEq, Eq)]
pub struct KeyMap {
keys: BTreeMap<[u8; KEY_LEN], [u8; KEY_LEN]>,
}
impl fmt::Debug for KeyMap {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("KeyMap")
.field("kids", &self.keys.keys().collect::<Vec<_>>())
.finish()
}
}
impl KeyMap {
pub fn new() -> Self {
Self {
keys: BTreeMap::new(),
}
}
pub fn with_key(mut self, kid: [u8; KEY_LEN], key: [u8; KEY_LEN]) -> Self {
self.keys.insert(kid, key);
self
}
pub fn insert(&mut self, kid: [u8; KEY_LEN], key: [u8; KEY_LEN]) {
self.keys.insert(kid, key);
}
pub fn get(&self, kid: &[u8; KEY_LEN]) -> Option<&[u8; KEY_LEN]> {
self.keys.get(kid)
}
}
#[derive(Debug, Clone)]
struct TrackCrypto {
track_id: u32,
tenc: TrackEncryptionBox,
original_format: [u8; 4],
scheme: CencScheme,
samples: Vec<SampleEncryptionEntry>,
}
#[derive(Clone)]
pub struct CencDecryptor {
file: bytes::Bytes,
tracks: Vec<TrackCrypto>,
}
impl fmt::Debug for CencDecryptor {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("CencDecryptor")
.field("file_len", &self.file.len())
.field("tracks", &self.tracks)
.finish()
}
}
impl CencDecryptor {
pub fn from_fmp4(file: &[u8]) -> Result<Self> {
Self::from_fmp4_bytes(bytes::Bytes::copy_from_slice(file))
}
pub fn from_fmp4_bytes(file: bytes::Bytes) -> Result<Self> {
let mut tracks = Vec::new();
harvest_tracks(&file, &mut tracks)?;
if tracks.is_empty() {
return Err(Error::UnexpectedBox {
expected: "a protected track (sinf/tenc + senc)",
});
}
Ok(Self { file, tracks })
}
pub fn original_format(&self) -> [u8; 4] {
self.tracks
.first()
.map(|t| t.original_format)
.unwrap_or(*b"\0\0\0\0")
}
pub fn scheme(&self) -> Option<CencScheme> {
self.tracks.first().map(|t| t.scheme)
}
pub fn track_encryption(&self) -> Option<&TrackEncryptionBox> {
self.tracks.first().map(|t| &t.tenc)
}
pub fn sample_entries(&self) -> &[SampleEncryptionEntry] {
self.tracks
.first()
.map(|t| t.samples.as_slice())
.unwrap_or(&[])
}
pub fn demux(&self) -> Result<Media> {
demux_protected(&self.file)
}
fn decrypt_sample(
scheme: CencScheme,
tenc: &TrackEncryptionBox,
entry: &SampleEncryptionEntry,
key: &[u8; KEY_LEN],
data: &mut bytes::Bytes,
) -> Result<bool> {
cenc_crypto::rewrite_in_place(data, |buf| match scheme {
CencScheme::Cenc => {
cenc_crypto::apply_ctr(&entry.initialization_vector, key, &entry.subsamples, buf)
}
CencScheme::Cbcs => cenc_crypto::cbcs_sample(tenc, entry, key, buf, CbcsOp::Decrypt),
other => Err(Error::UnsupportedCencScheme { scheme: other }),
})
}
}
impl Decrypt for CencDecryptor {
type Media = Media;
type Keys = KeyMap;
type Error = Error;
fn decrypt(&self, media: &mut Media, keys: &KeyMap) -> Result<()> {
for track in media.tracks.iter() {
let crypto = self
.tracks
.iter()
.find(|c| c.track_id == track.spec.track_id)
.ok_or(Error::InvalidInput(
"no protected-source track matches this media track's track_id",
))?;
if crypto.tenc.default_is_protected == 0 {
continue;
}
if keys.get(&crypto.tenc.default_kid).is_none() {
return Err(Error::InvalidInput(
"no content key for the track's default_KID",
));
}
if track.samples.len() != crypto.samples.len() {
return Err(Error::InvalidInput(
"sample count mismatch between media and senc",
));
}
for (sample, entry) in track.samples.iter().zip(crypto.samples.iter()) {
crate::cenc_crypto::validate_sample_decrypt(
crypto.scheme,
&crypto.tenc,
entry,
sample.data.len(),
)?;
}
}
for track in media.tracks.iter_mut() {
let crypto = self
.tracks
.iter()
.find(|c| c.track_id == track.spec.track_id)
.ok_or(Error::InvalidInput(
"no protected-source track matches this media track's track_id",
))?;
if crypto.tenc.default_is_protected == 0 {
continue;
}
let key = keys
.get(&crypto.tenc.default_kid)
.ok_or(Error::InvalidInput(
"no content key for the track's default_KID",
))?;
if track.samples.len() != crypto.samples.len() {
return Err(Error::InvalidInput(
"sample count mismatch between media and senc",
));
}
for (sample, entry) in track.samples.iter_mut().zip(crypto.samples.iter()) {
CencDecryptor::decrypt_sample(
crypto.scheme,
&crypto.tenc,
entry,
key,
&mut sample.data,
)?;
}
}
Ok(())
}
}
const FULL_HDR: usize = 4;
const STSD_ENTRY_COUNT: usize = 4;
const VISUAL_SAMPLE_ENTRY_HDR: usize = 78;
fn harvest_tracks(file: &[u8], out: &mut Vec<TrackCrypto>) -> Result<()> {
let moov = find_top_box(file, b"moov").ok_or(Error::UnexpectedBox { expected: "moov" })?;
let fragmented = find_top_box(file, b"moof").is_some();
for trak in iter_child_boxes(moov, b"trak") {
if let Some(crypto) = harvest_track(trak, fragmented)? {
out.push(crypto);
}
}
if fragmented {
harvest_fragment_senc(file, out)?;
}
Ok(())
}
fn harvest_track(trak: &[u8], fragmented: bool) -> Result<Option<TrackCrypto>> {
let Some(stbl) = descend(trak, &[b"mdia", b"minf", b"stbl"]) else {
return Ok(None);
};
let Some(stsd) = find_box(stbl, b"stsd") else {
return Ok(None);
};
let Some(sinf) = find_sinf_in_stsd(stsd) else {
return Ok(None);
};
let sinf_parsed = crate::cenc::ProtectionSchemeInfoBox::parse(sinf)?;
let scheme = sinf_parsed
.scheme_type
.as_ref()
.and_then(|s| CencScheme::from_four_cc(&s.scheme_type))
.ok_or(Error::InvalidInput(
"sinf missing or unknown schm scheme_type",
))?;
let tenc = sinf_parsed
.scheme_info
.as_ref()
.and_then(|si| si.tenc.clone())
.ok_or(Error::UnexpectedBox {
expected: "tenc inside schi",
})?;
let original_format = sinf_parsed.original_format.data_format;
if container_has_seig_sgpd(stbl)? {
return Err(Error::UnsupportedFeature(
"seig sample-group key rotation not implemented",
));
}
let tkhd = find_box(trak, b"tkhd").ok_or(Error::UnexpectedBox { expected: "tkhd" })?;
let track_id = crate::init_segment::TrackHeaderBox::parse(tkhd)?.track_id;
let samples = if fragmented {
Vec::new()
} else {
let senc = find_box(stbl, b"senc").ok_or(Error::UnexpectedBox { expected: "senc" })?;
parse_senc_box(senc, tenc.default_per_sample_iv_size)?.entries
};
Ok(Some(TrackCrypto {
track_id,
tenc,
original_format,
scheme,
samples,
}))
}
fn parse_senc_box(senc: &[u8], per_sample_iv_size: u8) -> Result<crate::cenc::SampleEncryptionBox> {
if senc.len() < BOX_HEADER_MIN_SIZE + FULL_HDR {
return Err(Error::BufferTooShort {
need: BOX_HEADER_MIN_SIZE + FULL_HDR,
have: senc.len(),
what: "senc header",
});
}
let version = senc[BOX_HEADER_MIN_SIZE];
let flags = u32::from_be_bytes([
0,
senc[BOX_HEADER_MIN_SIZE + 1],
senc[BOX_HEADER_MIN_SIZE + 2],
senc[BOX_HEADER_MIN_SIZE + 3],
]);
crate::cenc::SampleEncryptionBox::parse_body(
&senc[BOX_HEADER_MIN_SIZE + FULL_HDR..],
version,
flags,
per_sample_iv_size,
)
}
fn harvest_fragment_senc(file: &[u8], tracks: &mut [TrackCrypto]) -> Result<()> {
for moof in iter_top_boxes(file, b"moof") {
for traf in iter_child_boxes(moof, b"traf") {
let Some(tfhd) = find_box(traf, b"tfhd") else {
continue;
};
if tfhd.len() < BOX_HEADER_MIN_SIZE + FULL_HDR {
return Err(Error::BufferTooShort {
need: BOX_HEADER_MIN_SIZE + FULL_HDR,
have: tfhd.len(),
what: "tfhd header",
});
}
let tfhd_parsed = TrackFragmentHeaderBox::parse_body(&tfhd[BOX_HEADER_MIN_SIZE..])?;
let Some(crypto) = tracks
.iter_mut()
.find(|t| t.track_id == tfhd_parsed.track_id)
else {
continue;
};
if container_has_seig_sgpd(traf)? {
return Err(Error::UnsupportedFeature(
"seig sample-group key rotation not implemented",
));
}
match find_box(traf, b"senc") {
Some(senc) => {
let senc_parsed = parse_senc_box(senc, crypto.tenc.default_per_sample_iv_size)?;
crypto.samples.extend(senc_parsed.entries);
}
None if crypto.tenc.default_per_sample_iv_size == 0 => {
let sample_count = traf_trun_sample_count(traf)?;
crypto.samples.extend(
core::iter::repeat_with(|| SampleEncryptionEntry {
initialization_vector: Vec::new(),
subsamples: Vec::new(),
})
.take(sample_count),
);
}
None => {
}
}
}
}
Ok(())
}
fn traf_trun_sample_count(traf: &[u8]) -> Result<usize> {
let mut total = 0usize;
for trun in
box_slices(&traf[BOX_HEADER_MIN_SIZE.min(traf.len())..]).filter(|b| &b[4..8] == b"trun")
{
if trun.len() < BOX_HEADER_MIN_SIZE {
return Err(Error::BufferTooShort {
need: BOX_HEADER_MIN_SIZE,
have: trun.len(),
what: "trun header",
});
}
let parsed = TrackFragmentRunBox::parse_body(&trun[BOX_HEADER_MIN_SIZE..])?;
total += parsed.samples.len();
}
Ok(total)
}
fn find_sinf_in_stsd(stsd: &[u8]) -> Option<&[u8]> {
let body_start = BOX_HEADER_MIN_SIZE + FULL_HDR + STSD_ENTRY_COUNT;
if body_start > stsd.len() {
return None;
}
for entry in box_slices(&stsd[body_start..]) {
let ty = &entry[4..8];
if ty == b"encv" || ty == b"enca" {
let child_start = if ty == b"encv" {
BOX_HEADER_MIN_SIZE + VISUAL_SAMPLE_ENTRY_HDR
} else {
BOX_HEADER_MIN_SIZE + 28
};
if child_start <= entry.len() {
if let Some(sinf) = box_slices(&entry[child_start..]).find(|b| &b[4..8] == b"sinf")
{
return Some(sinf);
}
}
}
}
None
}
fn stsd_entry_fourcc(stsd: &[u8]) -> &'static str {
let body_start = BOX_HEADER_MIN_SIZE + FULL_HDR + STSD_ENTRY_COUNT;
let Some(first) = stsd.get(body_start..body_start + BOX_HEADER_MIN_SIZE) else {
return "unknown";
};
match &first[4..8] {
b"avc1" | b"encv" => "avc1",
b"mp4a" | b"enca" => "mp4a",
b"hvc1" | b"hev1" => "hvc1",
b"vp09" => "vp09",
b"av01" => "av01",
b"ac-3" => "ac-3",
b"ec-3" => "ec-3",
b"stpp" => "stpp",
b"wvtt" => "wvtt",
_ => "unknown",
}
}
fn demux_protected(file: &bytes::Bytes) -> Result<Media> {
use crate::AVCConfigurationBox;
use crate::media::{Media, Track};
use crate::pipeline::{CodecConfig, Sample, TrackSpec};
let moov = find_top_box(file, b"moov").ok_or(Error::UnexpectedBox { expected: "moov" })?;
let movie_timescale = mvhd_timescale(moov).unwrap_or(1000);
let fragmented = find_top_box(file, b"moof").is_some();
let mut tracks = Vec::new();
let mut skipped: Vec<crate::ir::SkippedTrack> = Vec::new();
for trak in iter_child_boxes(moov, b"trak") {
let Some(stbl) = descend(trak, &[b"mdia", b"minf", b"stbl"]) else {
continue;
};
let timescale = descend(trak, &[b"mdia"])
.and_then(|mdia| find_box(mdia, b"mdhd"))
.and_then(mdhd_timescale)
.unwrap_or(movie_timescale);
let Some(stsd) = find_box(stbl, b"stsd") else {
continue;
};
let Some(sinf) = find_sinf_in_stsd(stsd) else {
skipped.push(crate::ir::SkippedTrack::new(
stsd_entry_fourcc(stsd).to_owned(),
"unprotected track: CencDecryptor::demux reconstructs protected tracks only"
.to_owned(),
));
continue;
};
let sinf_parsed = crate::cenc::ProtectionSchemeInfoBox::parse(sinf)?;
if &sinf_parsed.original_format.data_format != b"avc1" {
let fourcc = core::str::from_utf8(&sinf_parsed.original_format.data_format)
.unwrap_or("unknown")
.to_owned();
skipped.push(crate::ir::SkippedTrack::new(
fourcc,
"protected track whose original format is not avc1: only protected AVC demux is supported"
.to_owned(),
));
continue;
}
let avc_config = find_avcc_config(stsd)?;
let tkhd = find_box(trak, b"tkhd").ok_or(Error::UnexpectedBox { expected: "tkhd" })?;
let track_id = crate::init_segment::TrackHeaderBox::parse(tkhd)?.track_id;
let samples = if fragmented {
collect_fragment_samples(file, track_id)?
} else {
let layout = progressive_sample_layout(stbl, file.len())?;
let mut samples = Vec::with_capacity(layout.len());
for &(offset, size) in &layout {
let end = offset
.checked_add(size)
.ok_or(Error::InvalidInput("sample offset + size overflow"))?;
if end > file.len() {
return Err(Error::BufferTooShort {
need: end,
have: file.len(),
what: "protected sample data",
});
}
samples.push(Sample {
data: file.slice(offset..end),
dts: None,
pts: None,
duration: None,
flags: crate::ir::SampleFlags::SYNC,
provenance: None,
});
}
samples
};
tracks.push(Track::new(
TrackSpec::new(
track_id,
timescale,
CodecConfig::Avc {
config: AVCConfigurationBox::new(avc_config),
width: 0,
height: 0,
},
),
samples,
));
}
if tracks.is_empty() {
return Err(Error::UnexpectedBox {
expected: "a protected AVC track",
});
}
let mut media = Media::new(tracks, movie_timescale);
media.skipped = skipped;
Ok(media)
}
fn collect_fragment_samples(
file: &bytes::Bytes,
target_track_id: u32,
) -> Result<Vec<crate::pipeline::Sample>> {
let mut out = Vec::new();
let mut offset = 0usize;
let mdats = crate::frag_offsets::mdat_ranges(file);
let mut pending_moof: Option<(usize, MovieFragmentBox)> = None;
let mut next_dts: i64 = 0;
let mut seeded = false;
while offset + BOX_HEADER_MIN_SIZE <= file.len() {
let (bx, consumed) = parse_box(&file[offset..])?;
if bx.header.box_type.is(b"moof") {
let moof = MovieFragmentBox::parse_body(bx.body)?;
pending_moof = Some((offset, moof));
} else if bx.header.box_type.is(b"mdat")
&& let Some((moof_off, moof)) = pending_moof.take()
{
if !seeded {
if let Some(tfdt) = moof
.traf
.iter()
.find(|t| t.tfhd.track_id == target_track_id)
.and_then(|t| t.tfdt.as_ref())
{
next_dts = tfdt.base_media_decode_time() as i64;
}
seeded = true;
}
absorb_protected_fragment(
file,
&mdats,
moof_off,
&moof,
target_track_id,
&mut next_dts,
&mut out,
)?;
}
if consumed == 0 {
break;
}
offset += consumed;
}
Ok(out)
}
fn absorb_protected_fragment(
file: &bytes::Bytes,
mdats: &[crate::frag_offsets::MdatRange],
moof_off: usize,
moof: &MovieFragmentBox,
target_track_id: u32,
next_dts: &mut i64,
out: &mut Vec<crate::pipeline::Sample>,
) -> Result<()> {
use crate::pipeline::Sample;
for r in crate::frag_offsets::sample_ranges_in(file, mdats, moof_off, moof, target_track_id)? {
let dts = *next_dts;
let pts = crate::frag_offsets::add_offset(dts, r.composition_offset)?;
let is_sync = r.flags & SAMPLE_FLAG_IS_NON_SYNC == 0;
out.push(Sample {
data: file.slice(r.start..r.end),
dts: Some(dts),
pts: Some(pts),
duration: Some(r.duration),
flags: crate::ir::SampleFlags::new(is_sync),
provenance: None,
});
*next_dts = crate::frag_offsets::add_duration(dts, r.duration)?;
}
Ok(())
}
fn find_avcc_config(stsd: &[u8]) -> Result<crate::avc_config::AVCDecoderConfigurationRecord> {
let body_start = BOX_HEADER_MIN_SIZE + FULL_HDR + STSD_ENTRY_COUNT;
for entry in box_slices(&stsd[body_start.min(stsd.len())..]) {
if &entry[4..8] == b"encv" {
let child_start = BOX_HEADER_MIN_SIZE + VISUAL_SAMPLE_ENTRY_HDR;
if child_start <= entry.len()
&& let Some(avcc) = box_slices(&entry[child_start..]).find(|b| &b[4..8] == b"avcC")
{
let cfg = crate::AVCConfigurationBox::parse_body(&avcc[BOX_HEADER_MIN_SIZE..])?;
return Ok(cfg.config);
}
}
}
Err(Error::UnexpectedBox {
expected: "avcC inside encv",
})
}
fn iter_child_boxes<'a>(
container: &'a [u8],
fourcc: &'a [u8; 4],
) -> impl Iterator<Item = &'a [u8]> {
let body = &container[BOX_HEADER_MIN_SIZE.min(container.len())..];
box_slices(body).filter(move |b| &b[4..8] == fourcc)
}
fn iter_top_boxes<'a>(file: &'a [u8], fourcc: &[u8; 4]) -> impl Iterator<Item = &'a [u8]> {
box_slices(file).filter(move |b| b[4..8] == *fourcc)
}
fn find_box<'a>(container: &'a [u8], fourcc: &[u8; 4]) -> Option<&'a [u8]> {
let body = &container[BOX_HEADER_MIN_SIZE.min(container.len())..];
box_slices(body).find(|b| &b[4..8] == fourcc)
}
fn container_has_seig_sgpd(container: &[u8]) -> Result<bool> {
let body = &container[BOX_HEADER_MIN_SIZE.min(container.len())..];
for entry in box_slices(body).filter(|b| &b[4..8] == b"sgpd") {
let sgpd = SampleGroupDescriptionBox::parse(entry)?;
if sgpd.grouping_type == GROUPING_TYPE_SEIG {
return Ok(true);
}
}
Ok(false)
}
fn descend<'a>(start: &'a [u8], path: &[&[u8; 4]]) -> Option<&'a [u8]> {
let mut cur = start;
for fourcc in path {
cur = find_box(cur, fourcc)?;
}
Some(cur)
}
fn mvhd_timescale(moov: &[u8]) -> Option<u32> {
let mvhd = find_box(moov, b"mvhd")?;
let version = mvhd.get(BOX_HEADER_MIN_SIZE)?;
let ts_off = if *version == 1 {
BOX_HEADER_MIN_SIZE + FULL_HDR + 16
} else {
BOX_HEADER_MIN_SIZE + FULL_HDR + 8
};
Some(u32::from_be_bytes([
*mvhd.get(ts_off)?,
*mvhd.get(ts_off + 1)?,
*mvhd.get(ts_off + 2)?,
*mvhd.get(ts_off + 3)?,
]))
}
fn mdhd_timescale(mdhd: &[u8]) -> Option<u32> {
let version = mdhd.get(BOX_HEADER_MIN_SIZE)?;
let ts_off = if *version == 1 {
BOX_HEADER_MIN_SIZE + FULL_HDR + 16
} else {
BOX_HEADER_MIN_SIZE + FULL_HDR + 8
};
Some(u32::from_be_bytes([
*mdhd.get(ts_off)?,
*mdhd.get(ts_off + 1)?,
*mdhd.get(ts_off + 2)?,
*mdhd.get(ts_off + 3)?,
]))
}
fn progressive_sample_layout(stbl: &[u8], file_len: usize) -> Result<Vec<(usize, usize)>> {
let stsz = SampleSizeBox::parse(
find_box(stbl, b"stsz").ok_or(Error::UnexpectedBox { expected: "stsz" })?,
)?;
let stsc = SampleToChunkBox::parse(
find_box(stbl, b"stsc").ok_or(Error::UnexpectedBox { expected: "stsc" })?,
)?;
let stco = find_box(stbl, b"stco")
.map(ChunkOffsetBox::parse)
.transpose()?;
let co64 = find_box(stbl, b"co64")
.map(ChunkLargeOffsetBox::parse)
.transpose()?;
sample_layout(&stsz, &stsc, co64.as_ref(), stco.as_ref(), file_len)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::cenc_crypto;
fn full_box(fourcc: &[u8; 4], entries: &[u8], count: u32) -> Vec<u8> {
let mut b = Vec::new();
let len = (BOX_HEADER_MIN_SIZE + FULL_HDR + 4 + entries.len()) as u32;
b.extend_from_slice(&len.to_be_bytes());
b.extend_from_slice(fourcc);
b.extend_from_slice(&[0; FULL_HDR]);
b.extend_from_slice(&count.to_be_bytes());
b.extend_from_slice(entries);
b
}
fn stbl_with(boxes: &[Vec<u8>]) -> Vec<u8> {
let mut stbl = vec![0u8; BOX_HEADER_MIN_SIZE];
for b in boxes {
stbl.extend_from_slice(b);
}
stbl
}
fn stsz_box(size: u32, count: u32) -> Vec<u8> {
let mut b = Vec::new();
b.extend_from_slice(&((BOX_HEADER_MIN_SIZE + FULL_HDR + 8) as u32).to_be_bytes());
b.extend_from_slice(b"stsz");
b.extend_from_slice(&[0; FULL_HDR]);
b.extend_from_slice(&size.to_be_bytes());
b.extend_from_slice(&count.to_be_bytes());
b
}
#[test]
fn stbl_layout_maps_one_sample_per_chunk_through_stco() {
const CHUNKS: u32 = 400;
let mut stsc = Vec::new();
let mut stco = Vec::new();
for c in 1..=CHUNKS {
stsc.extend_from_slice(&c.to_be_bytes()); stsc.extend_from_slice(&1u32.to_be_bytes()); stsc.extend_from_slice(&1u32.to_be_bytes()); stco.extend_from_slice(&(c * 100).to_be_bytes());
}
let stbl = stbl_with(&[
stsz_box(10, CHUNKS),
full_box(b"stsc", &stsc, CHUNKS),
full_box(b"stco", &stco, CHUNKS),
]);
let layout = progressive_sample_layout(&stbl, 1 << 20).unwrap();
assert_eq!(layout.len(), CHUNKS as usize);
assert_eq!(layout[0], (100, 10));
assert_eq!(layout[CHUNKS as usize - 1], (CHUNKS as usize * 100, 10));
}
#[test]
fn stbl_layout_resolves_co64_chunk_offsets() {
let mut stsc = Vec::new();
stsc.extend_from_slice(&1u32.to_be_bytes());
stsc.extend_from_slice(&2u32.to_be_bytes()); stsc.extend_from_slice(&1u32.to_be_bytes());
let co64 = 0x1_0000u64.to_be_bytes();
let stbl = stbl_with(&[
stsz_box(7, 2),
full_box(b"stsc", &stsc, 1),
full_box(b"co64", &co64, 1),
]);
let layout = progressive_sample_layout(&stbl, 1 << 20).unwrap();
assert_eq!(layout, vec![(0x1_0000, 7), (0x1_0007, 7)]);
}
#[test]
fn stbl_layout_rejects_sample_count_above_file_length() {
let stbl = stbl_with(&[
stsz_box(1, u32::MAX),
full_box(b"stsc", &[], 0),
full_box(b"stco", &[], 0),
]);
assert!(progressive_sample_layout(&stbl, 64).is_err());
}
use crate::media::Track;
use crate::pipeline::{CodecConfig, Sample, TrackSpec};
use broadcast_common::Unpackage;
const VIDEO_TRACK_ID: u32 = 1;
const AUDIO_TRACK_ID: u32 = 2;
const KID: [u8; KEY_LEN] = [0xAA; KEY_LEN];
const KEY: [u8; KEY_LEN] = [
0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0A, 0x0B, 0x0C, 0x0D, 0x0E, 0x0F,
0x10,
];
const VIDEO_IV: [u8; 8] = [0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x11];
const AUDIO_IV: [u8; 8] = [0x22, 0x22, 0x22, 0x22, 0x22, 0x22, 0x22, 0x22];
const SAMPLES_PER_TRACK: usize = 2;
fn tenc() -> TrackEncryptionBox {
TrackEncryptionBox {
version: 0,
default_crypt_byte_block: 0,
default_skip_byte_block: 0,
default_is_protected: 1,
default_per_sample_iv_size: 8,
default_kid: KID,
default_constant_iv: None,
}
}
fn crypto(track_id: u32, iv: &[u8; 8]) -> TrackCrypto {
TrackCrypto {
track_id,
tenc: tenc(),
original_format: *b"avc1",
scheme: CencScheme::Cenc,
samples: (0..SAMPLES_PER_TRACK)
.map(|_| SampleEncryptionEntry {
initialization_vector: iv.to_vec(),
subsamples: Vec::new(),
})
.collect(),
}
}
fn decryptor() -> CencDecryptor {
CencDecryptor {
file: bytes::Bytes::new(),
tracks: alloc::vec![
crypto(VIDEO_TRACK_ID, &VIDEO_IV),
crypto(AUDIO_TRACK_ID, &AUDIO_IV),
],
}
}
fn test_codec_config() -> CodecConfig {
CodecConfig::Opus {
config: crate::opus::OpusSpecificBox {
version: 0,
output_channel_count: 2,
pre_skip: 0,
input_sample_rate: 48_000,
output_gain: 0,
channel_mapping_family: 0,
channel_mapping: None,
},
channel_count: 2,
sample_rate: 48_000,
sample_size: 16,
}
}
fn plaintext(i: usize) -> Vec<u8> {
(0u8..64).map(|b| b.wrapping_add(i as u8 * 7)).collect()
}
fn encrypted_media(track_id: u32, iv: &[u8; 8]) -> Media {
let samples = (0..SAMPLES_PER_TRACK)
.map(|i| {
let mut buf = plaintext(i);
cenc_crypto::apply_ctr(iv, &KEY, &[], &mut buf).expect("encrypt");
Sample {
data: buf.into(),
dts: None,
pts: None,
duration: None,
flags: crate::ir::SampleFlags::SYNC,
provenance: None,
}
})
.collect();
Media::new(
alloc::vec![Track::new(
TrackSpec::new(track_id, 90_000, test_codec_config()),
samples,
)],
90_000,
)
}
#[test]
fn narrowed_media_decrypts_with_its_own_tracks_ivs() {
let dec = decryptor();
let keys = KeyMap::new().with_key(KID, KEY);
let mut media = encrypted_media(AUDIO_TRACK_ID, &AUDIO_IV);
dec.decrypt(&mut media, &keys).expect("decrypt");
for (i, sample) in media.tracks[0].samples.iter().enumerate() {
assert_eq!(
&sample.data[..],
&plaintext(i)[..],
"sample {i} must be decrypted with track {AUDIO_TRACK_ID}'s IV, not \
whichever record happens to sit at the same position"
);
}
}
#[test]
fn first_track_still_decrypts_with_its_own_ivs() {
let dec = decryptor();
let keys = KeyMap::new().with_key(KID, KEY);
let mut media = encrypted_media(VIDEO_TRACK_ID, &VIDEO_IV);
dec.decrypt(&mut media, &keys).expect("decrypt");
for (i, sample) in media.tracks[0].samples.iter().enumerate() {
assert_eq!(&sample.data[..], &plaintext(i)[..]);
}
}
fn two_track_media_second_bad() -> Media {
let video = encrypted_media(VIDEO_TRACK_ID, &VIDEO_IV);
let audio = encrypted_media(AUDIO_TRACK_ID, &AUDIO_IV);
Media::new(
alloc::vec![video.tracks[0].clone(), audio.tracks[0].clone()],
90_000,
)
}
fn decryptor_second_track_count_mismatch() -> CencDecryptor {
let mut second = crypto(AUDIO_TRACK_ID, &AUDIO_IV);
second.samples.pop();
CencDecryptor {
file: bytes::Bytes::new(),
tracks: alloc::vec![crypto(VIDEO_TRACK_ID, &VIDEO_IV), second],
}
}
#[test]
fn decrypt_is_atomic_across_tracks() {
let dec = decryptor_second_track_count_mismatch();
let keys = KeyMap::new().with_key(KID, KEY);
let mut media = two_track_media_second_bad();
let before: Vec<Vec<u8>> = media
.tracks
.iter()
.flat_map(|t| t.samples.iter().map(|s| s.data.to_vec()))
.collect();
assert_ne!(&media.tracks[0].samples[0].data[..], &plaintext(0)[..]);
let err = dec.decrypt(&mut media, &keys).unwrap_err();
assert!(matches!(err, Error::InvalidInput(_)), "got {err:?}");
let after: Vec<Vec<u8>> = media
.tracks
.iter()
.flat_map(|t| t.samples.iter().map(|s| s.data.to_vec()))
.collect();
assert_eq!(
after, before,
"a rejected decrypt must leave every sample byte-identical"
);
}
#[test]
fn decrypt_is_atomic_on_bad_subsample_map_track2() {
let mut second = crypto(AUDIO_TRACK_ID, &AUDIO_IV);
second.samples[0].subsamples = alloc::vec![crate::cenc::SubSampleEntry {
bytes_of_clear_data: 0,
bytes_of_protected_data: 100,
}];
let dec = CencDecryptor {
file: bytes::Bytes::new(),
tracks: alloc::vec![crypto(VIDEO_TRACK_ID, &VIDEO_IV), second],
};
let mut media = two_track_media_second_bad();
let before: Vec<Vec<u8>> = media
.tracks
.iter()
.flat_map(|t| t.samples.iter().map(|s| s.data.to_vec()))
.collect();
let err = dec
.decrypt(&mut media, &KeyMap::new().with_key(KID, KEY))
.unwrap_err();
assert!(
matches!(err, Error::InvalidInput(_) | Error::BufferTooShort { .. }),
"got {err:?}"
);
let after: Vec<Vec<u8>> = media
.tracks
.iter()
.flat_map(|t| t.samples.iter().map(|s| s.data.to_vec()))
.collect();
assert_eq!(
after, before,
"media must be byte-identical after a rejection"
);
}
#[test]
fn decrypt_is_atomic_for_cbcs() {
let mut t = tenc();
t.default_per_sample_iv_size = 16;
t.default_crypt_byte_block = 1;
t.default_skip_byte_block = 9;
let mk = |id: u32, iv: u8, count: usize| TrackCrypto {
track_id: id,
tenc: t.clone(),
original_format: *b"avc1",
scheme: CencScheme::Cbcs,
samples: (0..count)
.map(|_| SampleEncryptionEntry {
initialization_vector: alloc::vec![iv; 16],
subsamples: Vec::new(),
})
.collect(),
};
let dec = CencDecryptor {
file: bytes::Bytes::new(),
tracks: alloc::vec![
mk(VIDEO_TRACK_ID, 0x11, SAMPLES_PER_TRACK),
mk(AUDIO_TRACK_ID, 0x22, SAMPLES_PER_TRACK - 1),
],
};
let mut media = two_track_media_second_bad();
let before: Vec<Vec<u8>> = media
.tracks
.iter()
.flat_map(|t| t.samples.iter().map(|s| s.data.to_vec()))
.collect();
let err = dec
.decrypt(&mut media, &KeyMap::new().with_key(KID, KEY))
.unwrap_err();
assert!(matches!(err, Error::InvalidInput(_)), "got {err:?}");
let after: Vec<Vec<u8>> = media
.tracks
.iter()
.flat_map(|t| t.samples.iter().map(|s| s.data.to_vec()))
.collect();
assert_eq!(
after, before,
"a rejected cbcs decrypt must leave media byte-identical"
);
}
#[test]
fn decrypt_is_atomic_when_a_key_is_missing() {
let mut second = crypto(AUDIO_TRACK_ID, &AUDIO_IV);
second.tenc.default_kid = [0xBB; KEY_LEN];
let dec = CencDecryptor {
file: bytes::Bytes::new(),
tracks: alloc::vec![crypto(VIDEO_TRACK_ID, &VIDEO_IV), second],
};
let mut media = two_track_media_second_bad();
let before: Vec<Vec<u8>> = media
.tracks
.iter()
.flat_map(|t| t.samples.iter().map(|s| s.data.to_vec()))
.collect();
let err = dec
.decrypt(&mut media, &KeyMap::new().with_key(KID, KEY))
.unwrap_err();
assert!(matches!(err, Error::InvalidInput(_)), "got {err:?}");
let after: Vec<Vec<u8>> = media
.tracks
.iter()
.flat_map(|t| t.samples.iter().map(|s| s.data.to_vec()))
.collect();
assert_eq!(
after, before,
"media must be byte-identical after a rejection"
);
}
#[test]
fn unknown_track_id_errors() {
let dec = decryptor();
let keys = KeyMap::new().with_key(KID, KEY);
let mut media = encrypted_media(99, &AUDIO_IV);
let err = dec.decrypt(&mut media, &keys).unwrap_err();
assert!(matches!(err, Error::InvalidInput(_)), "got {err:?}");
}
#[test]
fn keymap_debug_redacts_key_bytes_but_shows_kids() {
let km = KeyMap::new().with_key(KID, KEY);
let out = alloc::format!("{km:?}");
assert!(
!out.contains(&alloc::format!("{KEY:?}")),
"Debug output must not contain the key's array representation: {out}"
);
assert!(
out.contains(&alloc::format!("{KID:?}")),
"KIDs are not secret and should still be visible: {out}"
);
}
#[test]
fn decryptor_debug_does_not_dump_raw_file_bytes() {
let mut small = decryptor();
small.file = bytes::Bytes::from_static(&[0x42u8; 10]);
let mut large = decryptor();
large.file = bytes::Bytes::from(alloc::vec![0x42u8; 10_000]);
let small_out = alloc::format!("{small:?}");
let large_out = alloc::format!("{large:?}");
assert!(!small_out.contains(&alloc::format!("{KEY:?}")));
assert!(!large_out.contains(&alloc::format!("{KEY:?}")));
let diff = large_out.len().abs_diff(small_out.len());
assert!(
diff < 20,
"Debug output must not scale with file size (it should print file_len, not the \
bytes): small={small_out} ({} chars), large={large_out} ({} chars)",
small_out.len(),
large_out.len()
);
assert!(large_out.contains("file_len"));
}
#[test]
fn all_layouts_decrypt_audio_to_clear() {
let dir = concat!(
env!("CARGO_MANIFEST_DIR"),
"/tests/fixtures/cenc_frag_layouts"
);
let read = |name: &str| {
std::fs::read(alloc::format!("{dir}/{name}"))
.unwrap_or_else(|e| panic!("read {dir}/{name}: {e}"))
};
let clear_bytes = read("clear.mp4");
let mut fd = crate::media::Fmp4Demux::new();
let clear_media = fd.unpackage(clear_bytes.as_slice()).expect("demux clear");
let clear_audio: Vec<Vec<u8>> = clear_media
.tracks
.iter()
.find(|t| t.spec.track_id == 2)
.expect("clear.mp4 must carry track 2 (audio)")
.samples
.iter()
.map(|s| s.data.to_vec())
.collect();
assert_eq!(clear_audio.len(), 95, "clear audio has 95 samples");
let files = [
"enc_default_none.mp4",
"enc_default_explicit.mp4",
"enc_default_implicit.mp4",
"enc_base_moof_none.mp4",
"enc_base_moof_explicit.mp4",
"enc_base_moof_implicit.mp4",
"enc_omit_none.mp4",
"enc_omit_explicit.mp4",
"enc_omit_implicit.mp4",
];
const FKID: [u8; KEY_LEN] = [
0x01, 0x23, 0x45, 0x67, 0x89, 0xab, 0xcd, 0xef, 0x01, 0x23, 0x45, 0x67, 0x89, 0xab,
0xcd, 0xef,
];
const FKEY: [u8; KEY_LEN] = [
0x00, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, 0x99, 0xaa, 0xbb, 0xcc, 0xdd,
0xee, 0xff,
];
let keys = KeyMap::new().with_key(FKID, FKEY);
for f in files {
let bytes = read(f);
let cipher = collect_fragment_samples(&bytes::Bytes::from(bytes.clone()), 2)
.unwrap_or_else(|e| panic!("{f}: collect audio: {e}"));
assert_eq!(
cipher.len(),
clear_audio.len(),
"{f}: audio sample count must match the clear reference"
);
let dec = CencDecryptor::from_fmp4(&bytes).unwrap_or_else(|e| panic!("{f}: {e}"));
let crypto = dec
.tracks
.iter()
.find(|c| c.track_id == 2)
.unwrap_or_else(|| panic!("{f}: no audio crypto record"));
let key = keys
.get(&crypto.tenc.default_kid)
.unwrap_or_else(|| panic!("{f}: no key"));
assert_eq!(crypto.samples.len(), cipher.len(), "{f}: senc count");
for (i, (c, entry)) in cipher.iter().zip(crypto.samples.iter()).enumerate() {
let mut buf = c.data.to_vec();
crate::cenc_crypto::apply_ctr(
&entry.initialization_vector,
key,
&entry.subsamples,
&mut buf,
)
.unwrap_or_else(|e| panic!("{f}: audio sample {i}: {e}"));
assert_eq!(
buf, clear_audio[i],
"{f}: audio sample {i} must equal the clear plaintext byte-for-byte"
);
}
}
}
}