1use std::path::{Path, PathBuf};
4
5use aes::Aes128;
6use chacha20::ChaCha20;
7use cipher::block_padding::Pkcs7;
8use cipher::{BlockDecryptMut, KeyInit, KeyIvInit, StreamCipher};
9
10use crate::config::{CustomKey, DecryptionEngine};
11use crate::error::{Error, Result};
12use crate::manifest::EncryptionMethod;
13
14const ZERO_KID: &str = "00000000000000000000000000000000";
15const WIDEVINE_SYSTEM_ID: [u8; 16] = [
16 0xed, 0xef, 0x8b, 0xa9, 0x79, 0xd6, 0x4a, 0xce, 0xa3, 0xc8, 0x27, 0xdc, 0xd5, 0x1d, 0x21, 0xed,
17];
18const PLAYREADY_SYSTEM_ID: [u8; 16] = [
19 0x9a, 0x04, 0xf0, 0x79, 0x98, 0x40, 0x42, 0x86, 0xab, 0x92, 0xe6, 0x5b, 0xe0, 0x88, 0x5f, 0x95,
20];
21const FAIRPLAY_SYSTEM_ID: [u8; 16] = [
22 0x94, 0xce, 0x86, 0xfb, 0x07, 0xff, 0x4f, 0x43, 0xad, 0xb8, 0x93, 0xd2, 0xfa, 0x96, 0x8c, 0xa2,
23];
24
25#[derive(Clone, Debug, Default, Eq, PartialEq)]
27pub struct Mp4ProtectionInfo {
28 pub pssh: Option<String>,
30 pub pssh_system: Option<PsshSystem>,
32 pub psshs: Vec<PsshInfo>,
34 pub kid: Option<String>,
36 pub scheme: Option<String>,
38 pub is_multi_drm: bool,
40}
41
42#[derive(Clone, Copy, Debug, Eq, PartialEq)]
44pub enum PsshSystem {
45 Widevine,
47 PlayReady,
49 FairPlay,
51}
52
53#[derive(Clone, Debug, Eq, PartialEq)]
55pub struct PsshInfo {
56 pub system: PsshSystem,
58 pub data: String,
60}
61
62#[derive(Clone, Debug, Eq, PartialEq)]
64pub struct SelectedKey {
65 pub track_id: Option<String>,
67 pub key_pair: String,
69}
70
71#[derive(Clone, Debug, Eq, PartialEq)]
73pub struct ExternalDecryptPlan {
74 pub engine: DecryptionEngine,
76 pub program: PathBuf,
78 pub redacted_arguments: String,
80 pub work_dir: Option<PathBuf>,
82 pub requires_init_concat: bool,
84}
85
86#[derive(Clone, Copy, Debug)]
88pub struct ExternalDecryptRequest<'a> {
89 pub engine: DecryptionEngine,
91 pub program: &'a Path,
93 pub keys: &'a [String],
95 pub source: &'a Path,
97 pub dest: &'a Path,
99 pub kid: Option<&'a str>,
101 pub init: Option<&'a Path>,
103 pub is_multi_drm: bool,
105}
106
107pub fn aes_128_cbc_decrypt(encrypted: &[u8], key: &[u8], iv: &[u8]) -> Result<Vec<u8>> {
109 type Aes128CbcDec = cbc::Decryptor<Aes128>;
110 let decryptor = Aes128CbcDec::new_from_slices(key, iv)
111 .map_err(|_| Error::decrypt("AES-128-CBC key or IV length is invalid"))?;
112 decryptor
113 .decrypt_padded_vec_mut::<Pkcs7>(encrypted)
114 .map_err(|_| Error::decrypt("AES-128-CBC decrypt failed"))
115}
116
117pub fn aes_128_ecb_decrypt(encrypted: &[u8], key: &[u8]) -> Result<Vec<u8>> {
119 type Aes128EcbDec = ecb::Decryptor<Aes128>;
120 let decryptor = Aes128EcbDec::new_from_slice(key)
121 .map_err(|_| Error::decrypt("AES-128-ECB key length is invalid"))?;
122 decryptor
123 .decrypt_padded_vec_mut::<Pkcs7>(encrypted)
124 .map_err(|_| Error::decrypt("AES-128-ECB decrypt failed"))
125}
126
127pub fn decrypt_hls_segment_bytes(
129 method: EncryptionMethod,
130 encrypted: &[u8],
131 key: Option<&[u8]>,
132 iv: Option<&[u8]>,
133) -> Result<Vec<u8>> {
134 match method {
135 EncryptionMethod::Aes128 => aes_128_cbc_decrypt(
136 encrypted,
137 key.ok_or_else(|| Error::decrypt("AES key is missing"))?,
138 iv.ok_or_else(|| Error::decrypt("AES IV is missing"))?,
139 ),
140 EncryptionMethod::Aes128Ecb => aes_128_ecb_decrypt(
141 encrypted,
142 key.ok_or_else(|| Error::decrypt("AES key is missing"))?,
143 ),
144 EncryptionMethod::Chacha20 => chacha20_decrypt_per_1024_bytes(
145 encrypted,
146 key.ok_or_else(|| Error::decrypt("ChaCha20 key is missing"))?,
147 iv.ok_or_else(|| Error::decrypt("ChaCha20 nonce is missing"))?,
148 ),
149 EncryptionMethod::SampleAesCtr => Ok(encrypted.to_vec()),
150 _ => Ok(encrypted.to_vec()),
151 }
152}
153
154pub async fn decrypt_hls_segment_file(
156 path: &Path,
157 method: EncryptionMethod,
158 key: Option<&[u8]>,
159 iv: Option<&[u8]>,
160) -> Result<()> {
161 let encrypted = tokio::fs::read(path).await?;
162 let decrypted = decrypt_hls_segment_bytes(method, &encrypted, key, iv)?;
163 tokio::fs::write(path, decrypted).await?;
164 Ok(())
165}
166
167pub fn chacha20_decrypt_per_1024_bytes(
169 encrypted: &[u8],
170 key: &[u8],
171 nonce: &[u8],
172) -> Result<Vec<u8>> {
173 if key.len() != 32 {
174 return Err(Error::decrypt("ChaCha20 key must be 32 bytes"));
175 }
176 let nonce = match nonce.len() {
177 12 => nonce.to_vec(),
178 8 => {
179 let mut padded = vec![0, 0, 0, 0];
180 padded.extend(nonce);
181 padded
182 }
183 _ => return Err(Error::decrypt("ChaCha20 nonce must be 12 or 8 bytes")),
184 };
185 let mut output = Vec::with_capacity(encrypted.len());
186 for chunk in encrypted.chunks(1024) {
187 let mut buffer = chunk.to_vec();
188 let mut cipher = ChaCha20::new_from_slices(key, &nonce)
189 .map_err(|_| Error::decrypt("ChaCha20 key or nonce length is invalid"))?;
190 cipher.apply_keystream(&mut buffer);
191 output.extend(buffer);
192 }
193 Ok(output)
194}
195
196pub fn custom_keys_to_pairs(keys: &[CustomKey]) -> Vec<String> {
198 keys.iter()
199 .map(|key| match key {
200 CustomKey::Track { track_id, key_hex } => format!("{track_id}:{key_hex}"),
201 CustomKey::Kid { kid_hex, key_hex } => format!("{kid_hex}:{key_hex}"),
202 CustomKey::Key { key_hex } => key_hex.clone(),
203 })
204 .collect()
205}
206
207pub async fn search_key_text_file(
209 path: Option<&Path>,
210 kid: Option<&str>,
211) -> Result<Option<String>> {
212 let Some(path) = path else {
213 return Ok(None);
214 };
215 let Some(kid) = kid.filter(|value| !value.is_empty()) else {
216 return Ok(None);
217 };
218 let Ok(metadata) = tokio::fs::metadata(path).await else {
219 return Ok(None);
220 };
221 if !metadata.is_file() {
222 return Ok(None);
223 }
224 let Ok(bytes) = tokio::fs::read(path).await else {
225 return Ok(None);
226 };
227 let text = String::from_utf8_lossy(&bytes);
228 Ok(text
229 .lines()
230 .map(str::trim)
231 .find(|line| line.starts_with(kid))
232 .map(str::to_string))
233}
234
235pub fn read_mp4_protection_info(data: &[u8]) -> Mp4ProtectionInfo {
237 let mut info = Mp4ProtectionInfo::default();
238 scan_boxes(data, &mut info);
239 info
240}
241
242pub fn select_key_pair(
244 keys: &[String],
245 kid: Option<&str>,
246 is_multi_drm: bool,
247) -> Option<SelectedKey> {
248 if keys.is_empty() {
249 return None;
250 }
251 let mut track_id = if is_multi_drm {
252 Some("1".to_string())
253 } else {
254 None
255 };
256 let kid = kid.unwrap_or_default();
257 let mut key_pair = keys
258 .iter()
259 .find(|key| !kid.is_empty() && key.starts_with(kid))
260 .cloned();
261 if kid == ZERO_KID {
262 key_pair = keys.first().cloned();
263 track_id = Some("1".to_string());
264 }
265 if key_pair.is_none() && keys.len() == 1 && !keys[0].contains(':') {
266 key_pair = Some(format!("{kid}:{}", keys[0]));
267 }
268 key_pair.map(|key_pair| SelectedKey { track_id, key_pair })
269}
270
271pub fn plan_external_decrypt(
273 request: ExternalDecryptRequest<'_>,
274) -> Result<Option<ExternalDecryptPlan>> {
275 let ExternalDecryptRequest {
276 engine,
277 program,
278 keys,
279 source,
280 dest,
281 kid,
282 init,
283 is_multi_drm,
284 } = request;
285 let Some(selected) = select_key_pair(keys, kid, is_multi_drm) else {
286 return Ok(None);
287 };
288 if engine == DecryptionEngine::Mp4forge {
289 return Ok(None);
290 }
291 if source
292 .file_name()
293 .and_then(|value| value.to_str())
294 .is_some_and(|name| name.ends_with("_init.mp4"))
295 && engine != DecryptionEngine::Mp4decrypt
296 {
297 return Ok(None);
298 }
299 let program = program.to_path_buf();
300 let key_value = selected
301 .key_pair
302 .split_once(':')
303 .map(|(_, key)| key)
304 .unwrap_or(selected.key_pair.as_str());
305 let kid = kid.unwrap_or_default();
306 let requires_init_concat = init.is_some() && engine != DecryptionEngine::Mp4decrypt;
307 let redacted_arguments = match engine {
308 DecryptionEngine::Mp4forge => return Ok(None),
309 DecryptionEngine::ShakaPackager => {
310 let key_id = selected
311 .track_id
312 .as_deref()
313 .map(|_| ZERO_KID)
314 .or_else(|| {
315 selected
316 .key_pair
317 .split_once(':')
318 .map(|(key_id, _)| key_id)
319 .filter(|key_id| !key_id.is_empty())
320 })
321 .unwrap_or(kid);
322 let label = selected
323 .track_id
324 .as_deref()
325 .map(|track_id| format!("label={track_id}:"))
326 .unwrap_or_default();
327 format!(
328 "--quiet --enable_raw_key_decryption input=\"{}\",stream=0,output=\"{}\" --keys {label}key_id={key_id}:key={}",
329 source.display(),
330 dest.display(),
331 redact_key(key_value)
332 )
333 }
334 DecryptionEngine::Mp4decrypt => {
335 let key_args = if let Some(track_id) = &selected.track_id {
336 keys.iter()
337 .filter_map(|key| key.split_once(':').map(|(_, key)| key))
338 .map(|key| format!("--key {track_id}:{}", redact_key(key)))
339 .collect::<Vec<_>>()
340 .join(" ")
341 } else {
342 keys.iter()
343 .map(|key| redact_key_pair_argument(key))
344 .collect::<Vec<_>>()
345 .join(" ")
346 };
347 format!(
348 "{key_args} \"{}\" \"{}\"",
349 source
350 .file_name()
351 .and_then(|value| value.to_str())
352 .unwrap_or_default(),
353 dest.file_name()
354 .and_then(|value| value.to_str())
355 .unwrap_or_default()
356 )
357 }
358 DecryptionEngine::Ffmpeg => format!(
359 "-loglevel error -nostdin -decryption_key {} -i \"{}\" -c copy \"{}\"",
360 redact_key(key_value),
361 source.display(),
362 dest.display()
363 ),
364 };
365 Ok(Some(ExternalDecryptPlan {
366 engine,
367 program,
368 redacted_arguments,
369 work_dir: if engine == DecryptionEngine::Mp4decrypt {
370 source.parent().map(Path::to_path_buf)
371 } else {
372 None
373 },
374 requires_init_concat,
375 }))
376}
377
378pub fn redact_secrets(input: &str) -> String {
380 let mut output = input.to_string();
381 for marker in ["key=", "decryption_key ", "--key "] {
382 output = redact_after_marker(&output, marker);
383 }
384 for query in ["token=", "signature=", "sig=", "Policy=", "Key-Pair-Id="] {
385 output = redact_query_value(&output, query);
386 }
387 output
388}
389
390fn scan_boxes(data: &[u8], info: &mut Mp4ProtectionInfo) {
391 let mut offset = 0_usize;
392 while offset + 8 <= data.len() {
393 let size = u32::from_be_bytes([
394 data[offset],
395 data[offset + 1],
396 data[offset + 2],
397 data[offset + 3],
398 ]) as usize;
399 if size < 8 || offset + size > data.len() {
400 offset += 1;
401 continue;
402 }
403 let box_type = &data[offset + 4..offset + 8];
404 let payload = &data[offset + 8..offset + size];
405 if box_type == b"pssh" {
406 read_pssh(payload, info);
407 } else if matches!(
408 box_type,
409 b"moov"
410 | b"trak"
411 | b"mdia"
412 | b"minf"
413 | b"stbl"
414 | b"stsd"
415 | b"encv"
416 | b"enca"
417 | b"enct"
418 | b"encs"
419 | b"sinf"
420 | b"schi"
421 ) {
422 read_encryption_sample_entry(payload, info);
423 scan_boxes(payload, info);
424 }
425 offset += size;
426 }
427}
428
429fn read_pssh(payload: &[u8], info: &mut Mp4ProtectionInfo) {
430 if payload.len() < 24 {
431 return;
432 }
433 let version = payload[0];
434 if version > 1 {
435 return;
436 }
437 let system_id = &payload[4..20];
438 let Some(system) = pssh_system(system_id) else {
439 return;
440 };
441 let mut data_size_offset = 20;
442 if version == 1 {
443 let Some(kid_count_bytes) = payload.get(data_size_offset..data_size_offset + 4) else {
444 return;
445 };
446 let kid_count = u32::from_be_bytes([
447 kid_count_bytes[0],
448 kid_count_bytes[1],
449 kid_count_bytes[2],
450 kid_count_bytes[3],
451 ]) as usize;
452 data_size_offset += 4 + kid_count.saturating_mul(16);
453 }
454 let Some(size_bytes) = payload.get(data_size_offset..data_size_offset + 4) else {
455 return;
456 };
457 let data_size =
458 u32::from_be_bytes([size_bytes[0], size_bytes[1], size_bytes[2], size_bytes[3]]) as usize;
459 let Some(pssh_data) = payload.get(data_size_offset + 4..data_size_offset + 4 + data_size)
460 else {
461 return;
462 };
463 let data = base64_encode(pssh_data);
464 if info.pssh.is_none() {
465 info.pssh = Some(data.clone());
466 info.pssh_system = Some(system);
467 }
468 info.psshs.push(PsshInfo { system, data });
469 if system == PsshSystem::Widevine
470 && info.kid.as_deref() == Some(ZERO_KID)
471 && let Some(kid) = pssh_data.get(2..18)
472 {
473 info.kid = Some(bytes_to_hex_lower(kid));
474 info.is_multi_drm = true;
475 }
476}
477
478fn pssh_system(system_id: &[u8]) -> Option<PsshSystem> {
479 match system_id {
480 value if value == WIDEVINE_SYSTEM_ID.as_slice() => Some(PsshSystem::Widevine),
481 value if value == PLAYREADY_SYSTEM_ID.as_slice() => Some(PsshSystem::PlayReady),
482 value if value == FAIRPLAY_SYSTEM_ID.as_slice() => Some(PsshSystem::FairPlay),
483 _ => None,
484 }
485}
486
487fn read_encryption_sample_entry(data: &[u8], info: &mut Mp4ProtectionInfo) {
488 if let Some(index) = find_subslice(data, b"schm")
489 && let Some(scheme) = data.get(index + 8..index + 12)
490 {
491 info.scheme = Some(String::from_utf8_lossy(scheme).to_string());
492 }
493 if let Some(index) = find_subslice(data, b"tenc")
494 && let Some(kid) = data.get(index + 12..index + 28)
495 {
496 info.kid = Some(bytes_to_hex_lower(kid));
497 }
498}
499
500fn find_subslice(data: &[u8], needle: &[u8]) -> Option<usize> {
501 data.windows(needle.len())
502 .position(|window| window == needle)
503}
504
505fn redact_key_pair_argument(key: &str) -> String {
506 match key.split_once(':') {
507 Some((kid, value)) => format!("--key {kid}:{}", redact_key(value)),
508 None => format!("--key {}", redact_key(key)),
509 }
510}
511
512fn redact_key(value: &str) -> String {
513 if value.is_empty() {
514 "<redacted>".to_string()
515 } else {
516 format!("<redacted:{}>", value.len())
517 }
518}
519
520fn redact_after_marker(input: &str, marker: &str) -> String {
521 let mut output = String::new();
522 let mut rest = input;
523 while let Some(index) = rest.find(marker) {
524 let before = rest.get(..index).unwrap_or_default();
525 output.push_str(before);
526 output.push_str(marker);
527 output.push_str("<redacted>");
528 let after_marker = index + marker.len();
529 let tail = rest.get(after_marker..).unwrap_or_default();
530 let end = tail
531 .find(|ch: char| ch.is_whitespace() || ch == '&' || ch == ',' || ch == '"')
532 .unwrap_or(tail.len());
533 rest = tail.get(end..).unwrap_or_default();
534 }
535 output.push_str(rest);
536 output
537}
538
539fn redact_query_value(input: &str, marker: &str) -> String {
540 let mut output = String::new();
541 let mut rest = input;
542 while let Some(index) = rest.find(marker) {
543 output.push_str(rest.get(..index).unwrap_or_default());
544 output.push_str(marker);
545 output.push_str("<redacted>");
546 let tail = rest.get(index + marker.len()..).unwrap_or_default();
547 let end = tail.find('&').unwrap_or(tail.len());
548 rest = tail.get(end..).unwrap_or_default();
549 }
550 output.push_str(rest);
551 output
552}
553
554fn bytes_to_hex_lower(bytes: &[u8]) -> String {
555 const HEX: &[u8; 16] = b"0123456789abcdef";
556 let mut output = String::with_capacity(bytes.len() * 2);
557 for byte in bytes {
558 output.push(char::from(HEX[usize::from(byte >> 4)]));
559 output.push(char::from(HEX[usize::from(byte & 0x0f)]));
560 }
561 output
562}
563
564fn base64_encode(bytes: &[u8]) -> String {
565 const TABLE: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
566 let mut output = String::new();
567 for chunk in bytes.chunks(3) {
568 let b0 = chunk[0];
569 let b1 = *chunk.get(1).unwrap_or(&0);
570 let b2 = *chunk.get(2).unwrap_or(&0);
571 let triple = (u32::from(b0) << 16) | (u32::from(b1) << 8) | u32::from(b2);
572 output.push(char::from(TABLE[((triple >> 18) & 0x3f) as usize]));
573 output.push(char::from(TABLE[((triple >> 12) & 0x3f) as usize]));
574 if chunk.len() > 1 {
575 output.push(char::from(TABLE[((triple >> 6) & 0x3f) as usize]));
576 } else {
577 output.push('=');
578 }
579 if chunk.len() > 2 {
580 output.push(char::from(TABLE[(triple & 0x3f) as usize]));
581 } else {
582 output.push('=');
583 }
584 }
585 output
586}