Deep dive
How it works
In: one sample, a 16-byte key named by a KID, an IV, the subsample map [(clear u16, protected u32)] · Out: a same-length sample; the inverse call restores it · State: per track the tenc defaults (scheme, IV size, pattern); inside a sample a CBC chain or CTR counter · Cost: O(protected bytes); cbcs 1:9 touches 1 block in 10, cenc every byte
Layout (boxes per DASH-IF IOP v5 Part 6 and the W3C MP4 stream format):
- Init segment: the sample entry becomes
encv/enca; itssinfholdsfrma(original codec),schm(cencorcbcs) andschi/tenc: isProtected, Per_Sample_IV_Size (0, 8 or 16),default_KID, the constant IV when the size is 0, and in version 1 the pattern. - Each fragment:
traf/senclists per sample an IV and, with flag 0x2, subsample entries;saiz/saiogive sizes and offset. Key rotation overrides the KID through aseigsample group. - Per DRM system one
psshbox: SystemID plus opaque data. DASH-IF lists the SystemIDs (Widevine, PlayReady, FairPlay, the common1077efec-c0b2-4d02-ace3-3c1e52e2fb4b). W3C'scencinit data is concatenatedpsshboxes; the player takes the first its system supports. - Subsamples: for length-prefixed AVC/HEVC the clear part is length prefix, NAL header and slice header; the rest of a VCL NAL is protected, other NALs stay clear. Audio is one protected range.
cenc: AES-CTR, counter block = IV || 64-bit block counter, consumed by protected bytes only; any byte count works.cbcs: AES-CBC over 16-byte blocks in a crypt:skip pattern (default 1:9; 5:5 and 10:0 occur), restarted at each protected range; the chain restarts from the IV in every subsample and covers encrypted blocks only; a final partial block stays clear.cbc1is CBC without pattern,censCTR with it. GPAC uses skip 0 for non-NAL tracks.- Player: read
tenc,senc, KID; givepsshto the DRM system; pass samples, IV and map to its decryptor.
Every system implements the same sample transform and differs only in pssh data and license exchange. A system supporting only cbcs cannot play cenc and vice versa, so services pick cbcs or package both.
Code
Std only. AES is not implemented here (use a vetted library); the block cipher is a trait and Toy is a stand-in that only tests the plumbing, never a cipher.
pub trait Block { fn enc(&self, b: [u8; 16]) -> [u8; 16]; fn dec(&self, b: [u8; 16]) -> [u8; 16]; }
/// STAND-IN, NOT A CIPHER: XOR with a key block, then rotate one byte. Invertible, nothing more.
pub struct Toy(pub [u8; 16]);
impl Block for Toy {
fn enc(&self, b: [u8; 16]) -> [u8; 16] { let mut o = x16(b, self.0); o.rotate_left(1); o }
fn dec(&self, mut b: [u8; 16]) -> [u8; 16] { b.rotate_right(1); x16(b, self.0) }
}
fn x16(mut a: [u8; 16], b: [u8; 16]) -> [u8; 16] { a.iter_mut().zip(b).for_each(|(x, y)| *x ^= y); a }
#[derive(Debug, PartialEq, Clone, Copy)]
pub struct Sub { pub clear: u16, pub prot: u32 }
/// cbcs: per protected range, `crypt` encrypted then `skip` clear 16-byte blocks; the CBC chain restarts
/// from the constant IV in every subsample and runs over encrypted blocks only; a partial tail stays clear.
pub fn cbcs<C: Block>(c: &C, iv: [u8; 16], (crypt, skip): (usize, usize), subs: &[Sub], buf: &mut [u8], decrypt: bool) {
let mut at = 0;
for s in subs {
at += s.clear as usize; // length prefix, NAL header, slice header: never touched
let (end, mut chain, mut n) = (at + s.prot as usize, iv, 0);
while at + 16 <= end {
if n % (crypt + skip) < crypt {
let blk: [u8; 16] = buf[at..at + 16].try_into().unwrap();
let out = if decrypt { x16(c.dec(blk), chain) } else { c.enc(x16(blk, chain)) };
chain = if decrypt { blk } else { out };
buf[at..at + 16].copy_from_slice(&out);
}
at += 16; n += 1;
}
at = end;
}
}
/// cenc: CTR over protected bytes only; clear bytes do not consume keystream.
/// Counter block = 8-byte IV || 64-bit block counter. Encrypt = decrypt.
pub fn cenc<C: Block>(c: &C, iv: [u8; 8], subs: &[Sub], buf: &mut [u8]) {
let (mut at, mut pos, mut ctr, mut ks) = (0, 16, 0u64, [0u8; 16]);
for s in subs {
at += s.clear as usize;
for b in &mut buf[at..at + s.prot as usize] {
if pos == 16 {
ks = c.enc([&iv[..], &ctr.to_be_bytes()].concat().try_into().unwrap());
(ctr, pos) = (ctr + 1, 0);
}
*b ^= ks[pos]; pos += 1;
}
at += s.prot as usize;
}
}
/// `senc` parser; `iv_size` (0, 8, 16) comes from `tenc`; flag 0x2 = subsample maps.
pub fn parse_senc(b: &[u8], iv_size: usize) -> Option<Vec<(Vec<u8>, Vec<Sub>)>> {
let u32at = |o: usize| b.get(o..o + 4).map(|x| u32::from_be_bytes(x.try_into().unwrap()));
let u16at = |o: usize| b.get(o..o + 2).map(|x| u16::from_be_bytes(x.try_into().unwrap()));
if b.get(4..8)? != b"senc" { return None; }
let (flags, count, mut o) = (u32at(8)? & 0xFF_FFFF, u32at(12)?, 16);
let mut out = vec![];
for _ in 0..count {
let iv = b.get(o..o + iv_size)?.to_vec();
o += iv_size;
let mut subs = vec![];
if flags & 2 != 0 {
let n = u16at(o)?;
o += 2;
for _ in 0..n { subs.push(Sub { clear: u16at(o)?, prot: u32at(o + 2)? }); o += 6; }
}
out.push((iv, subs));
}
Some(out)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn clear_stays_clear_and_roundtrips() {
let plain: Vec<u8> = (0..400u32).map(|i| (i * 7 + 3) as u8).collect();
let (c, iv) = (Toy([0x5A; 16]), [9u8; 16]);
let subs = [Sub { clear: 5, prot: 327 }]; // 5 clear, 20 blocks, 7-byte tail
let mut x = plain.clone();
cbcs(&c, iv, (1, 9), &subs, &mut x, false);
let d: Vec<usize> = (0..400).filter(|&i| x[i] != plain[i]).collect();
assert!(d.len() > 20 && d.iter().all(|&i| (5..21).contains(&i) || (165..181).contains(&i))); // blocks 0, 10
cbcs(&c, iv, (1, 9), &subs, &mut x, true);
assert_eq!(x, plain);
let subs = [Sub { clear: 40, prot: 100 }, Sub { clear: 13, prot: 247 }];
cenc(&c, [1; 8], &subs, &mut x);
assert!(x[..40] == plain[..40] && x[140..153] == plain[140..153] && x[40..140] != plain[40..140]);
cenc(&c, [1; 8], &subs, &mut x);
assert_eq!(x, plain);
}
#[test]
fn senc() {
// size, 'senc', version 0 + flags 2, 1 sample, 8-byte IV, 2 subsamples
let mut b = vec![0, 0, 0, 38, b's', b'e', b'n', b'c', 0, 0, 0, 2, 0, 0, 0, 1];
b.extend([1, 2, 3, 4, 5, 6, 7, 8, 0, 2, 0, 40, 0, 0, 0, 100, 0, 13, 0, 0, 0, 247]);
let s = parse_senc(&b, 8).unwrap();
assert!(s[0].0 == [1, 2, 3, 4, 5, 6, 7, 8] && s[0].1 == [Sub { clear: 40, prot: 100 }, Sub { clear: 13, prot: 247 }]);
}
}
cargo test passed: in a 400-byte sample cbcs 1:9 changes only blocks 0 and 10 of the protected range (head, skipped blocks and tail stay clear) and decrypt restores it; cenc keeps both clear ranges and inverts itself; a hand-built senc parses.
Complexity and constants
- One pass, O(protected bytes): one cipher call per 16-byte block, none for clear or skipped bytes.
sencper sample: IV size + 2 B + 6 B per subsample. - Measured on this Mac (Apple M4 Pro),
openssl speed -evp, OpenSSL 4.0.3, 16 KiB blocks, 3 s, one thread: AES-128-CTR 16.4 GB/s, AES-128-CBC encrypt 2.7 GB/s. CBC encrypt is a serial chain; CBC decrypt (player side) is not, butspeeddoes not measure it.
Efficiency
- Cost: per sample one AES-128 pass over protected bytes, no tag, no size change. Bound: AES throughput at the packager, the secure decode path at the player. Arithmetic on the measured numbers, not measured end to end: cbcs 1:9 needs the CBC rate on a tenth of the bytes, so 2.7 GB/s covers about 27 GB/s of video, above full-sample CTR at 16.4 GB/s.
- Levers:
- Pattern 1:9 (
cbcs) — cipher work on about 10 % of video blocks (pattern defaults in GPAC and Shaka docs) — video tracks. - One KID per adaptation set — one license per quality group, not per bitrate (DASH-IF) — ABR ladders.
- Clear lead — first segments unencrypted so playback starts before the license arrives (Shaka
--clear_lead) — start-up time. - Constant IV (
cbcs) — no 8-16 B IV per sample insenc— high frame rates, small chunks.
- Pattern 1:9 (
- Naive → standard → state of the art:
- Per-system encrypted copies · storage and CDN cache multiplied by systems.
- One
cenccopy, onepsshper system · one copy per scheme. - One
cbcsCMAF copy for HLS and DASH,psshin the manifest · one copy; addcenconly for old devices (DASH-IF).
Implementation notes
- Check every map: clear + protected must sum to the sample size.
sencneeds the IV size fromtenc(mp4-atom returns it as raw bytes): read the init segment first.- KID bytes in
tencand in thedefault_KIDstring have the same order; no Microsoft GUID byte swap (DASH-IF).
Pitfalls
- Reusing a (key, IV) pair in
cenc: the keystream repeats and two samples XOR to their plaintexts' XOR. - One key for both the
cencandcbcscopy of a title: DASH-IF advises different keys. - Encrypting the partial last block in
cbcs, or resetting the chain per sample instead of per subsample: garbage after the first subsample. - Encrypting slice headers (old
cencAVC behaviour, GPACencryptSliceHeader) breaks parsers. - Mixing schemes in one adaptation set (DASH-IF forbids).
Variants and alternatives
In production
- Shaka Packager, Bento4 and GPAC write it; browsers pass
psshto EME as init data typecenc; HLS requirescbcsfor fMP4.
Implementations
| name | lang | repo | install | verdict | licence | health | as_of | notes |
|---|---|---|---|---|---|---|---|---|
| Shaka Packager | C++ | shaka-project/shaka-packager | binary from GitHub releases | — | — | — | — | cenc, cbc1, cens, cbcs; --protection_scheme, --crypt_byte_block, --skip_byte_block, --clear_lead |
| GPAC (MP4Box) | C | gpac/gpac | brew install gpac | — | — | — | — | encrypt/decrypt via DRM XML; pattern and constant IV options |
| Bento4 (mp4encrypt) | C++ | axiomatic-systems/Bento4 | brew install bento4 | — | — | — | — | MPEG-CENC, CBC1, CENS, CBCS plus legacy methods |
| FFmpeg (mov muxer) | C | FFmpeg/FFmpeg | brew install ffmpeg | — | — | — | — | encryption_scheme: only cenc-aes-ctr in the docs; no cbcs |
| mp4-atom | Rust | kixelated/mp4-atom | cargo add mp4-atom | — | — | — | — | low-level boxes; senc raw bytes, saiz/saio; no tenc, pssh, sinf in 0.16.2 |
| pssh-box | Rust | emarsden/pssh-box-rs | cargo add pssh-box | — | — | — | — | parse and serialize pssh boxes of several DRM systems |
No Rust crate found covers tenc plus the sample transform: the mp4 crate (0.14.0) has no CENC boxes; iso-cenc (0.1.1) is Clear Key only.
Sources
- ISO/IEC 23001-7, Information technology — MPEG systems technologies — Part 7: Common encryption in ISO base media file format files, paywalled; used here only through the free sources below.
- Parrish, Freedman, ISO Common Encryption Protection Scheme for ISO Base Media File Format Stream Format, W3C Group Note, 2024. spec
- Wang, Freedman, "cenc" Initialization Data Format, W3C Group Note, 2026. spec
- DASH-IF, Implementation guidelines: content protection and security (IOP v5 Part 6). text · system IDs
- Pantos, May, HTTP Live Streaming, RFC 8216, 2017. rfc
- GPAC, Common Encryption. wiki · Shaka Packager, DRM options. doc · Bento4, mp4encrypt. doc