Skip to main content

haki_dl/
decrypt.rs

1//! Decryption helpers and external decrypt command planning.
2
3use 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/// Parsed MP4 initialization encryption metadata.
26#[derive(Clone, Debug, Default, Eq, PartialEq)]
27pub struct Mp4ProtectionInfo {
28    /// First retained PSSH payload in base64, kept for compatibility.
29    pub pssh: Option<String>,
30    /// DRM system identified for the first retained PSSH payload.
31    pub pssh_system: Option<PsshSystem>,
32    /// Retained PSSH payloads for recognized DRM system boxes.
33    pub psshs: Vec<PsshInfo>,
34    /// KID in lowercase hex.
35    pub kid: Option<String>,
36    /// Common-encryption scheme such as cenc/cbcs when present.
37    pub scheme: Option<String>,
38    /// Whether a Widevine PSSH identified a multi-DRM init.
39    pub is_multi_drm: bool,
40}
41
42/// DRM system identified for a retained PSSH payload.
43#[derive(Clone, Copy, Debug, Eq, PartialEq)]
44pub enum PsshSystem {
45    /// Widevine system ID.
46    Widevine,
47    /// PlayReady system ID.
48    PlayReady,
49    /// FairPlay system ID.
50    FairPlay,
51}
52
53/// Parsed PSSH payload associated with a recognized DRM system ID.
54#[derive(Clone, Debug, Eq, PartialEq)]
55pub struct PsshInfo {
56    /// DRM system identified by the PSSH system ID.
57    pub system: PsshSystem,
58    /// PSSH data payload in base64.
59    pub data: String,
60}
61
62/// Selected key material for an MP4 decrypt command.
63#[derive(Clone, Debug, Eq, PartialEq)]
64pub struct SelectedKey {
65    /// Optional track ID override.
66    pub track_id: Option<String>,
67    /// Normalized key pair as `kid:key` or `key`.
68    pub key_pair: String,
69}
70
71/// Redacted external decrypt command plan.
72#[derive(Clone, Debug, Eq, PartialEq)]
73pub struct ExternalDecryptPlan {
74    /// Requested decrypt engine.
75    pub engine: DecryptionEngine,
76    /// Program path.
77    pub program: PathBuf,
78    /// Redacted command arguments suitable for logs and diagnostics.
79    pub redacted_arguments: String,
80    /// Optional working directory.
81    pub work_dir: Option<PathBuf>,
82    /// Whether source and init must be concatenated before running the engine.
83    pub requires_init_concat: bool,
84}
85
86/// Input for planning an external MP4 decrypt command.
87#[derive(Clone, Copy, Debug)]
88pub struct ExternalDecryptRequest<'a> {
89    /// Requested decrypt engine.
90    pub engine: DecryptionEngine,
91    /// Program path.
92    pub program: &'a Path,
93    /// Available key pairs in engine-compatible text form.
94    pub keys: &'a [String],
95    /// Encrypted source path.
96    pub source: &'a Path,
97    /// Decrypted destination path.
98    pub dest: &'a Path,
99    /// Optional KID in lowercase hex.
100    pub kid: Option<&'a str>,
101    /// Optional initialization segment path.
102    pub init: Option<&'a Path>,
103    /// Whether the source metadata indicates multi-DRM handling.
104    pub is_multi_drm: bool,
105}
106
107/// Decrypts AES-128-CBC data with PKCS padding.
108pub 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
117/// Decrypts AES-128-ECB data with PKCS padding.
118pub 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
127/// Applies the in-place HLS segment decrypt behavior for supported methods.
128pub 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
154/// Decrypts a file in place with an HLS segment decrypt method.
155pub 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
167/// Decrypts data by reinitializing ChaCha20 at counter zero for every 1024-byte chunk.
168pub 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
196/// Converts typed custom key entries into the string form consumed by external engines.
197pub 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
207/// Searches a key text file for a line that starts with the requested KID.
208pub 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
235/// Reads MP4 protection metadata from initialization bytes.
236pub fn read_mp4_protection_info(data: &[u8]) -> Mp4ProtectionInfo {
237    let mut info = Mp4ProtectionInfo::default();
238    scan_boxes(data, &mut info);
239    info
240}
241
242/// Selects a key pair using MP4 decrypt compatibility rules.
243pub 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
271/// Plans an external MP4 decrypt command with redacted arguments.
272pub 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
378/// Redacts keys and common signed URL query values from diagnostics.
379pub 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}