Home › Audio & video › Streaming & packaging
algorithm low

Common Encryption (CENC)

Problem A video service must protect the same stream for devices that use different license systems, without storing and caching one encrypted copy per system, and without hiding the headers a decoder pipeline must read.

ISO/IEC 23001-7 fixes how MP4 samples are encrypted and where key ID, IVs and clear/protected byte ranges are stored, so one file serves many DRM systems.

per sample O(protected bytes), one cipher pass; cbcs 1:9 runs the cipher on about 1 block in 10 of the protected range
drmfragmented-mp4cmafsample-encryptionaes-ctraes-cbc
Overview

One encrypted file, many license systems. Common Encryption encrypts each MP4 sample with AES-128 under a key named by a 16-byte key ID and leaves clear the bytes a pipeline must parse (NAL length and header). Which DRM system hands out the key is not in the media: each system has its own pssh box.

Key facts

Use when

  • Fragmented MP4 / CMAF over DASH and HLS to browsers, phones, TVs and set-top boxes: video on demand, live sport, corporate training, lecture capture, camera archives with access keys.

Avoid when

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):

  1. Init segment: the sample entry becomes encv/enca; its sinf holds frma (original codec), schm (cenc or cbcs) and schi/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.
  2. Each fragment: traf/senc lists per sample an IV and, with flag 0x2, subsample entries; saiz/saio give sizes and offset. Key rotation overrides the KID through a seig sample group.
  3. Per DRM system one pssh box: SystemID plus opaque data. DASH-IF lists the SystemIDs (Widevine, PlayReady, FairPlay, the common 1077efec-c0b2-4d02-ace3-3c1e52e2fb4b). W3C's cenc init data is concatenated pssh boxes; the player takes the first its system supports.
  4. 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.
  5. cenc: AES-CTR, counter block = IV || 64-bit block counter, consumed by protected bytes only; any byte count works.
  6. 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. cbc1 is CBC without pattern, cens CTR with it. GPAC uses skip 0 for non-NAL tracks.
  7. Player: read tenc, senc, KID; give pssh to 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. senc per 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, but speed does 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 in senc — high frame rates, small chunks.
  • Naive → standard → state of the art:
    • Per-system encrypted copies · storage and CDN cache multiplied by systems.
    • One cenc copy, one pssh per system · one copy per scheme.
    • One cbcs CMAF copy for HLS and DASH, pssh in the manifest · one copy; add cenc only for old devices (DASH-IF).

Implementation notes

  • Check every map: clear + protected must sum to the sample size.
  • senc needs the IV size from tenc (mp4-atom returns it as raw bytes): read the init segment first.
  • KID bytes in tenc and in the default_KID string 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 cenc and cbcs copy 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 cenc AVC behaviour, GPAC encryptSliceHeader) breaks parsers.
  • Mixing schemes in one adaptation set (DASH-IF forbids).

Variants and alternatives

HLS SAMPLE-AES on MPEG-TSsame pattern idea for TS; GPAC documents 1:9, constant IV.
WebM encryptionseparate stream format in the same EME registry.

In production

  • Shaka Packager, Bento4 and GPAC write it; browsers pass pssh to EME as init data type cenc; HLS requires cbcs for fMP4.

Implementations

namelangrepoinstallverdictlicencehealthas_ofnotes
Shaka PackagerC++shaka-project/shaka-packagerbinary from GitHub releases————cenc, cbc1, cens, cbcs; --protection_scheme, --crypt_byte_block, --skip_byte_block, --clear_lead
GPAC (MP4Box)Cgpac/gpacbrew install gpac————encrypt/decrypt via DRM XML; pattern and constant IV options
Bento4 (mp4encrypt)C++axiomatic-systems/Bento4brew install bento4————MPEG-CENC, CBC1, CENS, CBCS plus legacy methods
FFmpeg (mov muxer)CFFmpeg/FFmpegbrew install ffmpeg————encryption_scheme: only cenc-aes-ctr in the docs; no cbcs
mp4-atomRustkixelated/mp4-atomcargo add mp4-atom————low-level boxes; senc raw bytes, saiz/saio; no tenc, pssh, sinf in 0.16.2
pssh-boxRustemarsden/pssh-box-rscargo 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

Used by

Systems (4):

Alternatives and related

media containers · plannedMP4/ISO BMFF, fragmented MP4/CMAF, Matroska and MPEG-TS mux/demux
Adaptive bitrate streamingA player on a link whose speed changes unpredictably must keep media playing without stalls while showing the best quality it can. For each next piece it must decide which of several pre-encoded versions to fetch.
adaptive streaming manifests · plannedHLS playlists and DASH MPDs: renditions, segment timelines, codecs strings
low latency hls dash · plannedPartial segments, chunked CMAF, blocking playlist reload for 2-5 s latency
Video transcoding ladderA service that accepts uploaded or recorded video must play it on phones, laptops and TVs over slow and fast links. It needs to turn one source file into several versions that players can switch between.
chunked parallel encoding · plannedSplit video at keyframes, encode chunks in parallel, stitch with rate consistency
SRTPTwo endpoints stream audio or video as small UDP packets that arrive late, lost, duplicated or reordered, and a third party on the path can read, alter or replay them. Each packet must be protected on its own.AEAD (AES-GCM, ChaCha20-Poly1305)Developers encrypting data at rest, tokens, cookies or messages need encryption that also detects tampering, so attackers cannot flip bits, truncate or splice ciphertexts unnoticed.Envelope encryptionServices storing sensitive data must encrypt it at rest, rotate keys and delete data reliably, without sending every byte through a key management service.
video codecs · plannedH.264/HEVC/AV1 basics: frames, GOPs, hardware decode/encode APIs and latency settings