Skip to main content

sheathe_crypto/
pssh.rs

1//! Protection System Specific Header (`pssh`) box generation.
2//!
3//! A `pssh` box carries the per-DRM-system data a player hands to its CDM to
4//! obtain the content key. sheathe generates the boxes directly from the raw
5//! key (the same path Shaka Packager's `--protection_systems` takes), so no key
6//! server is involved:
7//!
8//! - **Common** (`1077efec…`) — a version-1 box listing the `KID`(s); no system
9//!   data. The W3C clear-key / `urn:mpeg:dash:mp4protection` family.
10//! - **Widevine** (`edef8ba9…`) — a version-0 box whose data is a
11//!   `WidevinePsshData` protobuf: the `KID` and the protection-scheme fourcc.
12//! - **PlayReady** (`9a04f079…`) — a version-0 box wrapping a PlayReady Object
13//!   that carries a UTF-16LE `WRMHEADER` 4.0.0.0 (KID, key length, ALGID, and a
14//!   checksum proving possession of the key).
15
16use crate::{ContentKey, Scheme};
17use aes::Aes128;
18use aes::cipher::generic_array::GenericArray;
19use aes::cipher::{BlockEncrypt, KeyInit};
20
21/// Common (clear-key family) System ID — `urn:mpeg:dash:mp4protection`.
22const COMMON_SYSTEM_ID: [u8; 16] = [
23    0x10, 0x77, 0xef, 0xec, 0xc0, 0xb2, 0x4d, 0x02, 0xac, 0xe3, 0x3c, 0x1e, 0x52, 0xe2, 0xfb, 0x4b,
24];
25/// Widevine System ID.
26const WIDEVINE_SYSTEM_ID: [u8; 16] = [
27    0xed, 0xef, 0x8b, 0xa9, 0x79, 0xd6, 0x4a, 0xce, 0xa3, 0xc8, 0x27, 0xdc, 0xd5, 0x1d, 0x21, 0xed,
28];
29/// PlayReady System ID.
30const PLAYREADY_SYSTEM_ID: [u8; 16] = [
31    0x9a, 0x04, 0xf0, 0x79, 0x98, 0x40, 0x42, 0x86, 0xab, 0x92, 0xe6, 0x5b, 0xe0, 0x88, 0x5f, 0x95,
32];
33
34/// A DRM protection system a `pssh` box can be generated for.
35#[derive(Debug, Clone, Copy, PartialEq, Eq)]
36pub enum ProtectionSystem {
37    /// W3C Common / clear-key family (`urn:mpeg:dash:mp4protection`).
38    Common,
39    /// Google Widevine.
40    Widevine,
41    /// Microsoft PlayReady.
42    PlayReady,
43}
44
45impl ProtectionSystem {
46    /// Parse a case-insensitive system name (as used on the command line).
47    pub fn parse(name: &str) -> Option<Self> {
48        match name.trim().to_ascii_lowercase().as_str() {
49            "common" | "commonsystem" => Some(Self::Common),
50            "widevine" => Some(Self::Widevine),
51            "playready" => Some(Self::PlayReady),
52            _ => None,
53        }
54    }
55
56    /// The 16-byte DRM System ID written into the `pssh` box.
57    pub fn system_id(self) -> [u8; 16] {
58        match self {
59            Self::Common => COMMON_SYSTEM_ID,
60            Self::Widevine => WIDEVINE_SYSTEM_ID,
61            Self::PlayReady => PLAYREADY_SYSTEM_ID,
62        }
63    }
64
65    /// Build the complete `pssh` box for this system, protecting `key` under
66    /// `scheme`.
67    pub fn pssh_box(self, key: &ContentKey, scheme: Scheme) -> Vec<u8> {
68        match self {
69            // Version-1 box: KID list in the box header, no system data.
70            Self::Common => assemble(self.system_id(), 1, &[key.kid], &[]),
71            Self::Widevine => assemble(self.system_id(), 0, &[], &widevine_data(key, scheme)),
72            Self::PlayReady => assemble(self.system_id(), 0, &[], &playready_data(key, scheme)),
73        }
74    }
75}
76
77/// Assemble a `pssh` box. Version 1 carries a `KID` list; version 0 omits it.
78fn assemble(system_id: [u8; 16], version: u8, kids: &[[u8; 16]], data: &[u8]) -> Vec<u8> {
79    let mut body = Vec::new();
80    body.extend_from_slice(b"pssh");
81    body.push(version);
82    body.extend_from_slice(&[0, 0, 0]); // flags
83    body.extend_from_slice(&system_id);
84    if version >= 1 {
85        body.extend_from_slice(&(kids.len() as u32).to_be_bytes());
86        for kid in kids {
87            body.extend_from_slice(kid);
88        }
89    }
90    body.extend_from_slice(&(data.len() as u32).to_be_bytes());
91    body.extend_from_slice(data);
92
93    let mut out = ((body.len() + 4) as u32).to_be_bytes().to_vec();
94    out.extend_from_slice(&body);
95    out
96}
97
98/// `WidevinePsshData` protobuf: `key_id` (field 2) and `protection_scheme`
99/// (field 9, the scheme fourcc as a big-endian `uint32`).
100fn widevine_data(key: &ContentKey, scheme: Scheme) -> Vec<u8> {
101    let mut data = Vec::new();
102    data.push(0x12); // field 2 (key_id), wire type 2 (length-delimited)
103    data.push(0x10); // length 16
104    data.extend_from_slice(&key.kid);
105    data.push(0x48); // field 9 (protection_scheme), wire type 0 (varint)
106    put_varint(u32::from_be_bytes(scheme.scheme_type()), &mut data);
107    data
108}
109
110/// Append `value` as a protobuf base-128 varint.
111fn put_varint(mut value: u32, out: &mut Vec<u8>) {
112    loop {
113        let byte = (value & 0x7f) as u8;
114        value >>= 7;
115        if value == 0 {
116            out.push(byte);
117            return;
118        }
119        out.push(byte | 0x80);
120    }
121}
122
123/// A PlayReady Object wrapping a single Rights Management Header (`WRMHEADER`).
124fn playready_data(key: &ContentKey, scheme: Scheme) -> Vec<u8> {
125    let kid = playready_guid(&key.kid);
126    let kid_b64 = base64(&kid);
127    let checksum_b64 = base64(&playready_checksum(&key.key, &kid));
128    // CTR schemes use AESCTR; CBC schemes use AESCBC.
129    let algid = if scheme.is_cbc() { "AESCBC" } else { "AESCTR" };
130
131    let xml = format!(
132        "<WRMHEADER xmlns=\"http://schemas.microsoft.com/DRM/2007/03/PlayReadyHeader\" \
133         version=\"4.0.0.0\"><DATA><PROTECTINFO><KEYLEN>16</KEYLEN><ALGID>{algid}</ALGID>\
134         </PROTECTINFO><KID>{kid_b64}</KID><CHECKSUM>{checksum_b64}</CHECKSUM></DATA></WRMHEADER>"
135    );
136    let header: Vec<u8> = xml.encode_utf16().flat_map(u16::to_le_bytes).collect();
137
138    // One record: type 1 (Rights Management Header) + length + UTF-16LE header.
139    let record_len = header.len() as u16;
140    // PlayReady Object: total length (incl. itself) + record count + record.
141    let total_len = 4 + 2 + 2 + 2 + header.len();
142    let mut obj = Vec::with_capacity(total_len);
143    obj.extend_from_slice(&(total_len as u32).to_le_bytes());
144    obj.extend_from_slice(&1u16.to_le_bytes()); // record count
145    obj.extend_from_slice(&1u16.to_le_bytes()); // record type: RM header
146    obj.extend_from_slice(&record_len.to_le_bytes());
147    obj.extend_from_slice(&header);
148    obj
149}
150
151/// Reorder a `KID` into PlayReady's little-endian GUID byte order (the first
152/// three GUID fields are byte-swapped; the trailing eight bytes are unchanged).
153fn playready_guid(kid: &[u8; 16]) -> [u8; 16] {
154    let mut g = *kid;
155    g.swap(0, 3);
156    g.swap(1, 2);
157    g.swap(4, 5);
158    g.swap(6, 7);
159    g
160}
161
162/// PlayReady header checksum: the first 8 bytes of AES-128-ECB encrypting the
163/// (GUID-ordered) `KID` with the content key.
164fn playready_checksum(key: &[u8; 16], guid_kid: &[u8; 16]) -> [u8; 8] {
165    let cipher = Aes128::new(GenericArray::from_slice(key));
166    let mut block = GenericArray::clone_from_slice(guid_kid);
167    cipher.encrypt_block(&mut block);
168    block[..8].try_into().unwrap()
169}
170
171/// Standard Base64 encoding (RFC 4648, with `=` padding).
172fn base64(input: &[u8]) -> String {
173    const ALPHABET: &[u8; 64] =
174        b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
175    let mut out = String::with_capacity(input.len().div_ceil(3) * 4);
176    for chunk in input.chunks(3) {
177        let b = [chunk[0], *chunk.get(1).unwrap_or(&0), *chunk.get(2).unwrap_or(&0)];
178        let n = (u32::from(b[0]) << 16) | (u32::from(b[1]) << 8) | u32::from(b[2]);
179        out.push(ALPHABET[(n >> 18 & 0x3f) as usize] as char);
180        out.push(ALPHABET[(n >> 12 & 0x3f) as usize] as char);
181        out.push(if chunk.len() > 1 { ALPHABET[(n >> 6 & 0x3f) as usize] as char } else { '=' });
182        out.push(if chunk.len() > 2 { ALPHABET[(n & 0x3f) as usize] as char } else { '=' });
183    }
184    out
185}
186
187#[cfg(test)]
188mod tests {
189    use super::*;
190
191    // KID/KEY used to capture the Shaka Packager oracle bytes below.
192    const KID: [u8; 16] = [
193        0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, 0x99, 0x00, 0xaa, 0xbb, 0xcc, 0xdd, 0xee,
194        0xff,
195    ];
196    const KEY: [u8; 16] = [
197        0x00, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, 0x99, 0xaa, 0xbb, 0xcc, 0xdd, 0xee,
198        0xff,
199    ];
200
201    fn key() -> ContentKey {
202        ContentKey { kid: KID, key: KEY }
203    }
204
205    fn hex(bytes: &[u8]) -> String {
206        bytes.iter().map(|b| format!("{b:02x}")).collect()
207    }
208
209    #[test]
210    fn base64_matches_known_vectors() {
211        assert_eq!(base64(b""), "");
212        assert_eq!(base64(b"f"), "Zg==");
213        assert_eq!(base64(b"fo"), "Zm8=");
214        assert_eq!(base64(b"foo"), "Zm9v");
215        assert_eq!(base64(b"foobar"), "Zm9vYmFy");
216    }
217
218    #[test]
219    fn common_pssh_matches_shaka() {
220        let got = ProtectionSystem::Common.pssh_box(&key(), Scheme::Cenc);
221        assert_eq!(
222            hex(&got),
223            "0000003470737368010000001077efecc0b24d02ace33c1e52e2fb4b\
224             0000000111223344556677889900aabbccddeeff00000000"
225        );
226    }
227
228    #[test]
229    fn widevine_pssh_matches_shaka() {
230        let got = ProtectionSystem::Widevine.pssh_box(&key(), Scheme::Cenc);
231        assert_eq!(
232            hex(&got),
233            "000000387073736800000000edef8ba979d64acea3c827dcd51d21ed\
234             00000018121011223344556677889900aabbccddeeff48e3dc959b06"
235        );
236    }
237
238    #[test]
239    fn playready_pssh_matches_shaka() {
240        let got = ProtectionSystem::PlayReady.pssh_box(&key(), Scheme::Cenc);
241        // Full box captured byte-for-byte from Shaka `--protection_systems
242        // PlayReady` (the UTF-16LE WRMHEADER, swapped-GUID KID, and checksum).
243        let expected = concat!(
244            "0000022670737368000000009a04f07998404286ab92e65be0885f9500000206060200000100010",
245            "0fc013c00570052004d00480045004100440045005200200078006d006c006e0073003d002200680",
246            "07400740070003a002f002f0073006300680065006d00610073002e006d006900630072006f0073",
247            "006f00660074002e0063006f006d002f00440052004d002f00320030003000370",
248            "02f00300033002f0050006c0061007900520065006100640079004800650061006",
249            "4006500720022002000760065007200730069006f006e003d00220034002e0030",
250            "002e0030002e00300022003e003c0044004100540041003e003c00500052004f005",
251            "40045004300540049004e0046004f003e003c004b00450059004c0045004e003e0",
252            "0310036003c002f004b00450059004c0045004e003e003c0041004c00470049004",
253            "4003e004100450053004300540052003c002f0041004c004700490044003e003c0",
254            "02f00500052004f00540045004300540049004e0046004f003e003c004b0049004",
255            "4003e00520044004d006900450057005a0056006900480065005a0041004b00710",
256            "037007a004e00330075002f0077003d003d003c002f004b00490044003e003c004",
257            "3004800450043004b00530055004d003e00350041006100550053004600700056",
258            "004800640030003d003c002f0043004800450043004b00530055004d003e003c00",
259            "2f0044004100540041003e003c002f00570052004d004800450041004400450052003e00",
260        );
261        assert_eq!(hex(&got), expected);
262    }
263
264    #[test]
265    fn widevine_protection_scheme_tracks_fourcc() {
266        // Field 9 (protection_scheme) is the scheme fourcc as a big-endian u32,
267        // so cbcs differs from cenc only in the trailing varint while sharing the
268        // KID prefix (field 2).
269        let cenc = ProtectionSystem::Widevine.pssh_box(&key(), Scheme::Cenc);
270        let cbcs = ProtectionSystem::Widevine.pssh_box(&key(), Scheme::Cbcs);
271        assert_ne!(cenc, cbcs);
272        let kid_prefix = hex(&[&[0x12, 0x10][..], &KID].concat());
273        assert!(hex(&cenc).contains(&kid_prefix) && hex(&cbcs).contains(&kid_prefix));
274    }
275
276    #[test]
277    fn parse_is_case_insensitive() {
278        assert_eq!(ProtectionSystem::parse("Widevine"), Some(ProtectionSystem::Widevine));
279        assert_eq!(ProtectionSystem::parse("PLAYREADY"), Some(ProtectionSystem::PlayReady));
280        assert_eq!(ProtectionSystem::parse("commonsystem"), Some(ProtectionSystem::Common));
281        assert_eq!(ProtectionSystem::parse("nope"), None);
282    }
283}