diff --git a/tests/tracks/test_encrypted_sample_entry.py b/tests/tracks/test_encrypted_sample_entry.py index 9a9c70d2..63309f8e 100644 --- a/tests/tracks/test_encrypted_sample_entry.py +++ b/tests/tracks/test_encrypted_sample_entry.py @@ -1,5 +1,5 @@ """Self-check for ``has_encrypted_sample_entry``: a finalized MP4 whose moov still carries -an encv/enca sample entry means the decrypt tool skipped/failed the track (frma was never +an encrypted (enc*/drm*) sample entry means the decrypt tool skipped/failed the track (frma was never applied), so the detector must flag it before the file reaches the muxer. Detection is scoped to moov, so stray 4CC byte runs in mdat must never trip (or mask) it. @@ -11,6 +11,8 @@ from __future__ import annotations import struct from pathlib import Path +import pytest + from unshackle.core.tracks.track import has_encrypted_sample_entry @@ -80,6 +82,12 @@ def test_text_containing_enca_in_moov_not_detected(tmp_path: Path) -> None: assert has_encrypted_sample_entry(f) is False +@pytest.mark.parametrize("kind", [b"encs", b"enct", b"drmi"]) +def test_other_encrypted_entry_kinds_detected(tmp_path: Path, kind: bytes) -> None: + f = write(tmp_path, "enc.mp4", FTYP, moov(stsd([sample_entry(kind, box(b"sinf", box(b"frma", b"stpp")))]))) + assert has_encrypted_sample_entry(f) is True + + def test_missing_or_bogus_file(tmp_path: Path) -> None: assert has_encrypted_sample_entry(tmp_path / "nope.mp4") is False bogus = write(tmp_path, "bogus.bin", b"\x00\x01\x02\x03not-an-mp4") diff --git a/unshackle/core/drm/verify.py b/unshackle/core/drm/verify.py index f39b6db2..8e54388b 100644 --- a/unshackle/core/drm/verify.py +++ b/unshackle/core/drm/verify.py @@ -11,6 +11,7 @@ PIFF_TENC = bytes.fromhex("8974dbce7be74c5184f97148f9882554") PIFF_TFXD = bytes.fromhex("6d1d9b0542d544e680e2141daff757b2") PIFF_SENC = bytes.fromhex("a2394f525a9b4f14a2446c427c648df4") TENC = {b"tenc", b"uuid" + PIFF_TENC} +ENCRYPTED_ENTRY = (b"enc", b"drm") SENC = {b"senc", b"uuid" + PIFF_SENC} # the fixed sample entry fields before the child boxes: visual; sound v0 (and ISO v1), QuickTime sound v1 and v2 ENTRY_FIELDS = {b"encv": (78,), b"enca": (28, 44, 64)} @@ -130,7 +131,15 @@ def sample_entries(data: bytes, body: int, end: int) -> tuple[list[Optional[UUID whether any entry is encrypted.""" found = list(children(data, body + 8, end)) kids = [entry_kid(data, kind, entry, entry_end) for kind, entry, entry_end in found] - return kids, any(kind[:3] in (b"enc", b"drm") for kind, _, _ in found) + return kids, any(kind[:3] in ENCRYPTED_ENTRY for kind, _, _ in found) + + +def has_encrypted_entry(moov: bytes) -> bool: + """True if a moov box holds a sample entry of an encrypted type (``enc*`` or ``drm*``).""" + return any( + kind == b"stsd" and any(entry[:3] in ENCRYPTED_ENTRY for entry, _, _ in children(moov, body + 8, end)) + for kind, body, end in walk(moov) + ) def senc_protects_samples(buf: bytes, body: int, end: int) -> bool: diff --git a/unshackle/core/tracks/track.py b/unshackle/core/tracks/track.py index cbeb4c0e..0e8c9fba 100644 --- a/unshackle/core/tracks/track.py +++ b/unshackle/core/tracks/track.py @@ -30,7 +30,7 @@ from unshackle.core.config import config from unshackle.core.constants import DOWNLOAD_ALL_DRM, DOWNLOAD_CANCELLED, DOWNLOAD_LICENCE_ONLY, DownloadCancelled from unshackle.core.downloaders import requests from unshackle.core.drm import DRM_T, ClearKeyCENC, PlayReady, Widevine -from unshackle.core.drm.verify import decrypt_track, senc_protects_samples +from unshackle.core.drm.verify import decrypt_track, has_encrypted_entry, senc_protects_samples from unshackle.core.events import events from unshackle.core.session import MAX_BACKOFF, RnetSession, utf8_text from unshackle.core.temp import restore_segments, stash_segments @@ -244,28 +244,18 @@ def has_dts_uhd_sample_entry(path: Path) -> bool: def has_encrypted_sample_entry(path: Path) -> bool: - """True if the MP4's moov still carries an encrypted sample entry (encv/enca). + """True if the MP4's moov still carries an encrypted sample entry (``enc*`` or ``drm*``). - A faithful decrypt rewrites encv/enca back to the real codec 4CC through frma, so a - survivor means the decrypt tool skipped/failed the track. This is a 4CC scan - scoped to the moov box (where sample entries live), not a structural parse. - The scoping avoids chance byte collisions in mdat. Any read error -> False. + A faithful decrypt rewrites the entry back to the real codec 4CC through frma, so a + survivor means the decrypt tool skipped/failed the track. Only the moov is read, and its + stsd boxes are parsed, so byte runs in mdat or in metadata cannot match. Any read or + parse error -> False. """ try: moov = read_top_level_box(path, b"moov") - except OSError: + return moov is not None and has_encrypted_entry(moov) + except (OSError, struct.error, RecursionError): return False - if not moov: - return False - for tok in (b"encv", b"enca"): - i = moov.find(tok, 4) - while i != -1: - # require a plausible sample-entry box size right before the 4CC - size = int.from_bytes(moov[i - 4 : i], "big") - if 16 <= size <= len(moov) - (i - 4): - return True - i = moov.find(tok, i + 1) - return False def moof_still_encrypted(moof: bytes) -> bool: