From a24c9b92daeb02c0853a80ee20bdf3157879b161 Mon Sep 17 00:00:00 2001 From: imSp4rky Date: Mon, 27 Jul 2026 13:11:13 -0600 Subject: [PATCH] fix(tracks): ignore leftover PIFF uuid when verifying decryption A decrypter detaches only the atom it consumed, so content shipping both boxes keeps the uuid through a good decrypt and failed on mp4decrypt. --- tests/tracks/test_fragment_decrypted.py | 40 ++++++++++++++++--------- unshackle/core/tracks/track.py | 15 ++++++---- 2 files changed, 36 insertions(+), 19 deletions(-) diff --git a/tests/tracks/test_fragment_decrypted.py b/tests/tracks/test_fragment_decrypted.py index f92cd8ff..6396b4fa 100644 --- a/tests/tracks/test_fragment_decrypted.py +++ b/tests/tracks/test_fragment_decrypted.py @@ -1,7 +1,9 @@ """Self-check for ``assert_fragments_decrypted``: after decryption every moof must have lost -its sample-encryption box. A survivor means the tool silently skipped that fragment (the +its standard senc. A survivor means the tool silently skipped that fragment (the tfhd.sample_description_index ceiling bug), which the moov-scoped has_encrypted_sample_entry -structurally cannot see. The guard never reads mdat, so stray byte runs there leave it unmoved. +structurally cannot see. A leftover PIFF uuid survives a good decrypt too, because content +shipping both boxes keeps it: a decrypter detaches only the atom it consumed. +The guard never reads mdat, so stray byte runs there leave it unmoved. Run: uv run pytest tests/tracks/test_fragment_decrypted.py """ @@ -53,11 +55,11 @@ def test_all_fragments_decrypted_passes(tmp_path: Path) -> None: assert_fragments_decrypted(f) -def test_surviving_piff_senc_raises(tmp_path: Path) -> None: +def test_surviving_senc_raises(tmp_path: Path) -> None: # the real bug: fragment 1 (index 1) decrypted, the rest (index 2) silently skipped boxes = [FTYP, MOOV, _moof(), MDAT] for _ in range(3): - boxes += [_moof(_piff_senc(), sdi=2), MDAT] + boxes += [_moof(_senc(), sdi=2), MDAT] f = _write(tmp_path, "skipped.mp4", *boxes) with pytest.raises(ValueError, match=r"3/4 fragment"): assert_fragments_decrypted(f) @@ -69,6 +71,16 @@ def test_surviving_cenc_senc_raises(tmp_path: Path) -> None: assert_fragments_decrypted(f) +def test_leftover_piff_uuid_after_senc_removed_does_not_raise(tmp_path: Path) -> None: + # live DASH shape: the traf shipped senc+saiz+saio plus a duplicate PIFF uuid. mp4decrypt + # consumed senc, detached senc/saiz/saio, and left the uuid on a fully decrypted fragment. + boxes = [FTYP, MOOV] + for _ in range(3): + boxes += [_moof(_piff_senc()), MDAT] + f = _write(tmp_path, "piff_leftover.mp4", *boxes) + assert_fragments_decrypted(f) + + def test_stray_senc_bytes_in_mdat_ignored(tmp_path: Path) -> None: f = _write(tmp_path, "stray.mp4", FTYP, MOOV, _moof(), STRAY, _moof(sdi=2), STRAY) assert_fragments_decrypted(f) @@ -93,16 +105,16 @@ def test_empty_senc_on_clear_fragment_ignored(tmp_path: Path) -> None: assert_fragments_decrypted(f) -def _piff_override(sample_count: int) -> bytes: +def _senc_override(sample_count: int) -> bytes: # flags&1 => AlgorithmID(3) + IV_size(1) + KID(16) precede sample_count - body = PIFF_SENC_UUID + b"\x00\x00\x00\x01" + b"\x00\x00\x08" + b"\x08" + b"\xaa" * 16 - return _box(b"uuid", body + struct.pack(">I", sample_count) + b"\x00" * (8 * sample_count)) + head = b"\x00\x00\x00\x01" + b"\x00\x00\x08" + b"\x08" + b"\xaa" * 16 + return _box(b"senc", head + struct.pack(">I", sample_count) + b"\x00" * (8 * sample_count)) -def test_piff_override_flag_sample_count_offset(tmp_path: Path) -> None: - clear = _write(tmp_path, "ovr_clear.mp4", FTYP, MOOV, _moof(_piff_override(0)), MDAT) +def test_override_flag_shifts_sample_count_offset(tmp_path: Path) -> None: + clear = _write(tmp_path, "ovr_clear.mp4", FTYP, MOOV, _moof(_senc_override(0)), MDAT) assert_fragments_decrypted(clear) - f = _write(tmp_path, "ovr.mp4", FTYP, MOOV, _moof(_piff_override(5)), MDAT) + f = _write(tmp_path, "ovr.mp4", FTYP, MOOV, _moof(_senc_override(5)), MDAT) with pytest.raises(ValueError, match=r"1/1 fragment"): assert_fragments_decrypted(f) @@ -124,14 +136,14 @@ def test_undersized_largesize_box_does_not_desync_walk(tmp_path: Path, caplog: p # size==1 reads a 16-byte header, so a largesize of 12 clears the 8-byte floor but # still stalls the cursor onto payload; only a header-aware floor catches it runt = struct.pack(">I", 1) + b"free" + struct.pack(">Q", 12) - f = _write(tmp_path, "runt.mp4", FTYP, MOOV, runt, _moof(_piff_senc()), MDAT, _moof(_piff_senc()), MDAT) + f = _write(tmp_path, "runt.mp4", FTYP, MOOV, runt, _moof(_senc()), MDAT, _moof(_senc()), MDAT) with caplog.at_level("WARNING"): assert_fragments_decrypted(f) assert "malformed box size" in caplog.text def test_largesize_and_zero_size_boxes_walked(tmp_path: Path) -> None: - frag = _moof(_piff_senc()) + frag = _moof(_senc()) large = struct.pack(">I", 1) + b"mdat" + struct.pack(">Q", 16 + 64) + b"\x00" * 64 trailing = struct.pack(">I", 0) + b"mdat" + b"\x00" * 64 # size 0 => runs to EOF f = _write(tmp_path, "large.mp4", FTYP, MOOV, frag, large, trailing) @@ -142,7 +154,7 @@ def test_largesize_and_zero_size_boxes_walked(tmp_path: Path) -> None: def test_malformed_box_size_warns_and_does_not_raise(tmp_path: Path, caplog: pytest.LogCaptureFixture) -> None: # failing open is deliberate, but the abort must be logged so it cannot pass as clean overrun = struct.pack(">I", 1 << 30) + b"free" + b"\x00" * 8 - f = _write(tmp_path, "overrun.mp4", FTYP, MOOV, overrun, _moof(_piff_senc()), MDAT) + f = _write(tmp_path, "overrun.mp4", FTYP, MOOV, overrun, _moof(_senc()), MDAT) with caplog.at_level("WARNING"): assert_fragments_decrypted(f) assert "malformed box size" in caplog.text @@ -151,5 +163,5 @@ def test_malformed_box_size_warns_and_does_not_raise(tmp_path: Path, caplog: pyt def test_missing_or_bogus_file_is_noop(tmp_path: Path) -> None: assert_fragments_decrypted(tmp_path / "nope.mp4") assert_fragments_decrypted(_write(tmp_path, "bogus.bin", b"\x00\x01\x02\x03not-an-mp4")) - truncated = _write(tmp_path, "cut.mp4", FTYP, MOOV, _moof(_piff_senc())[:20]) + truncated = _write(tmp_path, "cut.mp4", FTYP, MOOV, _moof(_senc())[:20]) assert_fragments_decrypted(truncated) diff --git a/unshackle/core/tracks/track.py b/unshackle/core/tracks/track.py index a2eca307..4f001058 100644 --- a/unshackle/core/tracks/track.py +++ b/unshackle/core/tracks/track.py @@ -115,17 +115,18 @@ def _senc_protects_samples(buf: bytes, body: int, end: int) -> bool: def _moof_still_encrypted(moof: bytes) -> bool: + # senc only: a decrypter detaches just the atom it consumed, so a PIFF uuid can + # outlive a good decrypt. ISM arrives as senc via piff_senc_to_cenc. # structural walk: a 4CC byte scan collides with trun payload - from unshackle.core.manifests.ism_init import PIFF_SENC_UUID, iter_boxes + from unshackle.core.manifests.ism_init import iter_boxes body = 16 if int.from_bytes(moof[:4], "big") == 1 else 8 for box_type, _, traf_body, traf_end in iter_boxes(moof, body, len(moof)): if box_type != b"traf": continue - for child, usertype, child_body, child_end in iter_boxes(moof, traf_body, traf_end): - if child == b"senc" or (child == b"uuid" and usertype == PIFF_SENC_UUID): - if _senc_protects_samples(moof, child_body, child_end): - return True + for child, _usertype, child_body, child_end in iter_boxes(moof, traf_body, traf_end): + if child == b"senc" and _senc_protects_samples(moof, child_body, child_end): + return True return False @@ -138,6 +139,10 @@ def assert_fragments_decrypted(path: Path) -> None: stderr) while leaving the payload as ciphertext. Unlike has_encrypted_sample_entry (moov-scoped) this is fragment-scoped; the moov comes out clean in that failure. + Only a standard senc counts as evidence. Some DASH content ships a PIFF uuid beside it, + and a decrypter detaches just the atom it consumed, so that uuid outlives a good + decrypt; counting it would condemn a healthy file. + The guarantee runs in one direction: a survivor proves a skip, while a clean pass is only as good as the decrypter's own behaviour. shaka-packager (the default `decryption` backend) remuxes into a single fragment and drops senc whether or not it