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.
This commit is contained in:
imSp4rky
2026-07-27 13:11:13 -06:00
parent 3c0fef75a7
commit a24c9b92da
2 changed files with 36 additions and 19 deletions
+26 -14
View File
@@ -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)
+10 -5
View File
@@ -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