mirror of
https://github.com/unshackle-dl/unshackle.git
synced 2026-11-12 20:15:10 +00:00
fix(ism): repair synthesized init and verify fragments decrypted
UNITY_MATRIX emitted 11 int32s instead of 9, oversizing mvhd/tkhd so pymp4 desynced and fTTML subtitles yielded no cues. Also rewrites PIFF senc to CENC so shaka can decrypt Smooth, labels HEV1 as hev1, and raises when a fragment survives decryption still carrying a sample-encryption box.
This commit is contained in:
@@ -24,6 +24,7 @@ from unshackle.core.manifests.ism_init import (
|
||||
parse_codec_private_data_colour,
|
||||
parse_codec_private_data_vui,
|
||||
parse_hevc_sps_format,
|
||||
piff_senc_to_cenc,
|
||||
read_per_sample_iv_size,
|
||||
read_track_id,
|
||||
remove_emulation_prevention,
|
||||
@@ -578,3 +579,240 @@ def test_hvcc_profile_tier_level_is_nonzero():
|
||||
level_idc = payload[12]
|
||||
assert profile_idc != 0
|
||||
assert level_idc != 0
|
||||
|
||||
|
||||
def _piff_fragment(flags: int = 0x0, override: bool = False) -> bytes:
|
||||
"""Minimal moof+mdat fragment carrying sample encryption in the PIFF uuid box."""
|
||||
ivs = b"".join(bytes([i]) * 8 for i in range(1, 3))
|
||||
senc_payload = struct.pack(">I", 2) + ivs
|
||||
if override:
|
||||
flags |= 0x1
|
||||
senc_payload = b"\x00\x00\x01" + bytes([8]) + KID + senc_payload # AlgorithmID + IV_size + KID
|
||||
uuid_box = box(b"uuid", PIFF_SENC_UUID + bytes([0]) + flags.to_bytes(3, "big") + senc_payload)
|
||||
tfhd = full_box(b"tfhd", 0, 0x20000, struct.pack(">I", 1))
|
||||
trun = full_box(b"trun", 0, 0x1, struct.pack(">II", 2, 0)) # data_offset placeholder
|
||||
traf = box(b"traf", tfhd + trun + uuid_box)
|
||||
moof = box(b"moof", full_box(b"mfhd", 0, 0, struct.pack(">I", 1)) + traf)
|
||||
# patch trun data_offset to the real moof-relative start of the mdat payload
|
||||
moof = moof.replace(struct.pack(">II", 2, 0), struct.pack(">II", 2, len(moof) + 8), 1)
|
||||
return moof + box(b"mdat", b"\xab" * 64)
|
||||
|
||||
|
||||
def test_piff_senc_rewritten_to_cenc_senc():
|
||||
frag = _piff_fragment()
|
||||
out = piff_senc_to_cenc(frag)
|
||||
# trun's data_offset is moof-relative; shrinking the box misaims decryption
|
||||
assert len(out) == len(frag)
|
||||
assert PIFF_SENC_UUID not in out
|
||||
assert b"senc" in out and b"free" in out
|
||||
assert struct.pack(">I", 2) + b"".join(bytes([i]) * 8 for i in range(1, 3)) in out
|
||||
# moof size unchanged => the recorded data_offset still resolves
|
||||
assert out[:8] == frag[:8]
|
||||
assert out[len(out) - 72 :] == frag[len(frag) - 72 :] # mdat untouched
|
||||
|
||||
|
||||
def test_piff_senc_override_header_stripped():
|
||||
frag = _piff_fragment(override=True)
|
||||
out = piff_senc_to_cenc(frag)
|
||||
assert len(out) == len(frag)
|
||||
assert PIFF_SENC_UUID not in out
|
||||
senc = out.index(b"senc")
|
||||
assert out[senc + 4] == 0 # version
|
||||
assert int.from_bytes(out[senc + 5 : senc + 8], "big") & 0x1 == 0 # override flag cleared
|
||||
assert out[senc + 8 : senc + 12] == struct.pack(">I", 2) # sample_count directly follows
|
||||
|
||||
|
||||
def test_piff_subsample_flag_survives_override_strip():
|
||||
# losing 0x2 is silent: no subsample map, so clear NAL headers get encrypted too
|
||||
frag = _piff_fragment(flags=0x2, override=True)
|
||||
out = piff_senc_to_cenc(frag)
|
||||
senc = out.index(b"senc")
|
||||
flags = int.from_bytes(out[senc + 5 : senc + 8], "big")
|
||||
assert flags & 0x2, "subsample-encryption flag must survive"
|
||||
assert not flags & 0x1, "override flag must be cleared"
|
||||
|
||||
|
||||
def test_piff_rewrite_leaves_non_piff_fragments_alone():
|
||||
plain = box(b"moof", box(b"traf", full_box(b"tfhd", 0, 0, struct.pack(">I", 1)))) + box(b"mdat", b"\x00" * 16)
|
||||
assert piff_senc_to_cenc(plain) is plain
|
||||
assert piff_senc_to_cenc(b"") == b""
|
||||
|
||||
|
||||
def test_piff_rewrite_skips_traf_that_already_has_senc():
|
||||
# a second senc would leave the decrypter to choose between them
|
||||
ivs = b"".join(bytes([i]) * 8 for i in range(1, 3))
|
||||
senc = full_box(b"senc", 0, 0, struct.pack(">I", 2) + ivs)
|
||||
uuid_box = box(b"uuid", PIFF_SENC_UUID + bytes([0]) + (0).to_bytes(3, "big") + struct.pack(">I", 2) + ivs)
|
||||
frag = box(b"moof", box(b"traf", senc + uuid_box)) + box(b"mdat", b"\x00" * 16)
|
||||
assert piff_senc_to_cenc(frag) is frag
|
||||
|
||||
|
||||
def test_piff_rewrite_covers_every_moof_and_traf():
|
||||
frag = _piff_fragment() + _piff_fragment()
|
||||
out = piff_senc_to_cenc(frag)
|
||||
assert len(out) == len(frag)
|
||||
assert PIFF_SENC_UUID not in out
|
||||
assert out.count(b"senc") == 2
|
||||
|
||||
|
||||
def test_piff_rewrite_refuses_non_ctr_algorithm_id():
|
||||
# AlgorithmID 2 is AES-CBC but the sample entry says 'cenc'; rewriting yields garbage
|
||||
ivs = b"".join(bytes([i]) * 8 for i in range(1, 3))
|
||||
payload = (2).to_bytes(3, "big") + bytes([8]) + KID + struct.pack(">I", 2) + ivs
|
||||
uuid_box = box(b"uuid", PIFF_SENC_UUID + bytes([0]) + (0x1).to_bytes(3, "big") + payload)
|
||||
frag = box(b"moof", box(b"traf", uuid_box)) + box(b"mdat", b"\x00" * 16)
|
||||
assert piff_senc_to_cenc(frag) is frag
|
||||
|
||||
|
||||
def traf_children_tile_exactly(frag: bytes) -> bool:
|
||||
"""Every traf child's declared size must chain exactly to the traf end."""
|
||||
moof_size = struct.unpack(">I", frag[:4])[0]
|
||||
traf_start = 8
|
||||
while frag[traf_start + 4 : traf_start + 8] != b"traf":
|
||||
traf_start += struct.unpack(">I", frag[traf_start : traf_start + 4])[0]
|
||||
traf_end = traf_start + struct.unpack(">I", frag[traf_start : traf_start + 4])[0]
|
||||
assert traf_end <= moof_size
|
||||
offset = traf_start + 8
|
||||
while offset < traf_end:
|
||||
size = struct.unpack(">I", frag[offset : offset + 4])[0]
|
||||
if size < 8 or offset + size > traf_end:
|
||||
return False
|
||||
offset += size
|
||||
return offset == traf_end
|
||||
|
||||
|
||||
def test_piff_rewrite_keeps_traf_children_tiling_exactly():
|
||||
# a mis-sized senc still "decrypts", just off the wrong bytes, so assert the sizes too
|
||||
assert traf_children_tile_exactly(_piff_fragment())
|
||||
for frag in (_piff_fragment(), _piff_fragment(flags=0x2), _piff_fragment(flags=0x2, override=True)):
|
||||
assert traf_children_tile_exactly(piff_senc_to_cenc(frag)), "rewritten traf no longer tiles"
|
||||
|
||||
|
||||
def test_piff_rewrite_refuses_override_iv_size_mismatch():
|
||||
# tenc is the only IV width a decrypter consults; a disagreeing override misparses silently
|
||||
ivs = b"".join(bytes([i]) * 16 for i in range(1, 3))
|
||||
payload = (1).to_bytes(3, "big") + bytes([16]) + KID + struct.pack(">I", 2) + ivs
|
||||
uuid_box = box(b"uuid", PIFF_SENC_UUID + bytes([0]) + (0x1).to_bytes(3, "big") + payload)
|
||||
frag = box(b"moof", box(b"traf", uuid_box)) + box(b"mdat", b"\x00" * 16)
|
||||
assert piff_senc_to_cenc(frag, iv_size=8) is frag, "8-byte tenc must reject a 16-byte override"
|
||||
assert piff_senc_to_cenc(frag, iv_size=16) is not frag, "matching width must still rewrite"
|
||||
|
||||
|
||||
def test_piff_rewrite_never_grows_output_on_malformed_boxes():
|
||||
# A declared size running past the buffer would extend the output over the mdat.
|
||||
good = _piff_fragment()
|
||||
truncations = [good[:n] for n in range(8, len(good), 7)]
|
||||
oversized = bytearray(good)
|
||||
struct.pack_into(">I", oversized, 0, len(good) + 4096) # moof claims more than exists
|
||||
for frag in [*truncations, bytes(oversized), b"", b"\x00" * 12]:
|
||||
out = piff_senc_to_cenc(frag) # must not raise
|
||||
assert len(out) == len(frag), f"output grew: {len(frag)} -> {len(out)}"
|
||||
|
||||
|
||||
def _text_fragment(seq: int, begin: str, end: str, text: str, sdi: int) -> bytes:
|
||||
"""A single-sample fTTML moof+mdat, mirroring what a Smooth text stream serves."""
|
||||
payload = (
|
||||
"<?xml version='1.0' encoding='utf-8'?>"
|
||||
"<tt xmlns='http://www.w3.org/ns/ttml' xml:lang='en'><body><div>"
|
||||
f"<p begin='{begin}' end='{end}'>{text}</p>"
|
||||
"</div></body></tt>"
|
||||
).encode()
|
||||
mfhd = full_box(b"mfhd", 0, 0, struct.pack(">I", seq))
|
||||
tfhd = full_box(b"tfhd", 0, 0x020002, struct.pack(">I", 1) + struct.pack(">I", sdi))
|
||||
tfdt = full_box(b"tfdt", 1, 0, struct.pack(">Q", 0))
|
||||
trun = full_box(b"trun", 0, 0x000201, struct.pack(">I", 1) + struct.pack(">i", 0) + struct.pack(">I", len(payload)))
|
||||
return box(b"moof", mfhd + box(b"traf", tfhd + tfdt + trun)) + box(b"mdat", payload)
|
||||
|
||||
|
||||
def test_unity_matrix_is_nine_int32s():
|
||||
# extras shift every later field (mvhd next_track_ID, tkhd width/height) and desync pymp4
|
||||
from unshackle.core.manifests.ism_init import UNITY_MATRIX
|
||||
|
||||
assert len(UNITY_MATRIX) == 36
|
||||
|
||||
|
||||
@pytest.mark.parametrize("stream_type", ["video", "audio", "text"])
|
||||
def test_mvhd_tkhd_sizes_match_iso_layout(stream_type):
|
||||
# a declared size over the spec payload makes parsers under-read and stop at the moov
|
||||
kwargs = {
|
||||
"video": dict(fourcc="H264", codec_private_data=VIDEO_AVC_CPD, width=1920, height=1080),
|
||||
"audio": dict(fourcc="AACL", codec_private_data=AAC_LC_CPD),
|
||||
"text": dict(fourcc="TTML", codec_private_data=""),
|
||||
}[stream_type]
|
||||
init = build_init_segment(stream_type=stream_type, duration=6_000_000_000, language="eng", **kwargs)
|
||||
|
||||
sizes = {}
|
||||
for name in (b"mvhd", b"tkhd"):
|
||||
at = init.index(name) - 4
|
||||
sizes[name] = struct.unpack(">I", init[at : at + 4])[0]
|
||||
# version-1 mvhd: 8 header + 4 version/flags + 28 time fields + 80 trailer
|
||||
assert sizes[b"mvhd"] == 120
|
||||
# version-1 tkhd: 8 header + 4 version/flags + 32 time fields + 60 trailer
|
||||
assert sizes[b"tkhd"] == 104
|
||||
|
||||
|
||||
def test_tkhd_carries_video_dimensions():
|
||||
# width/height sit after the matrix, so a mis-sized matrix silently zeroes them
|
||||
init = build_init_segment(
|
||||
stream_type="video", fourcc="H264", codec_private_data=VIDEO_AVC_CPD, width=1920, height=1080
|
||||
)
|
||||
at = init.index(b"tkhd") - 4
|
||||
tkhd = init[at : at + struct.unpack(">I", init[at : at + 4])[0]]
|
||||
width, height = struct.unpack(">II", tkhd[-8:])
|
||||
assert (width >> 16, height >> 16) == (1920, 1080)
|
||||
|
||||
|
||||
def test_pymp4_consumes_whole_init_and_reaches_fragments():
|
||||
# pymp4 must reach the mdat here, or Subtitle.parse finds nothing to yield cues from
|
||||
from io import BytesIO
|
||||
|
||||
from pymp4.parser import MP4
|
||||
|
||||
init = build_init_segment(
|
||||
stream_type="text", fourcc="TTML", codec_private_data="", duration=6_000_000_000, language="eng"
|
||||
)
|
||||
data = init + _text_fragment(1, "00:00:01.000", "00:00:02.000", "first", sdi=1)
|
||||
types = [b.type for b in MP4.parse_stream(BytesIO(data))]
|
||||
assert types == [b"ftyp", b"moov", b"moof", b"mdat"]
|
||||
|
||||
|
||||
def test_fttml_subtitle_parse_yields_cues():
|
||||
from unshackle.core.tracks.subtitle import Subtitle
|
||||
|
||||
init = build_init_segment(
|
||||
stream_type="text", fourcc="TTML", codec_private_data="", duration=6_000_000_000, language="eng"
|
||||
)
|
||||
data = (
|
||||
init
|
||||
+ _text_fragment(1, "00:00:01.000", "00:00:02.000", "first", sdi=1)
|
||||
+ _text_fragment(2, "00:00:03.000", "00:00:04.000", "second", sdi=2)
|
||||
)
|
||||
caption_set = Subtitle.parse(data, Subtitle.Codec.fTTML)
|
||||
cues = [c for lang in caption_set.get_languages() for c in caption_set.get_captions(lang)]
|
||||
assert len(cues) == 2
|
||||
assert [c.get_text() for c in cues] == ["first", "second"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"fourcc,expected",
|
||||
[("HEV1", b"hev1"), ("hev1", b"hev1"), ("HVC1", b"hvc1"), ("hvc1", b"hvc1"), ("HEVC", b"hvc1"), ("H265", b"hvc1")],
|
||||
)
|
||||
def test_hevc_sample_entry_honours_manifest_fourcc(fourcc, expected):
|
||||
# hev1 permits in-band parameter sets where hvc1 requires them in the sample
|
||||
# entry. Real Smooth manifests ship FourCC="hev1", so relabelling it hvc1
|
||||
# misdescribes the stream.
|
||||
init = build_init_segment(
|
||||
stream_type="video", fourcc=fourcc, codec_private_data=VIDEO_HEVC_CPD, width=3840, height=2160
|
||||
)
|
||||
offset = find_stsd(init)
|
||||
assert init[offset + 20 : offset + 24] == expected
|
||||
|
||||
|
||||
def test_encrypted_hev1_frma_carries_the_same_fourcc():
|
||||
# frma names the original format the sinf wraps; if it drifts from the sample
|
||||
# entry, a decrypter restores the wrong sample entry type.
|
||||
init = build_init_segment(
|
||||
stream_type="video", fourcc="HEV1", codec_private_data=VIDEO_HEVC_CPD, kid=KID, width=3840, height=2160
|
||||
)
|
||||
assert b"encv" in init
|
||||
assert init[init.index(b"frma") + 4 : init.index(b"frma") + 8] == b"hev1"
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
"""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
|
||||
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.
|
||||
|
||||
Run: uv run pytest tests/tracks/test_fragment_decrypted.py
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import struct
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from unshackle.core.manifests.ism_init import PIFF_SENC_UUID
|
||||
from unshackle.core.tracks.track import assert_fragments_decrypted
|
||||
|
||||
|
||||
def _box(fmt: bytes, payload: bytes = b"") -> bytes:
|
||||
return struct.pack(">I", 8 + len(payload)) + fmt + payload
|
||||
|
||||
|
||||
def _senc(sample_count: int = 4) -> bytes:
|
||||
return _box(b"senc", b"\x00" * 4 + struct.pack(">I", sample_count) + b"\x00" * (8 * sample_count))
|
||||
|
||||
|
||||
def _piff_senc(sample_count: int = 4) -> bytes:
|
||||
body = PIFF_SENC_UUID + b"\x00" * 4 + struct.pack(">I", sample_count) + b"\x00" * (8 * sample_count)
|
||||
return _box(b"uuid", body)
|
||||
|
||||
|
||||
def _moof(*children: bytes, sdi: int = 1) -> bytes:
|
||||
tfhd = _box(b"tfhd", b"\x00\x00\x00\x02" + struct.pack(">II", 1, sdi)) # sample-description-index-present
|
||||
return _box(b"moof", _box(b"mfhd", b"\x00" * 8) + _box(b"traf", tfhd + b"".join(children)))
|
||||
|
||||
|
||||
FTYP = _box(b"ftyp", b"isom\x00\x00\x02\x00isomiso2")
|
||||
MOOV = _box(b"moov", _box(b"mvex", _box(b"trex", b"\x00" * 24)))
|
||||
MDAT = _box(b"mdat", b"\x00" * 64)
|
||||
# a compressed payload can contain anything, including plausible-looking box runs
|
||||
STRAY = _box(b"mdat", struct.pack(">I", 64) + b"senc" + b"\xbe\xef" * 8 + PIFF_SENC_UUID)
|
||||
|
||||
|
||||
def _write(tmp_path: Path, name: str, *boxes: bytes) -> Path:
|
||||
f = tmp_path / name
|
||||
f.write_bytes(b"".join(boxes))
|
||||
return f
|
||||
|
||||
|
||||
def test_all_fragments_decrypted_passes(tmp_path: Path) -> None:
|
||||
f = _write(tmp_path, "clean.mp4", FTYP, MOOV, _moof(), MDAT, _moof(sdi=2), MDAT)
|
||||
assert_fragments_decrypted(f)
|
||||
|
||||
|
||||
def test_surviving_piff_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]
|
||||
f = _write(tmp_path, "skipped.mp4", *boxes)
|
||||
with pytest.raises(ValueError, match=r"3/4 fragment"):
|
||||
assert_fragments_decrypted(f)
|
||||
|
||||
|
||||
def test_surviving_cenc_senc_raises(tmp_path: Path) -> None:
|
||||
f = _write(tmp_path, "cenc.mp4", FTYP, MOOV, _moof(_senc()), MDAT)
|
||||
with pytest.raises(ValueError, match=r"1/1 fragment"):
|
||||
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)
|
||||
|
||||
|
||||
def test_senc_text_without_plausible_size_ignored(tmp_path: Path) -> None:
|
||||
# "senc" inside a free-box string has no valid box size before it
|
||||
f = _write(tmp_path, "text.mp4", FTYP, MOOV, _moof(_box(b"free", b"presence sencoded here")), MDAT)
|
||||
assert_fragments_decrypted(f)
|
||||
|
||||
|
||||
def test_senc_bytes_inside_trun_payload_ignored(tmp_path: Path) -> None:
|
||||
# a trun sample entry spelling "senc" after a size-looking duration; only a byte scan bites
|
||||
trun = _box(b"trun", b"\x00\x00\x03\x01" + struct.pack(">I", 2) + struct.pack(">I", 512) + b"senc")
|
||||
f = _write(tmp_path, "trun.mp4", FTYP, MOOV, _moof(trun), MDAT)
|
||||
assert_fragments_decrypted(f)
|
||||
|
||||
|
||||
def test_empty_senc_on_clear_fragment_ignored(tmp_path: Path) -> None:
|
||||
# sample_count 0 marks a fragment with no protected samples, so the guard ignores it
|
||||
f = _write(tmp_path, "empty.mp4", FTYP, MOOV, _moof(_senc(sample_count=0)), MDAT)
|
||||
assert_fragments_decrypted(f)
|
||||
|
||||
|
||||
def _piff_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))
|
||||
|
||||
|
||||
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)
|
||||
assert_fragments_decrypted(clear)
|
||||
f = _write(tmp_path, "ovr.mp4", FTYP, MOOV, _moof(_piff_override(5)), MDAT)
|
||||
with pytest.raises(ValueError, match=r"1/1 fragment"):
|
||||
assert_fragments_decrypted(f)
|
||||
|
||||
|
||||
def test_senc_outside_traf_ignored(tmp_path: Path) -> None:
|
||||
# a decrypted moof whose only "senc" sits in a sibling box outside any traf
|
||||
f = _write(tmp_path, "sibling.mp4", FTYP, MOOV, _box(b"moof", _box(b"mfhd", b"\x00" * 8) + _senc()), MDAT)
|
||||
assert_fragments_decrypted(f)
|
||||
|
||||
|
||||
def test_senc_nested_under_non_traf_ignored(tmp_path: Path) -> None:
|
||||
# senc two levels down under a non-traf container: only traf children count
|
||||
meta = _box(b"meta", _senc())
|
||||
f = _write(tmp_path, "nested.mp4", FTYP, MOOV, _box(b"moof", _box(b"mfhd", b"\x00" * 8) + meta), MDAT)
|
||||
assert_fragments_decrypted(f)
|
||||
|
||||
|
||||
def test_undersized_largesize_box_does_not_desync_walk(tmp_path: Path, caplog: pytest.LogCaptureFixture) -> None:
|
||||
# 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)
|
||||
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())
|
||||
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)
|
||||
with pytest.raises(ValueError, match=r"1/1 fragment"):
|
||||
assert_fragments_decrypted(f)
|
||||
|
||||
|
||||
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)
|
||||
with caplog.at_level("WARNING"):
|
||||
assert_fragments_decrypted(f)
|
||||
assert "malformed box size" in caplog.text
|
||||
|
||||
|
||||
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])
|
||||
assert_fragments_decrypted(truncated)
|
||||
@@ -67,7 +67,7 @@ from unshackle.core.tracks import Audio, Subtitle, Tracks, Video
|
||||
from unshackle.core.tracks.attachment import Attachment
|
||||
from unshackle.core.tracks.dv_fixup import apply_dv_fixup
|
||||
from unshackle.core.tracks.hybrid import Hybrid
|
||||
from unshackle.core.tracks.track import has_encrypted_sample_entry
|
||||
from unshackle.core.tracks.track import assert_fragments_decrypted, has_encrypted_sample_entry
|
||||
from unshackle.core.utilities import (
|
||||
find_font_with_fallbacks,
|
||||
find_missing_langs,
|
||||
@@ -3088,6 +3088,8 @@ class dl:
|
||||
if drm and hasattr(drm, "decrypt"):
|
||||
drm.decrypt(track.path)
|
||||
if not isinstance(drm, MonaLisa):
|
||||
# MonaLisa decrypts per segment; its decrypt() here is a no-op
|
||||
assert_fragments_decrypted(track.path)
|
||||
has_decrypted = True
|
||||
events.emit(events.Types.TRACK_REPACKED, track=track)
|
||||
else:
|
||||
|
||||
@@ -28,6 +28,7 @@ from unshackle.core.drm import DRM_T, ClearKeyCENC, PlayReady, Widevine
|
||||
from unshackle.core.events import events
|
||||
from unshackle.core.session import RnetSession
|
||||
from unshackle.core.tracks import Audio, DownloadContext, Subtitle, Tracks, Video
|
||||
from unshackle.core.tracks.track import assert_fragments_decrypted
|
||||
from unshackle.core.utilities import is_close_match, log_event, try_ensure_utf8
|
||||
from unshackle.core.utils.redact import safe_display_url
|
||||
from unshackle.core.utils.xml import load_xml
|
||||
@@ -570,6 +571,7 @@ class DASH:
|
||||
if drm:
|
||||
progress(downloaded="Decrypting", completed=0, total=None)
|
||||
drm.decrypt(save_path)
|
||||
assert_fragments_decrypted(save_path)
|
||||
track.drm = None
|
||||
events.emit(events.Types.TRACK_DECRYPTED, track=track, drm=drm, segment=None)
|
||||
progress(downloaded="Decrypted", completed=100, total=100)
|
||||
|
||||
@@ -33,6 +33,7 @@ from unshackle.core.drm import DRM_T, ClearKey, MonaLisa, PlayReady, Widevine
|
||||
from unshackle.core.events import events
|
||||
from unshackle.core.session import RnetResponse, RnetSession
|
||||
from unshackle.core.tracks import Audio, DownloadContext, Subtitle, Tracks, Video
|
||||
from unshackle.core.tracks.track import assert_fragments_decrypted
|
||||
from unshackle.core.utilities import get_extension, is_close_match, log_event, try_ensure_utf8
|
||||
from unshackle.core.utils.redact import safe_display_url
|
||||
from unshackle.core.utils.subprocess import log_tool_run
|
||||
@@ -794,6 +795,7 @@ class HLS:
|
||||
# with widevine we can merge all segments and decrypt once
|
||||
merge(to=merged_path, via=files, delete=True, include_map_data=True)
|
||||
drm.decrypt(merged_path)
|
||||
assert_fragments_decrypted(merged_path)
|
||||
merged_path.rename(decrypted_path)
|
||||
else:
|
||||
# with other drm we must decrypt separately and then merge them
|
||||
|
||||
@@ -22,11 +22,13 @@ from unshackle.core.events import events
|
||||
from unshackle.core.manifests.ism_init import (
|
||||
build_init_segment,
|
||||
parse_codec_private_data_vui,
|
||||
piff_senc_to_cenc,
|
||||
read_per_sample_iv_size,
|
||||
read_track_id,
|
||||
)
|
||||
from unshackle.core.session import RnetSession
|
||||
from unshackle.core.tracks import Audio, DownloadContext, Subtitle, Track, Tracks, Video
|
||||
from unshackle.core.tracks.track import assert_fragments_decrypted
|
||||
from unshackle.core.utilities import log_event, try_ensure_utf8
|
||||
from unshackle.core.utils.redact import safe_display_url
|
||||
from unshackle.core.utils.xml import load_xml
|
||||
@@ -521,6 +523,7 @@ class ISM:
|
||||
init_segment = ISM._init_segment(track, session_drm, first_segment)
|
||||
if init_segment:
|
||||
f.write(init_segment)
|
||||
iv_size = (read_per_sample_iv_size(first_segment) if session_drm and first_segment else None) or 8
|
||||
for index, segment_file in enumerate(segments_to_merge):
|
||||
if is_text_subtitle:
|
||||
# first segment was already read for the init synthesis, reuse it
|
||||
@@ -533,6 +536,9 @@ class ISM:
|
||||
.encode("utf8")
|
||||
)
|
||||
f.write(segment_data)
|
||||
elif session_drm:
|
||||
segment_data = first_segment if index == 0 and first_segment else segment_file.read_bytes()
|
||||
f.write(piff_senc_to_cenc(segment_data, iv_size))
|
||||
elif index == 0 and first_segment:
|
||||
f.write(first_segment)
|
||||
else:
|
||||
@@ -547,6 +553,7 @@ class ISM:
|
||||
if session_drm:
|
||||
progress(downloaded="Decrypting", completed=0, total=None)
|
||||
session_drm.decrypt(save_path)
|
||||
assert_fragments_decrypted(save_path)
|
||||
track.drm = None
|
||||
events.emit(events.Types.TRACK_DECRYPTED, track=track, drm=session_drm, segment=None)
|
||||
progress(downloaded="Decrypted", completed=100, total=100)
|
||||
|
||||
@@ -8,6 +8,10 @@ sends a ``moov``. The init box must be reconstructed from the manifest's
|
||||
or decryptor such as shaka-packager can parse the stream. Ported from yt-dlp's
|
||||
``write_piff_header`` and N_m3u8DL-RE's ``MSSMoovProcessor`` with HEVC, Dolby
|
||||
Vision, EC-3, TTML and CENC (PIFF) support.
|
||||
|
||||
``piff_senc_to_cenc`` rewrites the fragments themselves, which neither port does:
|
||||
shaka-packager cannot read the PIFF sample-encryption box, so its payload is
|
||||
retagged as a standard ``senc``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -28,13 +32,14 @@ u1616 = struct.Struct(">Hxx")
|
||||
s32 = struct.Struct(">i")
|
||||
|
||||
# 3x3 transformation matrix (identity), as stored in tkhd/mvhd.
|
||||
# Exactly 9 int32s; extras shift every field after it and desync pymp4.
|
||||
UNITY_MATRIX = (
|
||||
s32.pack(0x10000)
|
||||
+ s32.pack(0) * 3
|
||||
+ s32.pack(0) * 2
|
||||
+ s32.pack(0)
|
||||
+ s32.pack(0x10000)
|
||||
+ s32.pack(0) * 2
|
||||
+ s32.pack(0) * 2
|
||||
+ s32.pack(0)
|
||||
+ s32.pack(0x40000000)
|
||||
)
|
||||
|
||||
@@ -308,7 +313,13 @@ def parse_codec_private_data_colour(fourcc: str, codec_private_data: bytes) -> O
|
||||
|
||||
|
||||
def iter_boxes(data: bytes, start: int, end: int) -> Iterator[tuple[bytes, Optional[bytes], int, int]]:
|
||||
"""Yield (type, uuid_usertype, payload_start, box_end) for each child box."""
|
||||
"""Yield (type, uuid_usertype, payload_start, box_end) for each child box.
|
||||
|
||||
Stops silently at the first box whose declared size cannot be walked. A short
|
||||
result therefore reports how far the walk got, and a caller counting survivors
|
||||
should treat it as unverified. box_end may exceed end when the declared size
|
||||
overruns; clamp it before slicing.
|
||||
"""
|
||||
offset = start
|
||||
while offset + 8 <= end:
|
||||
size = struct.unpack(">I", data[offset : offset + 4])[0]
|
||||
@@ -319,7 +330,7 @@ def iter_boxes(data: bytes, start: int, end: int) -> Iterator[tuple[bytes, Optio
|
||||
header = 16
|
||||
if size == 0:
|
||||
size = end - offset
|
||||
if size < 8: # corrupt box header; stop rather than loop forever
|
||||
if size < header: # size below the consumed header desyncs the walk; stop
|
||||
return
|
||||
usertype = None
|
||||
if box_type == b"uuid" and offset + header + 16 <= end:
|
||||
@@ -426,6 +437,74 @@ def read_per_sample_iv_size(fragment: bytes) -> Optional[int]:
|
||||
return None
|
||||
|
||||
|
||||
def piff_senc_to_cenc(fragment: bytes, iv_size: int = 8) -> bytes:
|
||||
"""Rewrite a fragment's moof/traf PIFF sample-encryption uuid boxes as standard 'senc'.
|
||||
|
||||
shaka-packager does not consume the PIFF uuid form. It finds no per-sample IVs, falls
|
||||
through to the constant-IV path, and aborts with "IV cannot be empty". mp4decrypt
|
||||
accepts either form, and the payloads are identical once the optional override header
|
||||
is stripped, so emitting 'senc' serves both decrypters.
|
||||
|
||||
The rewrite preserves length: bytes reclaimed from the uuid header become a 'free' box.
|
||||
The moof's byte length must stay the same. trun's data_offset resolves against tfhd's
|
||||
base_data_offset (the moof start only when that field is absent), so moving the sample
|
||||
data misaligns decryption and yields plausible garbage with no error.
|
||||
|
||||
iv_size is the default_Per_Sample_IV_Size the init segment's tenc declares, read from
|
||||
the first fragment by read_per_sample_iv_size. This function skips any fragment whose
|
||||
override disagrees, since tenc is the only IV width a CENC decrypter consults and a
|
||||
mismatch misparses every IV.
|
||||
"""
|
||||
out: Optional[bytearray] = None
|
||||
limit = len(fragment)
|
||||
trafs = [
|
||||
(body, min(box_end, limit))
|
||||
for moof_type, _, moof_body, moof_end in iter_boxes(fragment, 0, limit)
|
||||
if moof_type == b"moof"
|
||||
for box_type, _, body, box_end in iter_boxes(fragment, moof_body, min(moof_end, limit))
|
||||
if box_type == b"traf"
|
||||
]
|
||||
for traf_body, traf_end in trafs:
|
||||
# a second senc would leave the decrypter to choose between them
|
||||
if any(bt == b"senc" for bt, _, _, _ in iter_boxes(fragment, traf_body, traf_end)):
|
||||
continue
|
||||
offset = traf_body
|
||||
while offset + 8 <= traf_end:
|
||||
size = struct.unpack(">I", fragment[offset : offset + 4])[0]
|
||||
if size < 8 or offset + size > traf_end:
|
||||
break
|
||||
if (
|
||||
size >= 28
|
||||
and fragment[offset + 4 : offset + 8] == b"uuid"
|
||||
and fragment[offset + 8 : offset + 24] == PIFF_SENC_UUID
|
||||
):
|
||||
version = fragment[offset + 24]
|
||||
flags = int.from_bytes(fragment[offset + 25 : offset + 28], "big")
|
||||
# flag 0x1 = PIFF override header: AlgorithmID(3) + IV_size(1) + KID(16)
|
||||
strip = 20 if flags & 0x1 else 0
|
||||
# AlgorithmID 2 is AES-CBC and a stray IV width misparses every IV; leave
|
||||
# either alone so the decrypter fails loudly on a form it cannot handle
|
||||
if (
|
||||
strip
|
||||
and (
|
||||
size < 28 + strip # short-circuits before reading the override fields
|
||||
or int.from_bytes(fragment[offset + 28 : offset + 31], "big") != 1
|
||||
or fragment[offset + 31] != iv_size
|
||||
)
|
||||
):
|
||||
offset += size
|
||||
continue
|
||||
if offset + 28 + strip <= offset + size:
|
||||
senc = full_box(b"senc", version, flags & ~0x1, fragment[offset + 28 + strip : offset + size])
|
||||
pad = size - len(senc)
|
||||
if pad >= 8:
|
||||
if out is None:
|
||||
out = bytearray(fragment)
|
||||
out[offset : offset + size] = senc + box(b"free", bytes(pad - 8))
|
||||
offset += size
|
||||
return bytes(out) if out is not None else fragment
|
||||
|
||||
|
||||
def build_avcc(codec_private_data: bytes, nal_length_size: int = 4) -> bytes:
|
||||
"""Build an avcC (AVC decoder config) box from SPS+PPS CodecPrivateData."""
|
||||
nals = split_nal_units(codec_private_data)
|
||||
@@ -582,7 +661,7 @@ def build_sinf(
|
||||
schm = full_box(b"schm", 0, 0, b"cenc" + u32.pack(0x00010000))
|
||||
tenc_payload = (
|
||||
u8.pack(0) # reserved
|
||||
+ u8.pack(0) # default_crypt_byte_block / skip_byte_block (cenc)
|
||||
+ u8.pack(0) # reserved in v0 (crypt/skip nibbles are v1 only)
|
||||
+ u8.pack(1) # default_isProtected
|
||||
+ u8.pack(0 if constant_iv else iv_size) # default_Per_Sample_IV_Size
|
||||
+ kid # default_KID (16 bytes)
|
||||
@@ -616,8 +695,10 @@ def build_init_segment(
|
||||
Build a complete ftyp + moov initialization segment.
|
||||
|
||||
stream_type: "video" | "audio" | "text".
|
||||
fourcc: Smooth FourCC ("H264"/"AVC1"/"DAVC", "HVC1"/"HEV1", "DVHE"/"DVH1",
|
||||
"AACL"/"AACH", "EC-3", "TTML").
|
||||
fourcc: Smooth FourCC ("H264"/"AVC1"/"DAVC", "HVC1"/"HEV1"/"HEVC"/"H265",
|
||||
"DVHE"/"DVH1", "AACL"/"AACH"/"AAC", "EC-3", "TTML"/"STPP"/"DFXP").
|
||||
Only "HEV1" yields an hev1 sample entry; the rest of that group yield
|
||||
hvc1, which requires its parameter sets in the sample entry.
|
||||
codec_private_data: hex string from the manifest QualityLevel.
|
||||
nal_length_size: manifest NALUnitLengthField (bytes per NAL length prefix).
|
||||
kid: 16-byte default key id; when set, the sample entry is wrapped for CENC.
|
||||
@@ -638,6 +719,7 @@ def build_init_segment(
|
||||
ftyp = box(b"ftyp", b"isml" + u32.pack(1) + b"iso5" + b"iso6" + b"piff" + b"msdh")
|
||||
|
||||
# --- mvhd ---
|
||||
# version 1: at the 10 MHz ISM timescale a 32-bit duration overflows after ~429s
|
||||
mvhd = full_box(
|
||||
b"mvhd",
|
||||
1,
|
||||
@@ -724,7 +806,10 @@ def build_init_segment(
|
||||
codec_fourcc = b"avc1"
|
||||
elif fourcc in ("HVC1", "HEV1", "HEVC", "H265"):
|
||||
config_box = build_hvcc(cpd, nal_length_size)
|
||||
codec_fourcc = b"hvc1"
|
||||
# hev1 permits in-band parameter sets where hvc1 requires them in the
|
||||
# sample entry, so relabelling hev1 as hvc1 misdescribes the stream
|
||||
# (and the frma inside sinf).
|
||||
codec_fourcc = b"hev1" if fourcc == "HEV1" else b"hvc1"
|
||||
elif fourcc in ("DVHE", "DVH1"):
|
||||
# Dolby Vision over HEVC: same hvcC config, dvh1 sample entry.
|
||||
config_box = build_hvcc(cpd, nal_length_size)
|
||||
@@ -773,9 +858,10 @@ def build_init_segment(
|
||||
else:
|
||||
raise NotImplementedError(f"Unsupported text FourCC: {fourcc}")
|
||||
|
||||
# Fragments may set tfhd sample_description_index=2 (server inits carry a second,
|
||||
# identical entry). A lone entry leaves that dangling: mp4decrypt skips those
|
||||
# fragments and demuxers stop after the first.
|
||||
# Fragments may set tfhd sample_description_index=2. A lone entry leaves that
|
||||
# dangling: mp4decrypt skips those fragments and demuxers stop after the first.
|
||||
# We duplicate entry 1; a manifest carries one CodecPrivateData, so there is no
|
||||
# second config to build. The server's larger second entry only repeats its VPS.
|
||||
stsd = full_box(b"stsd", 0, 0, u32.pack(2) + sample_entry_box * 2)
|
||||
|
||||
# --- empty sample tables (fragmented: real samples live in moof/traf) ---
|
||||
|
||||
@@ -100,6 +100,110 @@ def has_encrypted_sample_entry(path: Path) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def _senc_protects_samples(buf: bytes, body: int, end: int) -> bool:
|
||||
"""True if a senc/PIFF-uuid box describes protected samples.
|
||||
|
||||
An empty senc (sample_count 0) sits on a genuinely clear fragment, so treating it as
|
||||
a skipped one would raise on a healthy file. Field order follows
|
||||
read_per_sample_iv_size.
|
||||
"""
|
||||
flags = int.from_bytes(buf[body + 1 : body + 4], "big")
|
||||
pos = body + 4 + (20 if flags & 0x1 else 0) # PIFF override: AlgorithmID(3)+IV_size(1)+KID(16)
|
||||
if pos + 4 > end:
|
||||
return False
|
||||
return int.from_bytes(buf[pos : pos + 4], "big") > 0
|
||||
|
||||
|
||||
def _moof_still_encrypted(moof: bytes) -> bool:
|
||||
# structural walk: a 4CC byte scan collides with trun payload
|
||||
from unshackle.core.manifests.ism_init import PIFF_SENC_UUID, 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
|
||||
return False
|
||||
|
||||
|
||||
def assert_fragments_decrypted(path: Path) -> None:
|
||||
"""Raise if any fragment survived decryption still carrying a sample-encryption box.
|
||||
|
||||
mp4decrypt strips senc from every fragment it processes and leaves it on the ones it
|
||||
skips, so a survivor marks a silent skip. One cause is a tfhd.sample_description_index
|
||||
dangling past the stsd entry count, which mp4decrypt reports as success (exit 0, no
|
||||
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.
|
||||
|
||||
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
|
||||
decrypted, so this cannot fire on shaka output. The walk is unbuffered and reads only
|
||||
box headers; it seeks over mdat and never reads it. A malformed box size aborts the
|
||||
walk with a warning and returns normally, so a file this function cannot parse is
|
||||
never escalated into a raise. The logged warning is the only signal of that; a caller
|
||||
sees the same silent return it gets from a verified clean file.
|
||||
"""
|
||||
surviving = 0
|
||||
total = 0
|
||||
first_offset = None
|
||||
try:
|
||||
file_size = path.stat().st_size
|
||||
with path.open("rb", buffering=0) as f:
|
||||
start = 0
|
||||
while start + 8 <= file_size:
|
||||
f.seek(start)
|
||||
header = f.read(8)
|
||||
if len(header) < 8:
|
||||
break
|
||||
size = int.from_bytes(header[:4], "big")
|
||||
box_header = 8
|
||||
if size == 1: # 64-bit largesize
|
||||
large = f.read(8)
|
||||
if len(large) < 8:
|
||||
break
|
||||
size = int.from_bytes(large, "big")
|
||||
box_header = 16
|
||||
elif size == 0: # box runs to EOF
|
||||
size = file_size - start
|
||||
# below the consumed header the cursor stalls and desyncs onto payload
|
||||
if size < box_header or start + size > file_size:
|
||||
logging.getLogger("track").warning(
|
||||
f"{path.name}: malformed box size at offset {start}; cannot verify the "
|
||||
"remaining fragments were decrypted."
|
||||
)
|
||||
break
|
||||
if header[4:8] == b"moof":
|
||||
total += 1
|
||||
f.seek(start)
|
||||
if _moof_still_encrypted(f.read(size)):
|
||||
surviving += 1
|
||||
if first_offset is None:
|
||||
first_offset = start
|
||||
start += size
|
||||
except Exception as e:
|
||||
logging.getLogger("track").warning(f"{path.name}: fragment decryption check did not complete ({e}).")
|
||||
return
|
||||
if surviving:
|
||||
log_event(
|
||||
"decrypt_fragments_still_encrypted",
|
||||
level="ERROR",
|
||||
message=f"{surviving}/{total} fragments still encrypted after decryption",
|
||||
file=path.name,
|
||||
surviving=surviving,
|
||||
total=total,
|
||||
first_offset=first_offset,
|
||||
)
|
||||
raise ValueError(
|
||||
f"{path.name}: {surviving}/{total} fragment(s) still encrypted after decryption (first at "
|
||||
f"byte {first_offset}). The decrypt tool skipped them silently, so check "
|
||||
f"tfhd.sample_description_index against the stsd entry count in the init segment."
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class DownloadContext:
|
||||
"""Shared arguments passed to each manifest's ``download_track``."""
|
||||
@@ -428,6 +532,7 @@ class Track:
|
||||
if drm:
|
||||
progress(downloaded="Decrypting", completed=0, total=None)
|
||||
drm.decrypt(save_path)
|
||||
assert_fragments_decrypted(save_path)
|
||||
self.drm = None
|
||||
events.emit(events.Types.TRACK_DECRYPTED, track=self, drm=drm, segment=None)
|
||||
progress(downloaded="Decrypted", completed=100, total=100)
|
||||
|
||||
Reference in New Issue
Block a user