From 3fcad1aa01437be1e6d7f08171580a1dbd08f454 Mon Sep 17 00:00:00 2001 From: Andy Date: Sat, 31 Jan 2026 22:05:44 -0700 Subject: [PATCH 1/3] feat(drm): add MonaLisa DRM support to core infrastructure - Add MonaLisaCDM class wrapping wasmtime for key extraction - Add MonaLisa DRM class with decrypt_segment() for per-segment decryption - Display Content ID and keys in download output (matching Widevine/PlayReady) - Add wasmtime dependency for WASM module execution --- pyproject.toml | 1 + unshackle/commands/dl.py | 22 +- unshackle/core/cdm/__init__.py | 3 +- unshackle/core/cdm/monalisa/__init__.py | 3 + unshackle/core/cdm/monalisa/monalisa_cdm.py | 371 ++++++++++++++++++++ unshackle/core/drm/__init__.py | 5 +- unshackle/core/drm/monalisa.py | 280 +++++++++++++++ unshackle/core/manifests/hls.py | 6 +- uv.lock | 21 ++ 9 files changed, 707 insertions(+), 5 deletions(-) create mode 100644 unshackle/core/cdm/monalisa/__init__.py create mode 100644 unshackle/core/cdm/monalisa/monalisa_cdm.py create mode 100644 unshackle/core/drm/monalisa.py diff --git a/pyproject.toml b/pyproject.toml index 2da4099..8c128d6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -66,6 +66,7 @@ dependencies = [ "PyExecJS>=1.5.1,<2", "pycountry>=24.6.1", "language-data>=1.4.0", + "wasmtime>=41.0.0", ] [project.urls] diff --git a/unshackle/commands/dl.py b/unshackle/commands/dl.py index 9a97711..b4c5434 100644 --- a/unshackle/commands/dl.py +++ b/unshackle/commands/dl.py @@ -47,7 +47,7 @@ from unshackle.core.config import config from unshackle.core.console import console from unshackle.core.constants import DOWNLOAD_LICENCE_ONLY, AnyTrack, context_settings from unshackle.core.credential import Credential -from unshackle.core.drm import DRM_T, PlayReady, Widevine +from unshackle.core.drm import DRM_T, MonaLisa, PlayReady, Widevine from unshackle.core.events import events from unshackle.core.proxies import Basic, Gluetun, Hola, NordVPN, SurfsharkVPN, WindscribeVPN from unshackle.core.service import Service @@ -2250,6 +2250,26 @@ class dl: export.write_text(jsonpickle.dumps(keys, indent=4), encoding="utf8") + elif isinstance(drm, MonaLisa): + with self.DRM_TABLE_LOCK: + display_id = drm.content_id or drm.pssh + pssh_display = self.truncate_pssh_for_display(display_id, "MonaLisa") + cek_tree = Tree(Text.assemble(("MonaLisa", "cyan"), (f"({pssh_display})", "text"), overflow="fold")) + pre_existing_tree = next( + (x for x in table.columns[0].cells if isinstance(x, Tree) and x.label == cek_tree.label), None + ) + if pre_existing_tree: + cek_tree = pre_existing_tree + + for kid_, key in drm.content_keys.items(): + label = f"[text2]{kid_.hex}:{key}" + if not any(f"{kid_.hex}:{key}" in x.label for x in cek_tree.children): + cek_tree.add(label) + + if cek_tree.children and not pre_existing_tree: + table.add_row() + table.add_row(cek_tree) + @staticmethod def get_cookie_path(service: str, profile: Optional[str]) -> Optional[Path]: """Get Service Cookie File Path for Profile.""" diff --git a/unshackle/core/cdm/__init__.py b/unshackle/core/cdm/__init__.py index 226f9ea..349099b 100644 --- a/unshackle/core/cdm/__init__.py +++ b/unshackle/core/cdm/__init__.py @@ -1,4 +1,5 @@ from .custom_remote_cdm import CustomRemoteCDM from .decrypt_labs_remote_cdm import DecryptLabsRemoteCDM +from .monalisa import MonaLisaCDM -__all__ = ["DecryptLabsRemoteCDM", "CustomRemoteCDM"] +__all__ = ["DecryptLabsRemoteCDM", "CustomRemoteCDM", "MonaLisaCDM"] diff --git a/unshackle/core/cdm/monalisa/__init__.py b/unshackle/core/cdm/monalisa/__init__.py new file mode 100644 index 0000000..999975f --- /dev/null +++ b/unshackle/core/cdm/monalisa/__init__.py @@ -0,0 +1,3 @@ +from .monalisa_cdm import MonaLisaCDM + +__all__ = ["MonaLisaCDM"] diff --git a/unshackle/core/cdm/monalisa/monalisa_cdm.py b/unshackle/core/cdm/monalisa/monalisa_cdm.py new file mode 100644 index 0000000..c5880e1 --- /dev/null +++ b/unshackle/core/cdm/monalisa/monalisa_cdm.py @@ -0,0 +1,371 @@ +""" +MonaLisa CDM - WASM-based Content Decryption Module wrapper. + +This module provides key extraction from MonaLisa-protected content using +a WebAssembly module that runs locally via wasmtime. +""" + +import base64 +import ctypes +import json +import re +import uuid +from pathlib import Path +from typing import Dict, Optional, Union + +import wasmtime + +from unshackle.core import binaries + + +class MonaLisaCDM: + """ + MonaLisa CDM wrapper for WASM-based key extraction. + + This CDM differs from Widevine/PlayReady in that it does not use a + challenge/response flow with a license server. Instead, the license + (ticket) is provided directly by the service API, and keys are extracted + locally via the WASM module. + """ + + DYNAMIC_BASE = 6065008 + DYNAMICTOP_PTR = 821968 + LICENSE_KEY_OFFSET = 0x5C8C0C + LICENSE_KEY_LENGTH = 16 + + ENV_STRINGS = ( + "USER=web_user", + "LOGNAME=web_user", + "PATH=/", + "PWD=/", + "HOME=/home/web_user", + "LANG=zh_CN.UTF-8", + "_=./this.program", + ) + + def __init__(self, device_path: Path): + """ + Initialize the MonaLisa CDM. + + Args: + device_path: Path to the device file (.mld). + """ + device_path = Path(device_path) + + self.device_path = device_path + self.base_dir = device_path.parent + + if not self.device_path.is_file(): + raise FileNotFoundError(f"Device file not found at: {self.device_path}") + + try: + data = json.loads(self.device_path.read_text(encoding="utf-8", errors="replace")) + except Exception as e: + raise ValueError(f"Invalid device file (JSON): {e}") + + wasm_path_str = data.get("wasm_path") + if not wasm_path_str: + raise ValueError("Device file missing 'wasm_path'") + + wasm_filename = Path(wasm_path_str).name + wasm_path = self.base_dir / wasm_filename + + if not wasm_path.exists(): + raise FileNotFoundError(f"WASM file not found at: {wasm_path}") + + try: + self.engine = wasmtime.Engine() + if wasm_path.suffix.lower() == ".wat": + self.module = wasmtime.Module.from_file(self.engine, str(wasm_path)) + else: + self.module = wasmtime.Module(self.engine, wasm_path.read_bytes()) + except Exception as e: + raise RuntimeError(f"Failed to load WASM module: {e}") + + self.store = None + self.memory = None + self.instance = None + self.exports = {} + self.ctx = None + + @staticmethod + def get_worker_path() -> Optional[Path]: + """Get ML-Worker binary path from the unshackle binaries system.""" + if binaries.ML_Worker: + return Path(binaries.ML_Worker) + return None + + def open(self) -> int: + """ + Open a CDM session. + + Returns: + Session ID (always 1 for MonaLisa). + + Raises: + RuntimeError: If session initialization fails. + """ + try: + self.store = wasmtime.Store(self.engine) + memory_type = wasmtime.MemoryType(wasmtime.Limits(256, 256)) + self.memory = wasmtime.Memory(self.store, memory_type) + + self._write_i32(self.DYNAMICTOP_PTR, self.DYNAMIC_BASE) + imports = self._build_imports() + self.instance = wasmtime.Instance(self.store, self.module, imports) + + ex = self.instance.exports(self.store) + self.exports = { + "___wasm_call_ctors": ex["s"], + "_monalisa_context_alloc": ex["D"], + "monalisa_set_license": ex["F"], + "_monalisa_set_canvas_id": ex["t"], + "_monalisa_version_get": ex["A"], + "monalisa_get_line_number": ex["v"], + "stackAlloc": ex["N"], + "stackSave": ex["L"], + "stackRestore": ex["M"], + } + + self.exports["___wasm_call_ctors"](self.store) + self.ctx = self.exports["_monalisa_context_alloc"](self.store) + return 1 + except Exception as e: + raise RuntimeError(f"Failed to initialize session: {e}") + + def close(self, session_id: int = 1) -> None: + """ + Close the CDM session and release resources. + + Args: + session_id: The session ID to close (unused, for API compatibility). + """ + self.store = None + self.memory = None + self.instance = None + self.exports = {} + self.ctx = None + + def extract_keys(self, license_data: Union[str, bytes]) -> Dict: + """ + Extract decryption keys from license/ticket data. + + Args: + license_data: The license ticket, either as base64 string or raw bytes. + + Returns: + Dictionary with keys: kid (hex), key (hex), type ("CONTENT"). + + Raises: + RuntimeError: If session not open or license validation fails. + ValueError: If license_data is empty. + """ + if not self.instance or not self.memory or self.ctx is None: + raise RuntimeError("Session not open. Call open() first.") + + if not license_data: + raise ValueError("license_data is empty") + + if isinstance(license_data, bytes): + license_b64 = base64.b64encode(license_data).decode("utf-8") + else: + license_b64 = license_data + + ret = self._ccall( + "monalisa_set_license", + int, + self.ctx, + license_b64, + len(license_b64), + "0", + ) + + if ret != 0: + raise RuntimeError(f"License validation failed with code: {ret}") + + key_bytes = self._extract_license_key_bytes() + + # Extract DCID from license to generate KID + try: + decoded = base64.b64decode(license_b64).decode("ascii", errors="ignore") + except Exception: + decoded = "" + + m = re.search( + r"DCID-[A-Z0-9]+-[A-Z0-9]+-\d{8}-\d{6}-[A-Z0-9]+-\d{10}-[A-Z0-9]+", + decoded, + ) + if m: + kid_bytes = uuid.uuid5(uuid.NAMESPACE_DNS, m.group()).bytes + else: + kid_bytes = uuid.UUID(int=0).bytes + + return {"kid": kid_bytes.hex(), "key": key_bytes.hex(), "type": "CONTENT"} + + def _extract_license_key_bytes(self) -> bytes: + """Extract the 16-byte decryption key from WASM memory.""" + data_ptr = self.memory.data_ptr(self.store) + data_len = self.memory.data_len(self.store) + + if self.LICENSE_KEY_OFFSET + self.LICENSE_KEY_LENGTH > data_len: + raise RuntimeError("License key offset beyond memory bounds") + + mem_ptr = ctypes.cast(data_ptr, ctypes.POINTER(ctypes.c_ubyte * data_len)) + start = self.LICENSE_KEY_OFFSET + end = self.LICENSE_KEY_OFFSET + self.LICENSE_KEY_LENGTH + + return bytes(mem_ptr.contents[start:end]) + + def _ccall(self, func_name: str, return_type: type, *args): + """Call a WASM function with automatic string conversion.""" + stack = 0 + converted_args = [] + + for arg in args: + if isinstance(arg, str): + if stack == 0: + stack = self.exports["stackSave"](self.store) + max_length = (len(arg) << 2) + 1 + ptr = self.exports["stackAlloc"](self.store, max_length) + self._string_to_utf8(arg, ptr, max_length) + converted_args.append(ptr) + else: + converted_args.append(arg) + + result = self.exports[func_name](self.store, *converted_args) + + if stack != 0: + self.exports["stackRestore"](self.store, stack) + + if return_type is bool: + return bool(result) + return result + + def _write_i32(self, addr: int, value: int) -> None: + """Write a 32-bit integer to WASM memory.""" + data = self.memory.data_ptr(self.store) + mem_ptr = ctypes.cast(data, ctypes.POINTER(ctypes.c_int32)) + mem_ptr[addr >> 2] = value + + def _string_to_utf8(self, data: str, ptr: int, max_length: int) -> int: + """Convert string to UTF-8 and write to WASM memory.""" + encoded = data.encode("utf-8") + write_length = min(len(encoded), max_length - 1) + + mem_data = self.memory.data_ptr(self.store) + mem_ptr = ctypes.cast(mem_data, ctypes.POINTER(ctypes.c_ubyte)) + + for i in range(write_length): + mem_ptr[ptr + i] = encoded[i] + mem_ptr[ptr + write_length] = 0 + return write_length + + def _write_ascii_to_memory(self, string: str, buffer: int, dont_add_null: int = 0) -> None: + """Write ASCII string to WASM memory.""" + mem_data = self.memory.data_ptr(self.store) + mem_ptr = ctypes.cast(mem_data, ctypes.POINTER(ctypes.c_ubyte)) + + encoded = string.encode("utf-8") + for i, byte_val in enumerate(encoded): + mem_ptr[buffer + i] = byte_val + + if dont_add_null == 0: + mem_ptr[buffer + len(encoded)] = 0 + + def _build_imports(self): + """Build the WASM import stubs required by the MonaLisa module.""" + + def sys_fcntl64(a, b, c): + return 0 + + def fd_write(a, b, c, d): + return 0 + + def fd_close(a): + return 0 + + def sys_ioctl(a, b, c): + return 0 + + def sys_open(a, b, c): + return 0 + + def sys_rmdir(a): + return 0 + + def sys_unlink(a): + return 0 + + def clock(): + return 0 + + def time(a): + return 0 + + def emscripten_run_script(a): + return None + + def fd_seek(a, b, c, d, e): + return 0 + + def emscripten_resize_heap(a): + return 0 + + def fd_read(a, b, c, d): + return 0 + + def emscripten_run_script_string(a): + return 0 + + def emscripten_run_script_int(a): + return 1 + + def emscripten_memcpy_big(dest, src, num): + mem_data = self.memory.data_ptr(self.store) + data_len = self.memory.data_len(self.store) + if num is None: + num = data_len - 1 + mem_ptr = ctypes.cast(mem_data, ctypes.POINTER(ctypes.c_ubyte)) + for i in range(num): + if dest + i < data_len and src + i < data_len: + mem_ptr[dest + i] = mem_ptr[src + i] + return dest + + def environ_get(environ_ptr, environ_buf): + buf_size = 0 + for index, string in enumerate(self.ENV_STRINGS): + ptr = environ_buf + buf_size + self._write_i32(environ_ptr + index * 4, ptr) + self._write_ascii_to_memory(string, ptr) + buf_size += len(string) + 1 + return 0 + + def environ_sizes_get(penviron_count, penviron_buf_size): + self._write_i32(penviron_count, len(self.ENV_STRINGS)) + buf_size = sum(len(s) + 1 for s in self.ENV_STRINGS) + self._write_i32(penviron_buf_size, buf_size) + return 0 + + i32 = wasmtime.ValType.i32() + + return [ + wasmtime.Func(self.store, wasmtime.FuncType([i32, i32, i32], [i32]), sys_fcntl64), + wasmtime.Func(self.store, wasmtime.FuncType([i32, i32, i32, i32], [i32]), fd_write), + wasmtime.Func(self.store, wasmtime.FuncType([i32], [i32]), fd_close), + wasmtime.Func(self.store, wasmtime.FuncType([i32, i32, i32], [i32]), sys_ioctl), + wasmtime.Func(self.store, wasmtime.FuncType([i32, i32, i32], [i32]), sys_open), + wasmtime.Func(self.store, wasmtime.FuncType([i32], [i32]), sys_rmdir), + wasmtime.Func(self.store, wasmtime.FuncType([i32], [i32]), sys_unlink), + wasmtime.Func(self.store, wasmtime.FuncType([], [i32]), clock), + wasmtime.Func(self.store, wasmtime.FuncType([i32], [i32]), time), + wasmtime.Func(self.store, wasmtime.FuncType([i32], []), emscripten_run_script), + wasmtime.Func(self.store, wasmtime.FuncType([i32, i32, i32, i32, i32], [i32]), fd_seek), + wasmtime.Func(self.store, wasmtime.FuncType([i32, i32, i32], [i32]), emscripten_memcpy_big), + wasmtime.Func(self.store, wasmtime.FuncType([i32], [i32]), emscripten_resize_heap), + wasmtime.Func(self.store, wasmtime.FuncType([i32, i32], [i32]), environ_get), + wasmtime.Func(self.store, wasmtime.FuncType([i32, i32], [i32]), environ_sizes_get), + wasmtime.Func(self.store, wasmtime.FuncType([i32, i32, i32, i32], [i32]), fd_read), + wasmtime.Func(self.store, wasmtime.FuncType([i32], [i32]), emscripten_run_script_string), + wasmtime.Func(self.store, wasmtime.FuncType([i32], [i32]), emscripten_run_script_int), + self.memory, + ] diff --git a/unshackle/core/drm/__init__.py b/unshackle/core/drm/__init__.py index 7622ae6..d94e912 100644 --- a/unshackle/core/drm/__init__.py +++ b/unshackle/core/drm/__init__.py @@ -1,10 +1,11 @@ from typing import Union from unshackle.core.drm.clearkey import ClearKey +from unshackle.core.drm.monalisa import MonaLisa from unshackle.core.drm.playready import PlayReady from unshackle.core.drm.widevine import Widevine -DRM_T = Union[ClearKey, Widevine, PlayReady] +DRM_T = Union[ClearKey, Widevine, PlayReady, MonaLisa] -__all__ = ("ClearKey", "Widevine", "PlayReady", "DRM_T") +__all__ = ("ClearKey", "Widevine", "PlayReady", "MonaLisa", "DRM_T") diff --git a/unshackle/core/drm/monalisa.py b/unshackle/core/drm/monalisa.py new file mode 100644 index 0000000..f89d764 --- /dev/null +++ b/unshackle/core/drm/monalisa.py @@ -0,0 +1,280 @@ +""" +MonaLisa DRM System. + +A WASM-based DRM system that uses local key extraction and two-stage +segment decryption (ML-Worker binary + AES-ECB). +""" + +from __future__ import annotations + +import os +import subprocess +import sys +from pathlib import Path +from typing import Any, Optional, Union +from uuid import UUID + +from Cryptodome.Cipher import AES +from Cryptodome.Util.Padding import unpad + + +class MonaLisa: + """ + MonaLisa DRM System. + + Unlike Widevine/PlayReady, MonaLisa does not use a challenge/response flow + with a license server. Instead, the PSSH value (ticket) is provided directly + by the service API, and keys are extracted locally via a WASM module. + + Decryption is performed in two stages: + 1. ML-Worker binary: Removes MonaLisa encryption layer (bbts -> ents) + 2. AES-ECB decryption: Final decryption with service-provided key + """ + + class Exceptions: + class TicketNotFound(Exception): + """Raised when no PSSH/ticket data is provided.""" + + class KeyExtractionFailed(Exception): + """Raised when key extraction from the ticket fails.""" + + class WorkerNotFound(Exception): + """Raised when the ML-Worker binary is not found.""" + + class DecryptionFailed(Exception): + """Raised when segment decryption fails.""" + + def __init__( + self, + ticket: Union[str, bytes], + aes_key: Union[str, bytes], + device_path: Path, + **kwargs: Any, + ): + """ + Initialize MonaLisa DRM. + + Args: + ticket: PSSH value from service API (base64 string or raw bytes). + aes_key: AES-ECB key for second-stage decryption (hex string or bytes). + device_path: Path to the CDM device file (.mld). + **kwargs: Additional metadata stored in self.data. + + Raises: + TicketNotFound: If ticket/PSSH is empty. + KeyExtractionFailed: If key extraction fails. + """ + if not ticket: + raise MonaLisa.Exceptions.TicketNotFound("No PSSH/ticket data provided.") + + self._ticket = ticket + + # Store AES key for second-stage decryption + if isinstance(aes_key, str): + self._aes_key = bytes.fromhex(aes_key) + else: + self._aes_key = aes_key + + self._device_path = device_path + self._kid: Optional[UUID] = None + self._key: Optional[str] = None + self.data: dict = kwargs or {} + + # Extract keys immediately + self._extract_keys() + + def _extract_keys(self) -> None: + """Extract keys from the ticket using the MonaLisa CDM.""" + # Import here to avoid circular import + from unshackle.core.cdm.monalisa import MonaLisaCDM + + try: + cdm = MonaLisaCDM(device_path=self._device_path) + session_id = cdm.open() + try: + keys = cdm.extract_keys(self._ticket) + if keys: + kid_hex = keys.get("kid") + if kid_hex: + self._kid = UUID(hex=kid_hex) + self._key = keys.get("key") + finally: + cdm.close(session_id) + except Exception as e: + raise MonaLisa.Exceptions.KeyExtractionFailed(f"Failed to extract keys: {e}") + + @classmethod + def from_ticket( + cls, + ticket: Union[str, bytes], + aes_key: Union[str, bytes], + device_path: Path, + ) -> MonaLisa: + """ + Create a MonaLisa DRM instance from a PSSH/ticket. + + Args: + ticket: PSSH value from service API. + aes_key: AES-ECB key for second-stage decryption. + device_path: Path to the CDM device file (.mld). + + Returns: + MonaLisa DRM instance with extracted keys. + """ + return cls(ticket=ticket, aes_key=aes_key, device_path=device_path) + + @property + def kid(self) -> Optional[UUID]: + """Get the Key ID.""" + return self._kid + + @property + def key(self) -> Optional[str]: + """Get the content key as hex string.""" + return self._key + + @property + def pssh(self) -> str: + """ + Get the raw PSSH/ticket value as a string. + + Returns: + The raw PSSH value as a base64 string. + """ + if isinstance(self._ticket, bytes): + return self._ticket.decode("utf-8") + return self._ticket + + @property + def content_id(self) -> Optional[str]: + """ + Extract the Content ID from the PSSH for display. + + The PSSH contains an embedded Content ID at bytes 21-75 with format: + H5DCID-V3-P1-YYYYMMDD-HHMMSS-MEDIAID-TIMESTAMP-SUFFIX + + Returns: + The Content ID string if extractable, None otherwise. + """ + import base64 + + try: + # Decode base64 PSSH to get raw bytes + if isinstance(self._ticket, bytes): + data = self._ticket + else: + data = base64.b64decode(self._ticket) + + # Content ID is at bytes 21-75 (55 bytes) + if len(data) >= 76: + content_id = data[21:76].decode("ascii") + # Validate it looks like a content ID + if content_id.startswith("H5DCID-"): + return content_id + except Exception: + pass + + return None + + @property + def content_keys(self) -> dict[UUID, str]: + """ + Get content keys in the same format as Widevine/PlayReady. + + Returns: + Dictionary mapping KID to key hex string. + """ + if self._kid and self._key: + return {self._kid: self._key} + return {} + + def decrypt_segment(self, segment_path: Path) -> None: + """ + Decrypt a single segment using two-stage decryption. + + Stage 1: ML-Worker binary (bbts -> ents) + Stage 2: AES-ECB decryption (ents -> ts) + + Args: + segment_path: Path to the encrypted segment file. + + Raises: + WorkerNotFound: If ML-Worker binary is not available. + DecryptionFailed: If decryption fails at any stage. + """ + if not self._key: + return + + # Import here to avoid circular import + from unshackle.core.cdm.monalisa import MonaLisaCDM + + worker_path = MonaLisaCDM.get_worker_path() + if not worker_path or not worker_path.exists(): + raise MonaLisa.Exceptions.WorkerNotFound("ML-Worker not found.") + + bbts_path = segment_path.with_suffix(".bbts") + ents_path = segment_path.with_suffix(".ents") + + try: + if segment_path.exists(): + segment_path.replace(bbts_path) + else: + raise MonaLisa.Exceptions.DecryptionFailed(f"Segment file does not exist: {segment_path}") + + # Stage 1: ML-Worker decryption + cmd = [str(worker_path), self._key, str(bbts_path), str(ents_path)] + + startupinfo = None + if sys.platform == "win32": + startupinfo = subprocess.STARTUPINFO() + startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW + + process = subprocess.run( + cmd, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + startupinfo=startupinfo, + ) + + if process.returncode != 0: + raise MonaLisa.Exceptions.DecryptionFailed( + f"ML-Worker failed for {segment_path.name}: {process.stderr}" + ) + + if not ents_path.exists(): + raise MonaLisa.Exceptions.DecryptionFailed( + f"Decrypted .ents file was not created for {segment_path.name}" + ) + + # Stage 2: AES-ECB decryption + with open(ents_path, "rb") as f: + ents_data = f.read() + + crypto = AES.new(self._aes_key, AES.MODE_ECB) + decrypted_data = unpad(crypto.decrypt(ents_data), AES.block_size) + + # Write decrypted segment back to original path + with open(segment_path, "wb") as f: + f.write(decrypted_data) + + except MonaLisa.Exceptions.DecryptionFailed: + raise + except Exception as e: + raise MonaLisa.Exceptions.DecryptionFailed(f"Failed to decrypt segment {segment_path.name}: {e}") + finally: + if ents_path.exists(): + os.remove(ents_path) + if bbts_path != segment_path and bbts_path.exists(): + os.remove(bbts_path) + + def decrypt(self, _path: Path) -> None: + """ + MonaLisa uses per-segment decryption during download via the + on_segment_downloaded callback. By the time this method is called, + the content has already been decrypted and muxed into a container. + + Args: + path: Path to the file (ignored). + """ + pass diff --git a/unshackle/core/manifests/hls.py b/unshackle/core/manifests/hls.py index 2f3dd1f..86133c0 100644 --- a/unshackle/core/manifests/hls.py +++ b/unshackle/core/manifests/hls.py @@ -30,7 +30,7 @@ from requests import Session from unshackle.core import binaries from unshackle.core.constants import DOWNLOAD_CANCELLED, DOWNLOAD_LICENCE_ONLY, AnyTrack from unshackle.core.downloaders import requests as requests_downloader -from unshackle.core.drm import DRM_T, ClearKey, PlayReady, Widevine +from unshackle.core.drm import DRM_T, ClearKey, MonaLisa, PlayReady, Widevine from unshackle.core.events import events from unshackle.core.tracks import Audio, Subtitle, Tracks, Video from unshackle.core.utilities import get_debug_logger, get_extension, is_close_match, try_ensure_utf8 @@ -316,6 +316,10 @@ class HLS: progress(downloaded="[red]FAILED") raise + if not initial_drm_licensed and session_drm and isinstance(session_drm, MonaLisa): + if license_widevine: + license_widevine(session_drm) + if DOWNLOAD_LICENCE_ONLY.is_set(): progress(downloaded="[yellow]SKIPPED") return diff --git a/uv.lock b/uv.lock index 5f8c3c2..21487dd 100644 --- a/uv.lock +++ b/uv.lock @@ -1670,6 +1670,7 @@ dependencies = [ { name = "subtitle-filter" }, { name = "unidecode" }, { name = "urllib3" }, + { name = "wasmtime" }, ] [package.dev-dependencies] @@ -1727,6 +1728,7 @@ requires-dist = [ { name = "subtitle-filter", specifier = ">=1.4.9,<2" }, { name = "unidecode", specifier = ">=1.3.8,<2" }, { name = "urllib3", specifier = ">=2.6.3,<3" }, + { name = "wasmtime", specifier = ">=41.0.0" }, ] [package.metadata.requires-dev] @@ -1766,6 +1768,25 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/6a/2a/dc2228b2888f51192c7dc766106cd475f1b768c10caaf9727659726f7391/virtualenv-20.36.1-py3-none-any.whl", hash = "sha256:575a8d6b124ef88f6f51d56d656132389f961062a9177016a50e4f507bbcc19f", size = 6008258, upload-time = "2026-01-09T18:20:59.425Z" }, ] +[[package]] +name = "wasmtime" +version = "41.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/be/68/6dc0e7156f883afe0129dd89e4031c8d1163131794ba6ce9e454a09168ad/wasmtime-41.0.0.tar.gz", hash = "sha256:fc2aaacf3ba794eac8baeb739939b2f7903e12d6b78edddc0b7f3ac3a9af6dfc", size = 117354, upload-time = "2026-01-20T18:18:00.565Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/31/f9/f6aef5de536d12652d97cf162f124cbdd642150c7da61ffa7863272cdab7/wasmtime-41.0.0-py3-none-android_26_arm64_v8a.whl", hash = "sha256:f5a6e237b5b94188ef9867926b447f779f540c729c92e4d91cc946f2bee7c282", size = 6837018, upload-time = "2026-01-20T18:17:41.489Z" }, + { url = "https://files.pythonhosted.org/packages/04/b9/42ec977972b2dcc8c61e3a40644d24d229b41fba151410644e44e35e6eb1/wasmtime-41.0.0-py3-none-android_26_x86_64.whl", hash = "sha256:4a3e33d0d3cf49062eaa231f748f54af991e89e9a795c5ab9d4f0eee85736e4c", size = 7654957, upload-time = "2026-01-20T18:17:43.285Z" }, + { url = "https://files.pythonhosted.org/packages/18/ca/6cce49b03c35c7fecb4437fd98990c64694a5e0024f9279bef0ddef000f7/wasmtime-41.0.0-py3-none-any.whl", hash = "sha256:5f6721406a6cd186d11f34e6d4991c4d536387b0c577d09a56bd93b8a3cf10c2", size = 6325757, upload-time = "2026-01-20T18:17:44.789Z" }, + { url = "https://files.pythonhosted.org/packages/a0/16/d91cb80322cc7ae10bfa5db8cea4e0b9bb112f0c100b4486783ab16c1c22/wasmtime-41.0.0-py3-none-macosx_10_13_x86_64.whl", hash = "sha256:2107360212fce33ed2adcfc33b7e75ed7136380a17d3ed598a5bab376dcf9e1b", size = 7471888, upload-time = "2026-01-20T18:17:46.185Z" }, + { url = "https://files.pythonhosted.org/packages/bc/f0/dcc80973d2ec58a1978b838887ccbd84d56900cf66dec5fb730bec3bd081/wasmtime-41.0.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:f475df32ce9bfec4f6d0e124a49ca4a89e2ee71ccca460677f5237b1c8ee92ae", size = 6507285, upload-time = "2026-01-20T18:17:48.138Z" }, + { url = "https://files.pythonhosted.org/packages/bd/df/0867edd9ec26eb2e5eee7674a55f82c23ec27dd1d38d2d401f0e308eb920/wasmtime-41.0.0-py3-none-manylinux1_x86_64.whl", hash = "sha256:ad7e866430313eb2ee07c85811e524344884489d00896f3b2246b65553fe322c", size = 7732024, upload-time = "2026-01-20T18:17:50.207Z" }, + { url = "https://files.pythonhosted.org/packages/bb/48/b748a2e70478feabc5c876d90e90a39f4aba35378f5ee822f607e8f29c69/wasmtime-41.0.0-py3-none-manylinux2014_aarch64.whl", hash = "sha256:e0ea44584f60dcfa620af82d4fc2589248bcf64a93905b54ac3144242113b48a", size = 6800017, upload-time = "2026-01-20T18:17:51.7Z" }, + { url = "https://files.pythonhosted.org/packages/14/29/43656c3a464d437d62421de16f2de2db645647bab0a0153deea30bfdade4/wasmtime-41.0.0-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:1dabb20a2751f01b835095013426a76091bd0bdb36ca9bcfc49c910b78347438", size = 6840763, upload-time = "2026-01-20T18:17:53.125Z" }, + { url = "https://files.pythonhosted.org/packages/9f/09/4608b65fa35ce5fc1479e138293a1166b4ea817cfa9a79f019ab6d7013d8/wasmtime-41.0.0-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:d9627dfc5625b4947ea35c819561da358838fe76f65bda8ffe01ce34df8b32b1", size = 7754016, upload-time = "2026-01-20T18:17:55.346Z" }, + { url = "https://files.pythonhosted.org/packages/f4/9d/236bb367270579e4f628fb7b04fe93541151df7953006f3766607fc667c9/wasmtime-41.0.0-py3-none-win_amd64.whl", hash = "sha256:4f29171d73b71f232b6fe86cba77526fee84139f1590071af5facba401b0c9eb", size = 6325764, upload-time = "2026-01-20T18:17:57.034Z" }, + { url = "https://files.pythonhosted.org/packages/4a/4a/bba9c0368c377250ab24fd005a7a1e9076121778c1e83b1bcc092ab84f86/wasmtime-41.0.0-py3-none-win_arm64.whl", hash = "sha256:0c4bcaba055e78fc161f497b85f39f1d35d475f0341b1e0259fa0a4b49e223e8", size = 5392238, upload-time = "2026-01-20T18:17:59.052Z" }, +] + [[package]] name = "wcwidth" version = "0.3.3" From a07191ac4f35274edeeee802032841f685baa052 Mon Sep 17 00:00:00 2001 From: Andy Date: Sat, 31 Jan 2026 22:06:04 -0700 Subject: [PATCH 2/3] fix(binaries): search subdirectories for binary files Allow binaries to be found in subdirectories of the binaries folder. --- unshackle/core/binaries.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/unshackle/core/binaries.py b/unshackle/core/binaries.py index d984c0c..598387c 100644 --- a/unshackle/core/binaries.py +++ b/unshackle/core/binaries.py @@ -17,6 +17,10 @@ def find(*names: str) -> Optional[Path]: if local_binaries_dir.exists(): candidate_paths = [local_binaries_dir / f"{name}{ext}", local_binaries_dir / name / f"{name}{ext}"] + for subdir in local_binaries_dir.iterdir(): + if subdir.is_dir(): + candidate_paths.append(subdir / f"{name}{ext}") + for path in candidate_paths: if path.is_file(): # On Unix-like systems, check if file is executable From ecedcb93eb446a0202938ffda9242de3a1bf1de3 Mon Sep 17 00:00:00 2001 From: Andy Date: Mon, 2 Feb 2026 08:24:13 -0700 Subject: [PATCH 3/3] fix(drm): hide Shaka Packager message for MonaLisa decryption --- unshackle/commands/dl.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/unshackle/commands/dl.py b/unshackle/commands/dl.py index b4c5434..f3eda8d 100644 --- a/unshackle/commands/dl.py +++ b/unshackle/commands/dl.py @@ -1634,7 +1634,8 @@ class dl: drm = track.get_drm_for_cdm(self.cdm) if drm and hasattr(drm, "decrypt"): drm.decrypt(track.path) - has_decrypted = True + if not isinstance(drm, MonaLisa): + has_decrypted = True events.emit(events.Types.TRACK_REPACKED, track=track) else: self.log.warning(