Skip to main content

dash_mpd/
decryption.rs

1//! Support for decrypting media content
2//
3// We provide implementations for decrypting using the following helper applications:
4//
5//   - the historical mp4decrypt application from the Bento4 suite
6//   - shaka-packager
7//   - shaka-packager running in a Podman/Docker container
8//   - MP4Box from the GPAC suite
9//   - MP4Box from the official GPAC Podman/Docker container
10//
11// The options for running a helper application in a container rely on being able to run the
12// container in rootless mode, to ensure that the decypted media files are owned by the user running
13// our library. This is the default configuration for Podman, so we default to using that. It is
14// possible to configure Docker to run in rootless mode; if you prefer to use Docker you can set the
15// DOCKER environment variable to "docker".
16
17
18use std::env;
19use std::path::Path;
20use std::process::Command;
21use std::ffi::OsStr;
22use tokio::fs;
23use tracing::{info, warn, error};
24use crate::DashMpdError;
25use crate::fetch::{DashDownloader, partial_process_output, tmp_file_path};
26
27
28pub async fn decrypt_mp4decrypt(
29    downloader: &DashDownloader,
30    inpath: &Path,
31    outpath: &Path,
32    media_type: &str) -> Result<(), DashMpdError>
33{
34    let mut args = Vec::new();
35    for (k, v) in downloader.decryption_keys.iter() {
36        args.push("--key".to_string());
37        args.push(format!("{k}:{v}"));
38    }
39    args.push(inpath.to_string_lossy().to_string());
40    args.push(outpath.to_string_lossy().to_string());
41    if downloader.verbosity > 1 {
42        info!("  Running mp4decrypt {}", args.join(" "));
43    }
44    let out = Command::new(downloader.mp4decrypt_location.clone())
45        .args(args)
46        .output()
47        .map_err(|e| DashMpdError::Io(e, String::from("spawning mp4decrypt")))?;
48    let mut no_output = false;
49    if let Ok(metadata) = fs::metadata(outpath).await {
50        if downloader.verbosity > 0 {
51            info!("  Decrypted {media_type} stream of size {} kB.", metadata.len() / 1024);
52        }
53        if metadata.len() == 0 {
54            no_output = true;
55        }
56    } else {
57        no_output = true;
58    }
59    if !out.status.success() || no_output {
60        error!("  mp4decrypt subprocess failed");
61        let msg = partial_process_output(&out.stdout);
62        if !msg.is_empty() {
63            warn!("  mp4decrypt stdout: {msg}");
64        }
65        let msg = partial_process_output(&out.stderr);
66        if !msg.is_empty() {
67            warn!("  mp4decrypt stderr: {msg}");
68        }
69    }
70    if no_output {
71        error!("  Failed to decrypt {media_type} stream with mp4decrypt");
72        warn!("  Undecrypted {media_type} stream left in {}", inpath.display());
73        return Err(DashMpdError::Decrypting(format!("{media_type} stream")));
74    }
75    Ok(())
76}
77
78
79pub async fn decrypt_shaka(
80    downloader: &DashDownloader,
81    inpath: &Path,
82    outpath: &Path,
83    media_type: &str) -> Result<(), DashMpdError>
84{
85    let mut args = Vec::new();
86    let mut keys = Vec::new();
87    if downloader.verbosity < 1 {
88        args.push("--quiet".to_string());
89    }
90    args.push(format!("in={},stream={media_type},output={}", inpath.display(), outpath.display()));
91    let mut drm_label = 0;
92    #[allow(clippy::explicit_counter_loop)]
93    for (k, v) in downloader.decryption_keys.iter() {
94        keys.push(format!("label=lbl{drm_label}:key_id={k}:key={v}"));
95        drm_label += 1;
96    }
97    args.push("--enable_raw_key_decryption".to_string());
98    args.push("--keys".to_string());
99    args.push(keys.join(","));
100    if downloader.verbosity > 1 {
101        info!("  Running shaka-packager {}", args.join(" "));
102    }
103    let out = Command::new(downloader.shaka_packager_location.clone())
104        .args(args)
105        .output()
106        .map_err(|e| DashMpdError::Io(e, String::from("spawning shaka-packager")))?;
107    let mut no_output = true;
108    if let Ok(metadata) = fs::metadata(outpath).await {
109        if downloader.verbosity > 0 {
110            info!("  Decrypted {media_type} stream of size {} kB.", metadata.len() / 1024);
111        }
112        no_output = false;
113    }
114    if !out.status.success() || no_output {
115        warn!("  shaka-packager subprocess failed");
116        let msg = partial_process_output(&out.stdout);
117        if !msg.is_empty() {
118            warn!("  shaka-packager stdout: {msg}");
119        }
120        let msg = partial_process_output(&out.stderr);
121        if !msg.is_empty() {
122            warn!("  shaka-packager stderr: {msg}");
123        }
124    }
125    if no_output {
126        error!("  Failed to decrypt {media_type} stream with shaka-packager");
127        warn!("  Undecrypted {media_type} left in {}", inpath.display());
128        return Err(DashMpdError::Decrypting(format!("{media_type} stream")));
129    }
130    Ok(())
131}
132
133
134// Run shaka-packager via its official Docker container, as per
135// https://github.com/shaka-project/shaka-packager/blob/main/docs/source/docker_instructions.md
136//
137// Given the complexity of Podman/Docker arguments, this would be a good candidate for a plugin
138// mechanism or use of a scripting language.
139pub async fn decrypt_shaka_container(
140    downloader: &DashDownloader,
141    inpath: &Path,
142    outpath: &Path,
143    media_type: &str) -> Result<(), DashMpdError>
144{
145    // We need to pass inpath and outpath into the container, in a manner which works both on Linux
146    // and on Windows. We assume the container is a Linux container. We can't map outpath directly
147    // in Docker/Podman using the -v argument, because outpath does not exist yet. We know that both
148    // inpath and outpath are created in the same system temporary directory (they are created using
149    // tmp_file_path, which uses the tempfile crate). The solution chosen here is to map the
150    // temporary directory of the host (the parent directory of the inpath) to /tmp in the Linux
151    // container, and in the container to refer to files in /tmp with the same filenames as on the
152    // host.
153    let inpath_dir = inpath.parent()
154        .ok_or_else(|| DashMpdError::Decrypting(String::from("inpath parent")))?;
155    let inpath_nondir = inpath.file_name()
156        .ok_or_else(|| DashMpdError::Decrypting(String::from("inpath file name")))?;
157    let outpath_nondir = outpath.file_name()
158        .ok_or_else(|| DashMpdError::Decrypting(String::from("outpath file name")))?;
159    let mut args = Vec::new();
160    let mut keys = Vec::new();
161    args.push(String::from("run"));
162    args.push(String::from("--rm"));
163    args.push(String::from("--network=none"));
164    args.push(String::from("--userns=keep-id"));
165    args.push(String::from("-v"));
166    args.push(format!("{}:/tmp", inpath_dir.display()));
167    args.push(String::from("docker.io/google/shaka-packager:latest"));
168    args.push(String::from("packager"));
169    // Without the --quiet option, shaka-packager prints debugging output to stderr
170    args.push("--quiet".to_string());
171    args.push(format!("in=/tmp/{},stream={media_type},output=/tmp/{}",
172                      inpath_nondir.display(), outpath_nondir.display()));
173    let mut drm_label = 0;
174    #[allow(clippy::explicit_counter_loop)]
175    for (k, v) in downloader.decryption_keys.iter() {
176        keys.push(format!("label=lbl{drm_label}:key_id={k}:key={v}"));
177        drm_label += 1;
178    }
179    args.push("--enable_raw_key_decryption".to_string());
180    args.push("--keys".to_string());
181    args.push(keys.join(","));
182    if downloader.verbosity > 1 {
183        info!("  Running shaka-packager container {}", args.join(" "));
184    }
185    // TODO: make container runner a DashDownloader option.
186    // TODO: perhaps use the bollard crate to use Docker API.
187    let container_runtime = env::var("DOCKER").unwrap_or(String::from("podman"));
188    let pull = Command::new(&container_runtime)
189        .args(["pull", "docker.io/google/shaka-packager:latest"])
190        .output()
191        .map_err(|e| DashMpdError::Decrypting(format!("pulling shaka-packager container: {e:?}")))?;
192    if !pull.status.success() {
193        error!("  Unable to pull shaka-packager decryption container with {container_runtime}");
194        let msg = partial_process_output(&pull.stdout);
195        if !msg.is_empty() {
196            info!("  {container_runtime} stdout: {msg}");
197        }
198        let msg = partial_process_output(&pull.stderr);
199        if !msg.is_empty() {
200            info!("  {container_runtime} stderr: {msg}");
201        }
202        return Err(DashMpdError::Decrypting(String::from("pulling container docker.io/google/shaka-packager:latest")));
203    }
204    let runner = Command::new(&container_runtime)
205        .args(args)
206        .output()
207        .map_err(|e| DashMpdError::Decrypting(format!("running shaka-packager container: {e:?}")))?;
208    let mut no_output = false;
209    if let Ok(metadata) = fs::metadata(outpath).await {
210        if downloader.verbosity > 0 {
211            info!("  Decrypted {media_type} stream of size {} kB.", metadata.len() / 1024);
212        }
213        no_output = false;
214    }
215    if !runner.status.success() || no_output {
216        warn!("  shaka-packager container failed");
217        let msg = partial_process_output(&runner.stdout);
218        if !msg.is_empty() {
219            warn!("  shaka-packager stdout: {msg}");
220        }
221        let msg = partial_process_output(&runner.stderr);
222        if !msg.is_empty() {
223            warn!("  shaka-packager stderr: {msg}");
224        }
225    }
226    if no_output {
227        error!("  Failed to decrypt {media_type} stream with shaka-packager container");
228        error!("  Undecrypted {media_type} left in {}", inpath.display());
229        return Err(DashMpdError::Decrypting(format!("{media_type} stream")));
230    }
231    Ok(())
232}
233
234
235// Decrypt with MP4Box as per https://wiki.gpac.io/xmlformats/Common-Encryption/
236//    MP4Box -decrypt drm_file.xml encrypted.mp4 -out decrypted.mp4
237pub async fn decrypt_mp4box(
238    downloader: &DashDownloader,
239    inpath: &Path,
240    outpath: &Path,
241    media_type: &str) -> Result<(), DashMpdError>
242{
243    let mut args = Vec::new();
244    let drmfile = tmp_file_path("mp4boxcrypt", OsStr::new("xml"))?;
245    let mut drmfile_contents = String::from("<GPACDRM>\n  <CrypTrack>\n");
246    for (k, v) in downloader.decryption_keys.iter() {
247        drmfile_contents += &format!("  <key KID=\"0x{k}\" value=\"0x{v}\"/>\n");
248    }
249    drmfile_contents += "  </CrypTrack>\n</GPACDRM>\n";
250    fs::write(&drmfile, drmfile_contents).await
251        .map_err(|e| DashMpdError::Io(e, String::from("writing to MP4Box decrypt file")))?;
252    args.push("-decrypt".to_string());
253    args.push(drmfile.display().to_string());
254    args.push(String::from(inpath.to_string_lossy()));
255    args.push("-out".to_string());
256    args.push(String::from(outpath.to_string_lossy()));
257    if downloader.verbosity > 1 {
258        info!("  Running decryption application MP4Box {}", args.join(" "));
259    }
260    let out = Command::new(downloader.mp4box_location.clone())
261        .args(args)
262        .output()
263        .map_err(|e| DashMpdError::Decrypting(format!("spawning MP4Box: {e:?}")))?;
264    if env::var("DASHMPD_PERSIST_FILES").is_err() {
265	if let Err(e) = fs::remove_file(drmfile).await {
266            warn!("  Error deleting temporary mp4boxcrypt file: {e}");
267        }
268    }
269    let mut no_output = false;
270    if let Ok(metadata) = fs::metadata(outpath).await {
271        if downloader.verbosity > 0 {
272            info!("  Decrypted {media_type} stream of size {} kB.", metadata.len() / 1024);
273        }
274        if metadata.len() == 0 {
275            no_output = true;
276        }
277    } else {
278        no_output = true;
279    }
280    if !out.status.success() || no_output {
281        warn!("  MP4Box decryption subprocess failed");
282        let msg = partial_process_output(&out.stdout);
283        if !msg.is_empty() {
284            warn!("  MP4Box stdout: {msg}");
285        }
286        let msg = partial_process_output(&out.stderr);
287        if !msg.is_empty() {
288            warn!("  MP4Box stderr: {msg}");
289        }
290    }
291    if no_output {
292        error!("  Failed to decrypt {media_type} with MP4Box");
293        warn!("  Undecrypted {media_type} stream left in {}", inpath.display());
294        return Err(DashMpdError::Decrypting(format!("{media_type} stream")));
295    }
296    Ok(())
297}
298
299
300// Decrypt using MP4Box from the GPAC suite, using their official Docker/Podman container.
301pub async fn decrypt_mp4box_container(
302    downloader: &DashDownloader,
303    inpath: &Path,
304    outpath: &Path,
305    media_type: &str) -> Result<(), DashMpdError>
306{
307    let inpath_dir = inpath.parent()
308        .ok_or_else(|| DashMpdError::Decrypting(String::from("inpath parent")))?;
309    let inpath_nondir = inpath.file_name()
310        .ok_or_else(|| DashMpdError::Decrypting(String::from("inpath file name")))?;
311    let outpath_nondir = outpath.file_name()
312        .ok_or_else(|| DashMpdError::Decrypting(String::from("outpath file name")))?;
313    let mut args = Vec::new();
314    let drmpath = tmp_file_path("mp4boxcrypt", OsStr::new("xml"))?;
315    let drmpath_nondir = drmpath.file_name()
316        .ok_or_else(|| DashMpdError::Decrypting(String::from("drmpath file name")))?;
317    let mut drm_contents = String::from("<GPACDRM>\n  <CrypTrack>\n");
318    for (k, v) in downloader.decryption_keys.iter() {
319        drm_contents += &format!("  <key KID=\"0x{k}\" value=\"0x{v}\"/>\n");
320    }
321    drm_contents += "  </CrypTrack>\n</GPACDRM>\n";
322    fs::write(&drmpath, drm_contents).await
323        .map_err(|e| DashMpdError::Io(e, String::from("writing to MP4Box decrypt file")))?;
324    args.push(String::from("run"));
325    args.push(String::from("--rm"));
326    args.push(String::from("--network=none"));
327    args.push(String::from("--userns=keep-id"));
328    args.push(String::from("-v"));
329    args.push(format!("{}:/tmp", inpath_dir.display()));
330    args.push(String::from("docker.io/gpac/ubuntu:latest"));
331    args.push(String::from("MP4Box"));
332    args.push("-decrypt".to_string());
333    args.push(format!("/tmp/{}", drmpath_nondir.display()));
334    args.push(format!("/tmp/{}", inpath_nondir.display()));
335    args.push("-out".to_string());
336    args.push(format!("/tmp/{}", outpath_nondir.display()));
337    if downloader.verbosity > 1 {
338        info!("  Running decryption container GPAC/MP4Box {}", args.join(" "));
339    }
340    let container_runtime = env::var("DOCKER").unwrap_or(String::from("podman"));
341    let pull = Command::new(&container_runtime)
342        .args(["pull", "docker.io/gpac/ubuntu:latest"])
343        .output()
344        .map_err(|e| DashMpdError::Decrypting(format!("pulling MP4Box container: {e:?}")))?;
345    if !pull.status.success() {
346        warn!("  Unable to pull MP4Box decryption container");
347        return Err(DashMpdError::Decrypting(String::from("pulling container docker.io/gpac/ubuntu:latest")));
348    }
349    let runner = Command::new(&container_runtime)
350        .args(args)
351        .output()
352        .map_err(|e| DashMpdError::Decrypting(format!("spawning MP4Box container: {e:?}")))?;
353    let mut no_output = false;
354    if let Ok(metadata) = fs::metadata(&outpath).await {
355        if downloader.verbosity > 0 {
356            info!("  Decrypted {media_type} stream of size {} kB.", metadata.len() / 1024);
357        }
358        if metadata.len() == 0 {
359            no_output = true;
360        }
361    } else {
362        no_output = true;
363    }
364    if !runner.status.success() || no_output {
365        warn!("  MP4Box decryption container failed");
366        let msg = partial_process_output(&runner.stdout);
367        if !msg.is_empty() {
368            warn!("  MP4Box stdout: {msg}");
369        }
370        let msg = partial_process_output(&runner.stderr);
371        if !msg.is_empty() {
372            warn!("  MP4Box stderr: {msg}");
373        }
374    }
375    if no_output {
376        error!("  Failed to decrypt {media_type} with MP4Box container");
377        error!("  Undecrypted {media_type} stream left in {}", inpath.display());
378        return Err(DashMpdError::Decrypting(format!("{media_type} stream")));
379    }
380    Ok(())
381}