mirror of
https://github.com/unshackle-dl/unshackle.git
synced 2026-03-10 08:29:00 +00:00
Merge branch 'feat/monalisa-drm' into dev
This commit is contained in:
@@ -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]
|
||||
|
||||
@@ -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
|
||||
@@ -1634,6 +1634,7 @@ class dl:
|
||||
drm = track.get_drm_for_cdm(self.cdm)
|
||||
if drm and hasattr(drm, "decrypt"):
|
||||
drm.decrypt(track.path)
|
||||
if not isinstance(drm, MonaLisa):
|
||||
has_decrypted = True
|
||||
events.emit(events.Types.TRACK_REPACKED, track=track)
|
||||
else:
|
||||
@@ -2250,6 +2251,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."""
|
||||
|
||||
@@ -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"]
|
||||
|
||||
3
unshackle/core/cdm/monalisa/__init__.py
Normal file
3
unshackle/core/cdm/monalisa/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from .monalisa_cdm import MonaLisaCDM
|
||||
|
||||
__all__ = ["MonaLisaCDM"]
|
||||
371
unshackle/core/cdm/monalisa/monalisa_cdm.py
Normal file
371
unshackle/core/cdm/monalisa/monalisa_cdm.py
Normal file
@@ -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,
|
||||
]
|
||||
@@ -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")
|
||||
|
||||
280
unshackle/core/drm/monalisa.py
Normal file
280
unshackle/core/drm/monalisa.py
Normal file
@@ -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
|
||||
@@ -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
|
||||
|
||||
21
uv.lock
generated
21
uv.lock
generated
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user