mirror of
https://github.com/unshackle-dl/unshackle.git
synced 2026-11-12 20:15:10 +00:00
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:
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user