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 {
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)
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("--v".to_string());
91    args.push(format!("{}", downloader.verbosity));
92    args.push(format!("in={},stream={media_type},output={}", inpath.display(), outpath.display()));
93    let mut drm_label = 0;
94    #[allow(clippy::explicit_counter_loop)]
95    for (k, v) in &downloader.decryption_keys {
96        keys.push(format!("label=lbl{drm_label}:key_id={k}:key={v}"));
97        drm_label += 1;
98    }
99    args.push("--enable_raw_key_decryption".to_string());
100    args.push("--keys".to_string());
101    args.push(keys.join(","));
102    if downloader.verbosity > 1 {
103        info!("  Running shaka-packager {}", args.join(" "));
104    }
105    let out = Command::new(&downloader.shaka_packager_location)
106        .args(args)
107        .output()
108        .map_err(|e| DashMpdError::Io(e, String::from("spawning shaka-packager")))?;
109    let mut no_output = true;
110    if let Ok(metadata) = fs::metadata(outpath).await {
111        if downloader.verbosity > 0 {
112            info!("  Decrypted {media_type} stream of size {} kB.", metadata.len() / 1024);
113        }
114        no_output = false;
115    }
116    if !out.status.success() || no_output {
117        warn!("  shaka-packager subprocess failed");
118        let msg = partial_process_output(&out.stdout);
119        if !msg.is_empty() {
120            warn!("  shaka-packager stdout: {msg}");
121        }
122        let msg = partial_process_output(&out.stderr);
123        if !msg.is_empty() {
124            warn!("  shaka-packager stderr: {msg}");
125        }
126    }
127    if no_output {
128        error!("  Failed to decrypt {media_type} stream with shaka-packager");
129        warn!("  Undecrypted {media_type} left in {}", inpath.display());
130        return Err(DashMpdError::Decrypting(format!("{media_type} stream")));
131    }
132    Ok(())
133}
134
135
136// Run shaka-packager via its official Docker container, as per
137// https://github.com/shaka-project/shaka-packager/blob/main/docs/source/docker_instructions.md
138//
139// Given the complexity of Podman/Docker arguments, this would be a good candidate for a plugin
140// mechanism or use of a scripting language.
141pub async fn decrypt_shaka_container(
142    downloader: &DashDownloader,
143    inpath: &Path,
144    outpath: &Path,
145    media_type: &str) -> Result<(), DashMpdError>
146{
147    // We need to pass inpath and outpath into the container, in a manner which works both on Linux
148    // and on Windows. We assume the container is a Linux container. We can't map outpath directly
149    // in Docker/Podman using the -v argument, because outpath does not exist yet. We know that both
150    // inpath and outpath are created in the same system temporary directory (they are created using
151    // tmp_file_path, which uses the tempfile crate). The solution chosen here is to map the
152    // temporary directory of the host (the parent directory of the inpath) to /tmp in the Linux
153    // container, and in the container to refer to files in /tmp with the same filenames as on the
154    // host.
155    let inpath_dir = inpath.parent()
156        .ok_or_else(|| DashMpdError::Decrypting(String::from("inpath parent")))?;
157    let inpath_nondir = inpath.file_name()
158        .ok_or_else(|| DashMpdError::Decrypting(String::from("inpath file name")))?;
159    let outpath_nondir = outpath.file_name()
160        .ok_or_else(|| DashMpdError::Decrypting(String::from("outpath file name")))?;
161    let mut args = Vec::new();
162    let mut keys = Vec::new();
163    args.push(String::from("run"));
164    args.push(String::from("--rm"));
165    args.push(String::from("--network=none"));
166    args.push(String::from("--userns=keep-id"));
167    args.push(String::from("-v"));
168    args.push(format!("{}:/tmp", inpath_dir.display()));
169    args.push(String::from("docker.io/google/shaka-packager:latest"));
170    args.push(String::from("packager"));
171    // Without the --quiet option, shaka-packager prints debugging output to stderr
172    if downloader.verbosity < 1 {
173        args.push("--quiet".to_string());
174    }
175    args.push("--v".to_string());
176    args.push(format!("{}", downloader.verbosity));
177    args.push(format!("in=/tmp/{},stream={media_type},output=/tmp/{}",
178                      inpath_nondir.display(), outpath_nondir.display()));
179    let mut drm_label = 0;
180    #[allow(clippy::explicit_counter_loop)]
181    for (k, v) in &downloader.decryption_keys {
182        keys.push(format!("label=lbl{drm_label}:key_id={k}:key={v}"));
183        drm_label += 1;
184    }
185    args.push("--enable_raw_key_decryption".to_string());
186    args.push("--keys".to_string());
187    args.push(keys.join(","));
188    if downloader.verbosity > 1 {
189        info!("  Running shaka-packager container {}", args.join(" "));
190    }
191    // TODO: make container runner a DashDownloader option.
192    // TODO: perhaps use the bollard crate to use Docker API.
193    let container_runtime = env::var("DOCKER").unwrap_or(String::from("podman"));
194    let pull = Command::new(&container_runtime)
195        .args(["pull", "docker.io/google/shaka-packager:latest"])
196        .output()
197        .map_err(|e| DashMpdError::Decrypting(format!("pulling shaka-packager container: {e:?}")))?;
198    if !pull.status.success() {
199        error!("  Unable to pull shaka-packager decryption container with {container_runtime}");
200        let msg = partial_process_output(&pull.stdout);
201        if !msg.is_empty() {
202            info!("  {container_runtime} stdout: {msg}");
203        }
204        let msg = partial_process_output(&pull.stderr);
205        if !msg.is_empty() {
206            info!("  {container_runtime} stderr: {msg}");
207        }
208        return Err(DashMpdError::Decrypting(String::from("pulling container docker.io/google/shaka-packager:latest")));
209    }
210    let runner = Command::new(&container_runtime)
211        .args(args)
212        .output()
213        .map_err(|e| DashMpdError::Decrypting(format!("running shaka-packager container: {e:?}")))?;
214    let mut no_output = false;
215    if let Ok(metadata) = fs::metadata(outpath).await {
216        if downloader.verbosity > 0 {
217            info!("  Decrypted {media_type} stream of size {} kB.", metadata.len() / 1024);
218        }
219        no_output = false;
220    }
221    if !runner.status.success() || no_output {
222        warn!("  shaka-packager container failed");
223        let msg = partial_process_output(&runner.stdout);
224        if !msg.is_empty() {
225            warn!("  shaka-packager stdout: {msg}");
226        }
227        let msg = partial_process_output(&runner.stderr);
228        if !msg.is_empty() {
229            warn!("  shaka-packager stderr: {msg}");
230        }
231    }
232    if no_output {
233        error!("  Failed to decrypt {media_type} stream with shaka-packager container");
234        error!("  Undecrypted {media_type} left in {}", inpath.display());
235        return Err(DashMpdError::Decrypting(format!("{media_type} stream")));
236    }
237    Ok(())
238}
239
240
241// Decrypt with MP4Box as per https://wiki.gpac.io/xmlformats/Common-Encryption/
242//    MP4Box -decrypt drm_file.xml encrypted.mp4 -out decrypted.mp4
243pub async fn decrypt_mp4box(
244    downloader: &DashDownloader,
245    inpath: &Path,
246    outpath: &Path,
247    media_type: &str) -> Result<(), DashMpdError>
248{
249    use std::fmt::Write;
250
251    let mut args = Vec::new();
252    let drmfile = tmp_file_path("mp4boxcrypt", OsStr::new("xml"))?;
253    let mut drmfile_contents = String::from("<GPACDRM>\n  <CrypTrack>\n");
254    for (k, v) in &downloader.decryption_keys {
255        let _ = writeln!(drmfile_contents, "  <key KID=\"0x{k}\" value=\"0x{v}\"/>");
256    }
257    drmfile_contents += "  </CrypTrack>\n</GPACDRM>\n";
258    fs::write(&drmfile, drmfile_contents).await
259        .map_err(|e| DashMpdError::Io(e, String::from("writing to MP4Box decrypt file")))?;
260    let verbosity = match downloader.verbosity {
261        0 => "all@error",
262        1 => "all@warning",
263        2 => "all@info",
264        _ => "all@debug",
265    };
266    args.push("-logs".to_string());
267    args.push(verbosity.to_string());
268    args.push("-decrypt".to_string());
269    args.push(drmfile.display().to_string());
270    args.push(String::from(inpath.to_string_lossy()));
271    args.push("-out".to_string());
272    args.push(String::from(outpath.to_string_lossy()));
273    if downloader.verbosity > 1 {
274        info!("  Running decryption application MP4Box {}", args.join(" "));
275    }
276    let out = Command::new(&downloader.mp4box_location)
277        .args(args)
278        .output()
279        .map_err(|e| DashMpdError::Decrypting(format!("spawning MP4Box: {e:?}")))?;
280    if env::var("DASHMPD_PERSIST_FILES").is_err() {
281	if let Err(e) = fs::remove_file(drmfile).await {
282            warn!("  Error deleting temporary mp4boxcrypt file: {e}");
283        }
284    }
285    let mut no_output = false;
286    if let Ok(metadata) = fs::metadata(outpath).await {
287        if downloader.verbosity > 0 {
288            info!("  Decrypted {media_type} stream of size {} kB.", metadata.len() / 1024);
289        }
290        if metadata.len() == 0 {
291            no_output = true;
292        }
293    } else {
294        no_output = true;
295    }
296    if !out.status.success() || no_output {
297        warn!("  MP4Box decryption subprocess failed");
298        let msg = partial_process_output(&out.stdout);
299        if !msg.is_empty() {
300            warn!("  MP4Box stdout: {msg}");
301        }
302        let msg = partial_process_output(&out.stderr);
303        if !msg.is_empty() {
304            warn!("  MP4Box stderr: {msg}");
305        }
306    }
307    if no_output {
308        error!("  Failed to decrypt {media_type} with MP4Box");
309        warn!("  Undecrypted {media_type} stream left in {}", inpath.display());
310        return Err(DashMpdError::Decrypting(format!("{media_type} stream")));
311    }
312    Ok(())
313}
314
315
316// Decrypt using MP4Box from the GPAC suite, using their official Docker/Podman container.
317pub async fn decrypt_mp4box_container(
318    downloader: &DashDownloader,
319    inpath: &Path,
320    outpath: &Path,
321    media_type: &str) -> Result<(), DashMpdError>
322{
323    use std::fmt::Write;
324    
325    let inpath_dir = inpath.parent()
326        .ok_or_else(|| DashMpdError::Decrypting(String::from("inpath parent")))?;
327    let inpath_nondir = inpath.file_name()
328        .ok_or_else(|| DashMpdError::Decrypting(String::from("inpath file name")))?;
329    let outpath_nondir = outpath.file_name()
330        .ok_or_else(|| DashMpdError::Decrypting(String::from("outpath file name")))?;
331    let mut args = Vec::new();
332    let drmpath = tmp_file_path("mp4boxcrypt", OsStr::new("xml"))?;
333    let drmpath_nondir = drmpath.file_name()
334        .ok_or_else(|| DashMpdError::Decrypting(String::from("drmpath file name")))?;
335    let mut drm_contents = String::from("<GPACDRM>\n  <CrypTrack>\n");
336    for (k, v) in &downloader.decryption_keys {
337        let _ = writeln!(drm_contents, "  <key KID=\"0x{k}\" value=\"0x{v}\"/>");
338    }
339    drm_contents += "  </CrypTrack>\n</GPACDRM>\n";
340    fs::write(&drmpath, drm_contents).await
341        .map_err(|e| DashMpdError::Io(e, String::from("writing to MP4Box decrypt file")))?;
342    args.push(String::from("run"));
343    args.push(String::from("--rm"));
344    args.push(String::from("--network=none"));
345    args.push(String::from("--userns=keep-id"));
346    args.push(String::from("-v"));
347    args.push(format!("{}:/tmp", inpath_dir.display()));
348    args.push(String::from("docker.io/gpac/ubuntu:latest"));
349    args.push(String::from("MP4Box"));
350    args.push("-decrypt".to_string());
351    args.push(format!("/tmp/{}", drmpath_nondir.display()));
352    args.push(format!("/tmp/{}", inpath_nondir.display()));
353    args.push("-out".to_string());
354    args.push(format!("/tmp/{}", outpath_nondir.display()));
355    if downloader.verbosity > 1 {
356        info!("  Running decryption container GPAC/MP4Box {}", args.join(" "));
357    }
358    let container_runtime = env::var("DOCKER").unwrap_or(String::from("podman"));
359    let pull = Command::new(&container_runtime)
360        .args(["pull", "docker.io/gpac/ubuntu:latest"])
361        .output()
362        .map_err(|e| DashMpdError::Decrypting(format!("pulling MP4Box container: {e:?}")))?;
363    if !pull.status.success() {
364        warn!("  Unable to pull MP4Box decryption container");
365        return Err(DashMpdError::Decrypting(String::from("pulling container docker.io/gpac/ubuntu:latest")));
366    }
367    let runner = Command::new(&container_runtime)
368        .args(args)
369        .output()
370        .map_err(|e| DashMpdError::Decrypting(format!("spawning MP4Box container: {e:?}")))?;
371    if env::var("DASHMPD_PERSIST_FILES").is_err() {
372	if let Err(e) = fs::remove_file(drmpath).await {
373            warn!("  Error deleting temporary mp4boxcrypt file: {e}");
374        }
375    }
376    let mut no_output = false;
377    if let Ok(metadata) = fs::metadata(&outpath).await {
378        if downloader.verbosity > 0 {
379            info!("  Decrypted {media_type} stream of size {} kB.", metadata.len() / 1024);
380        }
381        if metadata.len() == 0 {
382            no_output = true;
383        }
384    } else {
385        no_output = true;
386    }
387    if !runner.status.success() || no_output {
388        warn!("  MP4Box decryption container failed");
389        let msg = partial_process_output(&runner.stdout);
390        if !msg.is_empty() {
391            warn!("  MP4Box stdout: {msg}");
392        }
393        let msg = partial_process_output(&runner.stderr);
394        if !msg.is_empty() {
395            warn!("  MP4Box stderr: {msg}");
396        }
397    }
398    if no_output {
399        error!("  Failed to decrypt {media_type} with MP4Box container");
400        error!("  Undecrypted {media_type} stream left in {}", inpath.display());
401        return Err(DashMpdError::Decrypting(format!("{media_type} stream")));
402    }
403    Ok(())
404}