Skip to main content

mp4box/
drm.rs

1//! System-specific `pssh` payload decoding.
2//!
3//! The `data` blob inside a Protection System Specific Header box (ISO
4//! 23001-7) is opaque at the ISO-BMFF level; its format is defined by each
5//! DRM system. This module decodes the two formats seen in practice:
6//!
7//! - **Widevine**: a small protobuf (`WidevinePsshData` in Google's schema).
8//! - **PlayReady**: a PlayReady Object — little-endian length-prefixed
9//!   records carrying a UTF-16LE `WRMHEADER` XML document.
10//!
11//! It also parses raw `pssh` box bytes outside a file context
12//! ([`parse_pssh_boxes`]), the form carried by DASH `cenc:pssh` manifest
13//! elements and pasted around in logs and tickets.
14
15use crate::registry::{PsshData, drm_system_name, hex_string, uuid_string};
16
17pub const WIDEVINE_SYSTEM_ID: [u8; 16] = [
18    0xED, 0xEF, 0x8B, 0xA9, 0x79, 0xD6, 0x4A, 0xCE, 0xA3, 0xC8, 0x27, 0xDC, 0xD5, 0x1D, 0x21, 0xED,
19];
20
21pub const PLAYREADY_SYSTEM_ID: [u8; 16] = [
22    0x9A, 0x04, 0xF0, 0x79, 0x98, 0x40, 0x42, 0x86, 0xAB, 0x92, 0xE6, 0x5B, 0xE0, 0x88, 0x5F, 0x95,
23];
24
25/// Decoded Widevine pssh payload (the `WidevinePsshData` protobuf).
26#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
27pub struct WidevinePsshData {
28    /// Legacy v0 algorithm field: "UNENCRYPTED" or "AESCTR".
29    #[serde(skip_serializing_if = "Option::is_none", default)]
30    pub algorithm: Option<String>,
31    /// Key IDs, 32-char lowercase hex each.
32    pub key_ids: Vec<String>,
33    #[serde(skip_serializing_if = "Option::is_none", default)]
34    pub provider: Option<String>,
35    /// Content ID bytes as lowercase hex.
36    #[serde(skip_serializing_if = "Option::is_none", default)]
37    pub content_id: Option<String>,
38    /// Content ID as text, when the bytes are printable ASCII.
39    #[serde(skip_serializing_if = "Option::is_none", default)]
40    pub content_id_text: Option<String>,
41    #[serde(skip_serializing_if = "Option::is_none", default)]
42    pub policy: Option<String>,
43    #[serde(skip_serializing_if = "Option::is_none", default)]
44    pub crypto_period_index: Option<u32>,
45    /// Protection scheme 4CC: "cenc", "cbcs", "cens", or "cbc1".
46    #[serde(skip_serializing_if = "Option::is_none", default)]
47    pub protection_scheme: Option<String>,
48}
49
50/// Decoded PlayReady pssh payload (PlayReady Object + WRMHEADER).
51#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
52pub struct PlayReadyPsshData {
53    pub record_count: u16,
54    /// WRMHEADER version attribute, e.g. "4.2.0.0".
55    #[serde(skip_serializing_if = "Option::is_none", default)]
56    pub wrm_header_version: Option<String>,
57    /// Key IDs converted from PlayReady's little-endian GUID order to the
58    /// CENC byte order used everywhere else, 32-char lowercase hex each.
59    pub key_ids: Vec<String>,
60    #[serde(skip_serializing_if = "Option::is_none", default)]
61    pub la_url: Option<String>,
62    /// The rights-management header (WRMHEADER) XML, re-encoded as UTF-8.
63    #[serde(skip_serializing_if = "Option::is_none", default)]
64    pub xml: Option<String>,
65}
66
67// ---------- Widevine (protobuf) ----------
68
69struct ProtoReader<'a> {
70    data: &'a [u8],
71    pos: usize,
72}
73
74impl<'a> ProtoReader<'a> {
75    fn varint(&mut self) -> Option<u64> {
76        let mut value: u64 = 0;
77        let mut shift = 0u32;
78        loop {
79            let byte = *self.data.get(self.pos)?;
80            self.pos += 1;
81            if shift >= 64 {
82                return None;
83            }
84            value |= u64::from(byte & 0x7F) << shift;
85            if byte & 0x80 == 0 {
86                return Some(value);
87            }
88            shift += 7;
89        }
90    }
91
92    fn bytes(&mut self) -> Option<&'a [u8]> {
93        let len = self.varint()? as usize;
94        let end = self.pos.checked_add(len)?;
95        if end > self.data.len() {
96            return None;
97        }
98        let out = &self.data[self.pos..end];
99        self.pos = end;
100        Some(out)
101    }
102
103    fn skip(&mut self, wire: u8) -> Option<()> {
104        match wire {
105            0 => {
106                self.varint()?;
107            }
108            1 => {
109                self.pos = self.pos.checked_add(8)?;
110            }
111            2 => {
112                self.bytes()?;
113            }
114            5 => {
115                self.pos = self.pos.checked_add(4)?;
116            }
117            _ => return None,
118        }
119        (self.pos <= self.data.len()).then_some(())
120    }
121}
122
123/// Parses a bare Widevine pssh payload. Returns `None` for anything that
124/// doesn't decode cleanly as the Widevine protobuf, so it can double as a
125/// "is this Widevine data?" probe.
126pub fn parse_widevine_pssh_data(data: &[u8]) -> Option<WidevinePsshData> {
127    if data.is_empty() {
128        return None;
129    }
130    let mut out = WidevinePsshData {
131        algorithm: None,
132        key_ids: Vec::new(),
133        provider: None,
134        content_id: None,
135        content_id_text: None,
136        policy: None,
137        crypto_period_index: None,
138        protection_scheme: None,
139    };
140    let mut recognized = 0usize;
141    let mut r = ProtoReader { data, pos: 0 };
142    while r.pos < data.len() {
143        let key = r.varint()?;
144        let field = key >> 3;
145        let wire = (key & 7) as u8;
146        match (field, wire) {
147            (1, 0) => {
148                out.algorithm = Some(match r.varint()? {
149                    0 => "UNENCRYPTED".to_string(),
150                    1 => "AESCTR".to_string(),
151                    n => n.to_string(),
152                });
153                recognized += 1;
154            }
155            (2, 2) => {
156                out.key_ids.push(hex_string(r.bytes()?));
157                recognized += 1;
158            }
159            (3, 2) => {
160                out.provider = Some(String::from_utf8(r.bytes()?.to_vec()).ok()?);
161                recognized += 1;
162            }
163            (4, 2) => {
164                let id = r.bytes()?;
165                out.content_id = Some(hex_string(id));
166                if !id.is_empty() && id.iter().all(|b| b.is_ascii_graphic() || *b == b' ') {
167                    out.content_id_text = Some(String::from_utf8(id.to_vec()).ok()?);
168                }
169                recognized += 1;
170            }
171            (6, 2) => {
172                out.policy = Some(String::from_utf8(r.bytes()?.to_vec()).ok()?);
173                recognized += 1;
174            }
175            (7, 0) => {
176                out.crypto_period_index = Some(u32::try_from(r.varint()?).ok()?);
177                recognized += 1;
178            }
179            (9, 0) => {
180                let fourcc = u32::try_from(r.varint()?).ok()?.to_be_bytes();
181                out.protection_scheme = if fourcc.iter().all(|b| b.is_ascii_graphic()) {
182                    Some(String::from_utf8(fourcc.to_vec()).ok()?)
183                } else {
184                    Some(hex_string(&fourcc))
185                };
186                recognized += 1;
187            }
188            _ => r.skip(wire)?,
189        }
190    }
191    // Random bytes can survive the field loop; require at least one field we
192    // actually understand before claiming this was a Widevine payload.
193    (recognized > 0).then_some(out)
194}
195
196// ---------- PlayReady (PlayReady Object + WRMHEADER XML) ----------
197
198fn utf16le_to_string(bytes: &[u8]) -> Option<String> {
199    if !bytes.len().is_multiple_of(2) {
200        return None;
201    }
202    let units: Vec<u16> = bytes
203        .chunks_exact(2)
204        .map(|c| u16::from_le_bytes([c[0], c[1]]))
205        .collect();
206    String::from_utf16(&units)
207        .ok()
208        .map(|s| s.trim_start_matches('\u{feff}').to_string())
209}
210
211fn xml_unescape(s: &str) -> String {
212    s.replace("&lt;", "<")
213        .replace("&gt;", ">")
214        .replace("&quot;", "\"")
215        .replace("&apos;", "'")
216        .replace("&amp;", "&")
217}
218
219/// Text content of the first `<tag>...</tag>` element.
220fn xml_element_text<'a>(xml: &'a str, tag: &str) -> Option<&'a str> {
221    let open = format!("<{tag}>");
222    let close = format!("</{tag}>");
223    let start = xml.find(&open)? + open.len();
224    let end = start + xml[start..].find(&close)?;
225    Some(&xml[start..end])
226}
227
228/// Value of `attr="..."` inside the given tag text.
229fn xml_attr_value<'a>(tag_text: &'a str, attr: &str) -> Option<&'a str> {
230    let needle = format!("{attr}=\"");
231    let start = tag_text.find(&needle)? + needle.len();
232    let end = start + tag_text[start..].find('"')?;
233    Some(&tag_text[start..end])
234}
235
236/// PlayReady KIDs are base64 of the KID GUID in Microsoft's mixed-endian
237/// layout; the first three GUID fields are little-endian. Reversing them
238/// yields the big-endian byte order CENC uses.
239fn guid_le_to_be(mut guid: [u8; 16]) -> [u8; 16] {
240    guid[0..4].reverse();
241    guid[4..6].reverse();
242    guid[6..8].reverse();
243    guid
244}
245
246fn playready_kid_to_hex(b64: &str) -> Option<String> {
247    let bytes = crate::util::base64_decode(b64.trim())?;
248    let guid: [u8; 16] = bytes.try_into().ok()?;
249    Some(hex_string(&guid_le_to_be(guid)))
250}
251
252/// All KIDs in a WRMHEADER, across its versions: 4.0 uses `<KID>base64</KID>`,
253/// 4.1+ use `<KID ... VALUE="base64">` (nested in `<KIDS>` for 4.2/4.3).
254fn extract_playready_kids(xml: &str) -> Vec<String> {
255    let mut out: Vec<String> = Vec::new();
256    let mut search = 0usize;
257    while let Some(found) = xml[search..].find("<KID") {
258        let after = search + found + "<KID".len();
259        search = after;
260        match xml.as_bytes().get(after) {
261            Some(b'>') => {
262                if let Some(end) = xml[after + 1..].find("</KID>")
263                    && let Some(kid) = playready_kid_to_hex(&xml[after + 1..after + 1 + end])
264                {
265                    out.push(kid);
266                }
267            }
268            Some(c) if c.is_ascii_whitespace() => {
269                if let Some(end) = xml[after..].find('>')
270                    && let Some(value) = xml_attr_value(&xml[after..after + end], "VALUE")
271                    && let Some(kid) = playready_kid_to_hex(value)
272                {
273                    out.push(kid);
274                }
275            }
276            // "<KIDS" and other tags sharing the prefix.
277            _ => {}
278        }
279    }
280    out.dedup();
281    out
282}
283
284/// Parses a bare PlayReady pssh payload (a PlayReady Object). Returns `None`
285/// for anything that doesn't carry a readable WRMHEADER record.
286pub fn parse_playready_pssh_data(data: &[u8]) -> Option<PlayReadyPsshData> {
287    if data.len() < 10 {
288        return None;
289    }
290    let total = u32::from_le_bytes(data[0..4].try_into().unwrap()) as usize;
291    if total < 10 || total > data.len() {
292        return None;
293    }
294    let record_count = u16::from_le_bytes(data[4..6].try_into().unwrap());
295    let mut pos = 6usize;
296    let mut xml: Option<String> = None;
297    for _ in 0..record_count {
298        if pos + 4 > total {
299            return None;
300        }
301        let record_type = u16::from_le_bytes(data[pos..pos + 2].try_into().unwrap());
302        let record_len = u16::from_le_bytes(data[pos + 2..pos + 4].try_into().unwrap()) as usize;
303        pos += 4;
304        if pos + record_len > total {
305            return None;
306        }
307        // Type 1 is the rights-management header; 2 and 3 are reserved / an
308        // embedded license store, which carry no fields we surface.
309        if record_type == 1 && xml.is_none() {
310            xml = utf16le_to_string(&data[pos..pos + record_len]);
311        }
312        pos += record_len;
313    }
314    let xml = xml?;
315    let wrm_header_version = xml
316        .find("<WRMHEADER")
317        .and_then(|i| {
318            let tag_end = i + xml[i..].find('>')?;
319            xml_attr_value(&xml[i..tag_end], "version")
320        })
321        .map(str::to_string);
322    Some(PlayReadyPsshData {
323        record_count,
324        wrm_header_version,
325        key_ids: extract_playready_kids(&xml),
326        la_url: xml_element_text(&xml, "LA_URL").map(xml_unescape),
327        xml: Some(xml),
328    })
329}
330
331// ---------- pssh box parsing ----------
332
333/// Parses the body of a `pssh` box: everything after the FullBox
334/// version/flags. Decodes the system-specific data blob when the system is
335/// recognized; unknown systems still yield the generic fields.
336pub(crate) fn parse_pssh_body(version: u8, flags: u32, body: &[u8]) -> anyhow::Result<PsshData> {
337    if body.len() < 16 {
338        anyhow::bail!("pssh body truncated ({} bytes)", body.len());
339    }
340    let system_id: [u8; 16] = body[0..16].try_into().unwrap();
341    let mut pos = 16usize;
342
343    let mut key_ids = Vec::new();
344    if version >= 1 {
345        if pos + 4 > body.len() {
346            anyhow::bail!("pssh KID count truncated");
347        }
348        let kid_count = u32::from_be_bytes(body[pos..pos + 4].try_into().unwrap()) as usize;
349        pos += 4;
350        if pos + kid_count.saturating_mul(16) > body.len() {
351            anyhow::bail!("pssh KID list truncated");
352        }
353        for _ in 0..kid_count {
354            key_ids.push(hex_string(&body[pos..pos + 16]));
355            pos += 16;
356        }
357    }
358
359    if pos + 4 > body.len() {
360        anyhow::bail!("pssh data size truncated");
361    }
362    let data_size = u32::from_be_bytes(body[pos..pos + 4].try_into().unwrap());
363    pos += 4;
364    // Tolerate a data blob shorter than declared; parse what is there.
365    let data = &body[pos..pos + (data_size as usize).min(body.len() - pos)];
366
367    let widevine = (system_id == WIDEVINE_SYSTEM_ID)
368        .then(|| parse_widevine_pssh_data(data))
369        .flatten()
370        .map(Box::new);
371    let playready = (system_id == PLAYREADY_SYSTEM_ID)
372        .then(|| parse_playready_pssh_data(data))
373        .flatten()
374        .map(Box::new);
375
376    Ok(PsshData {
377        version,
378        flags,
379        system_id: uuid_string(&system_id),
380        system_name: drm_system_name(&system_id).map(str::to_string),
381        key_ids,
382        data_size,
383        widevine,
384        playready,
385    })
386}
387
388/// Parses one or more concatenated raw `pssh` boxes — the form carried by
389/// DASH `cenc:pssh` manifest elements. Fails on anything that isn't a
390/// well-formed pssh box, so callers can use it as a format probe.
391pub fn parse_pssh_boxes(data: &[u8]) -> anyhow::Result<Vec<PsshData>> {
392    let mut out = Vec::new();
393    let mut pos = 0usize;
394    while data.len() - pos >= 8 {
395        if &data[pos + 4..pos + 8] != b"pssh" {
396            anyhow::bail!("not a pssh box at offset {pos}");
397        }
398        let declared = u32::from_be_bytes(data[pos..pos + 4].try_into().unwrap()) as usize;
399        // size 0 means "extends to the end"; largesize (1) never occurs in
400        // practice for pssh and is rejected by the minimum below.
401        let size = if declared == 0 {
402            data.len() - pos
403        } else {
404            declared
405        };
406        if size < 12 || pos + size > data.len() {
407            anyhow::bail!("invalid pssh box size {declared} at offset {pos}");
408        }
409        let payload = &data[pos + 8..pos + size];
410        let version = payload[0];
411        let flags = u32::from_be_bytes([0, payload[1], payload[2], payload[3]]);
412        out.push(parse_pssh_body(version, flags, &payload[4..])?);
413        pos += size;
414    }
415    if out.is_empty() {
416        anyhow::bail!("no pssh box found");
417    }
418    Ok(out)
419}
420
421/// Wraps a bare Widevine payload (protobuf without box framing, as found in
422/// packager logs) in a synthetic [`PsshData`]. Returns `None` when the bytes
423/// don't decode as Widevine.
424pub fn pssh_from_raw_widevine(data: &[u8]) -> Option<PsshData> {
425    let widevine = parse_widevine_pssh_data(data)?;
426    Some(PsshData {
427        version: 0,
428        flags: 0,
429        system_id: uuid_string(&WIDEVINE_SYSTEM_ID),
430        system_name: Some("Widevine".to_string()),
431        key_ids: Vec::new(),
432        data_size: data.len() as u32,
433        widevine: Some(Box::new(widevine)),
434        playready: None,
435    })
436}