7 Commits

Author SHA1 Message Date
Andy
7c1de44ea4 Merge PR #78: Add support for httpx clients in service files 2026-02-09 16:17:40 -07:00
trivial99
7f6c81aaf2 add
httpx
client
as
possible
session
type
2026-02-09 20:51:24 +01:00
Andy
29a697a8e7 fix(tracks): close temp session and improve path type error 2026-02-08 20:04:22 -07:00
Andy
c5b063391c fix(serve): default PlayReady access to none
Remove unreachable fallback to all devices; if a user has no explicit playready_devices configured, the PlayReady subapp receives an empty list (secure-by-default).
2026-02-08 20:00:39 -07:00
Andy
5fa0b33664 revert(monalisa): pass key via argv again
Reverts the env/stdin key passing change introduced in 6c83790, since ML-Worker builds in use expect the key as argv[1].
2026-02-08 19:51:22 -07:00
Andy
5650c2b591 fix(hls): remove no-op encryption_data reassignment 2026-02-08 10:43:49 -07:00
Andy
5f49663ea8 fix(monalisa): harden wasm calls and license handling
- Validate _monalisa_context_alloc return and cleanup on init failure
- Derive deterministic KID when DCID missing to avoid collisions
- Ensure stackRestore always runs via try/finally in _ccall
- Log base64 decode failures without leaking license contents
- Add bounds/alignment checks for i32 memory writes
2026-02-08 10:39:23 -07:00
12 changed files with 157 additions and 80 deletions

View File

@@ -57,7 +57,7 @@ dependencies = [
"chardet>=5.2.0,<6",
"curl-cffi>=0.7.0b4,<0.14",
"pyplayready>=0.6.3,<0.7",
"httpx>=0.28.1,<0.29",
"httpx[http2]>=0.28.1,<0.29",
"cryptography>=45.0.0,<47",
"subby",
"aiohttp>=3.13.3,<4",

View File

@@ -211,7 +211,7 @@ def serve(
"devices": prd_devices,
"users": {
user_key: {
"devices": user_cfg.get("playready_devices", prd_device_names),
"devices": user_cfg.get("playready_devices", []),
"username": user_cfg.get("username", "user"),
}
for user_key, user_cfg in serve_config["users"].items()

View File

@@ -7,8 +7,11 @@ a WebAssembly module that runs locally via wasmtime.
import base64
import ctypes
import hashlib
import json
import logging
import re
import sys
import uuid
from pathlib import Path
from typing import Dict, Optional, Union
@@ -17,6 +20,8 @@ import wasmtime
from unshackle.core import binaries
logger = logging.getLogger(__name__)
class MonaLisaCDM:
"""
@@ -128,10 +133,27 @@ class MonaLisaCDM:
}
self.exports["___wasm_call_ctors"](self.store)
self.ctx = self.exports["_monalisa_context_alloc"](self.store)
ctx = self.exports["_monalisa_context_alloc"](self.store)
self.ctx = ctx
# _monalisa_context_alloc is expected to return a positive pointer/handle.
# Treat 0/negative/non-int-like values as allocation failure.
try:
ctx_int = int(ctx)
except Exception:
ctx_int = None
if ctx_int is None or ctx_int <= 0:
# Ensure we don't leave a partially-initialized instance around.
self.close()
raise RuntimeError(f"Failed to allocate MonaLisa context (ctx={ctx!r})")
return 1
except Exception as e:
raise RuntimeError(f"Failed to initialize session: {e}")
# Clean up partial state (e.g., store/memory/instance) before propagating failure.
self.close()
if isinstance(e, RuntimeError):
raise
raise RuntimeError(f"Failed to initialize session: {e}") from e
def close(self, session_id: int = 1) -> None:
"""
@@ -188,7 +210,9 @@ class MonaLisaCDM:
# Extract DCID from license to generate KID
try:
decoded = base64.b64decode(license_b64).decode("ascii", errors="ignore")
except Exception:
except Exception as e:
# Avoid logging raw license content; log only safe metadata.
logger.exception("Failed to base64-decode MonaLisa license (len=%s): %s", len(license_b64), e)
decoded = ""
m = re.search(
@@ -198,7 +222,14 @@ class MonaLisaCDM:
if m:
kid_bytes = uuid.uuid5(uuid.NAMESPACE_DNS, m.group()).bytes
else:
kid_bytes = uuid.UUID(int=0).bytes
# No DCID in the license: derive a deterministic per-license KID to avoid collisions.
try:
license_raw = base64.b64decode(license_b64)
except Exception:
license_raw = license_b64.encode("utf-8", errors="replace")
license_hash = hashlib.sha256(license_raw).hexdigest()
kid_bytes = uuid.uuid5(uuid.NAMESPACE_DNS, f"monalisa:license:{license_hash}").bytes
return {"kid": kid_bytes.hex(), "key": key_bytes.hex(), "type": "CONTENT"}
@@ -221,21 +252,29 @@ class MonaLisaCDM:
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)
try:
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)
result = self.exports[func_name](self.store, *converted_args)
finally:
# stackAlloc pointers live on the WASM stack; always restore even if the call throws.
if stack != 0:
exc = sys.exc_info()[1]
try:
self.exports["stackRestore"](self.store, stack)
except Exception:
# If we're already failing, don't mask the original exception.
if exc is None:
raise
if return_type is bool:
return bool(result)
@@ -243,6 +282,13 @@ class MonaLisaCDM:
def _write_i32(self, addr: int, value: int) -> None:
"""Write a 32-bit integer to WASM memory."""
if addr % 4 != 0:
raise ValueError(f"Unaligned i32 write: addr={addr} (must be 4-byte aligned)")
data_len = self.memory.data_len(self.store)
if addr < 0 or addr + 4 > data_len:
raise IndexError(f"i32 write out of bounds: addr={addr}, mem_len={data_len}")
data = self.memory.data_ptr(self.store)
mem_ptr = ctypes.cast(data, ctypes.POINTER(ctypes.c_int32))
mem_ptr[addr >> 2] = value

View File

@@ -11,6 +11,7 @@ from Cryptodome.Util.Padding import unpad
from curl_cffi.requests import Session as CurlSession
from m3u8.model import Key
from requests import Session
import httpx
class ClearKey:
@@ -70,8 +71,8 @@ class ClearKey:
"""
if not isinstance(m3u_key, Key):
raise ValueError(f"Provided M3U Key is in an unexpected type {m3u_key!r}")
if not isinstance(session, (Session, CurlSession, type(None))):
raise TypeError(f"Expected session to be a {Session} or {CurlSession}, not a {type(session)}")
if not isinstance(session, (Session, CurlSession, httpx.Client, type(None))):
raise TypeError(f"Expected session to be a {Session} or {CurlSession} or {httpx.Client}, not a {type(session)}")
if not m3u_key.method.startswith("AES"):
raise ValueError(f"Provided M3U Key is not an AES Clear Key, {m3u_key.method}")

View File

@@ -234,11 +234,7 @@ class MonaLisa:
raise MonaLisa.Exceptions.DecryptionFailed(f"Segment file does not exist: {segment_path}")
# Stage 1: ML-Worker decryption
# Do not pass secrets via argv (visible in process listings/logs).
# ML-Worker supports receiving the key out-of-band; we provide it via env + stdin.
cmd = [str(worker_path), "-", str(bbts_path), str(ents_path)]
worker_env = os.environ.copy()
worker_env["WORKER_KEY"] = self._key
cmd = [str(worker_path), str(self._key), str(bbts_path), str(ents_path)]
startupinfo = None
if sys.platform == "win32":
@@ -251,8 +247,6 @@ class MonaLisa:
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
input=self._key,
env=worker_env,
startupinfo=startupinfo,
timeout=worker_timeout_s,
)

View File

@@ -15,6 +15,7 @@ from urllib.parse import urljoin, urlparse
from uuid import UUID
from zlib import crc32
import httpx
import requests
from curl_cffi.requests import Session as CurlSession
from langcodes import Language, tag_is_valid
@@ -50,7 +51,7 @@ class DASH:
self.url = url
@classmethod
def from_url(cls, url: str, session: Optional[Union[Session, CurlSession]] = None, **args: Any) -> DASH:
def from_url(cls, url: str, session: Optional[Union[Session, CurlSession, httpx.Client]] = None, **args: Any) -> DASH:
if not url:
raise requests.URLRequired("DASH manifest URL must be provided for relative path computations.")
if not isinstance(url, str):
@@ -58,16 +59,15 @@ class DASH:
if not session:
session = Session()
elif not isinstance(session, (Session, CurlSession)):
raise TypeError(f"Expected session to be a {Session} or {CurlSession}, not {session!r}")
res = session.get(url, **args)
elif not isinstance(session, (Session, CurlSession, httpx.Client)):
raise TypeError(f"Expected session to be a {Session} or {CurlSession} or {httpx.Client}, not {session!r}")
try:
res = session.get(url, **args)
res.raise_for_status()
except Exception as e:
raise RuntimeError("Failed to request the MPD document.") from e
if res.url != url:
url = res.url
if not res.ok:
raise requests.ConnectionError("Failed to request the MPD document.", response=res)
return DASH.from_text(res.text, url)
@classmethod
@@ -261,8 +261,8 @@ class DASH:
):
if not session:
session = Session()
elif not isinstance(session, (Session, CurlSession)):
raise TypeError(f"Expected session to be a {Session} or {CurlSession}, not {session!r}")
elif not isinstance(session, (Session, CurlSession, httpx.Client)):
raise TypeError(f"Expected session to be a {Session} or {CurlSession} or {httpx.Client}, not {session!r}")
if proxy:
session.proxies.update({"all": proxy})

View File

@@ -15,6 +15,7 @@ from urllib.parse import urljoin
from uuid import UUID
from zlib import crc32
import httpx
import m3u8
import requests
from curl_cffi.requests import Response as CurlResponse
@@ -38,7 +39,7 @@ from unshackle.core.utilities import get_debug_logger, get_extension, is_close_m
class HLS:
def __init__(self, manifest: M3U8, session: Optional[Union[Session, CurlSession]] = None):
def __init__(self, manifest: M3U8, session: Optional[Union[Session, CurlSession, httpx.Client]] = None):
if not manifest:
raise ValueError("HLS manifest must be provided.")
if not isinstance(manifest, M3U8):
@@ -50,7 +51,7 @@ class HLS:
self.session = session or Session()
@classmethod
def from_url(cls, url: str, session: Optional[Union[Session, CurlSession]] = None, **args: Any) -> HLS:
def from_url(cls, url: str, session: Optional[Union[Session, CurlSession, httpx.Client]] = None, **args: Any) -> HLS:
if not url:
raise requests.URLRequired("HLS manifest URL must be provided.")
if not isinstance(url, str):
@@ -58,23 +59,15 @@ class HLS:
if not session:
session = Session()
elif not isinstance(session, (Session, CurlSession)):
raise TypeError(f"Expected session to be a {Session} or {CurlSession}, not {session!r}")
res = session.get(url, **args)
# Handle requests and curl_cffi response objects
if isinstance(res, requests.Response):
if not res.ok:
raise requests.ConnectionError("Failed to request the M3U(8) document.", response=res)
content = res.text
elif isinstance(res, CurlResponse):
if not res.ok:
raise requests.ConnectionError("Failed to request the M3U(8) document.", response=res)
content = res.text
else:
raise TypeError(f"Expected response to be a requests.Response or curl_cffi.Response, not {type(res)}")
elif not isinstance(session, (Session, CurlSession, httpx.Client)):
raise TypeError(f"Expected session to be a {Session} or {CurlSession} or {httpx.Client}, not {session!r}")
try:
res = session.get(url, **args)
res.raise_for_status()
except Exception as e:
raise RuntimeError("Failed to request the M3U(8) document.") from e
content = res.text
master = m3u8.loads(content, uri=url)
return cls(master, session)
@@ -265,7 +258,7 @@ class HLS:
save_path: Path,
save_dir: Path,
progress: partial,
session: Optional[Union[Session, CurlSession]] = None,
session: Optional[Union[Session, CurlSession, httpx.Client]] = None,
proxy: Optional[str] = None,
max_workers: Optional[int] = None,
license_widevine: Optional[Callable] = None,
@@ -274,8 +267,8 @@ class HLS:
) -> None:
if not session:
session = Session()
elif not isinstance(session, (Session, CurlSession)):
raise TypeError(f"Expected session to be a {Session} or {CurlSession}, not {session!r}")
elif not isinstance(session, (Session, CurlSession, httpx.Client)):
raise TypeError(f"Expected session to be a {Session} or {CurlSession} or {httpx.Client}, not {session!r}")
if proxy:
# Handle proxies differently based on session type
@@ -611,8 +604,6 @@ class HLS:
discon_i += 1
range_offset = 0 # TODO: Should this be reset or not?
map_data = None
if encryption_data:
encryption_data = (encryption_data[0], encryption_data[1])
if segment.init_section and (not map_data or segment.init_section != map_data[0]):
if segment.init_section.byterange:
@@ -850,7 +841,7 @@ class HLS:
@staticmethod
def parse_session_data_keys(
manifest: M3U8, session: Optional[Union[Session, CurlSession]] = None
manifest: M3U8, session: Optional[Union[Session, CurlSession, httpx.Client]] = None
) -> list[m3u8.model.Key]:
"""Parse `com.apple.hls.keys` session data and return Key objects."""
keys: list[m3u8.model.Key] = []
@@ -925,7 +916,7 @@ class HLS:
def get_track_kid_from_init(
master: M3U8,
track: AnyTrack,
session: Union[Session, CurlSession],
session: Union[Session, CurlSession, httpx.Client],
) -> Optional[UUID]:
"""
Extract the track's Key ID from its init segment (EXT-X-MAP).
@@ -992,7 +983,7 @@ class HLS:
@staticmethod
def get_drm(
key: Union[m3u8.model.SessionKey, m3u8.model.Key],
session: Optional[Union[Session, CurlSession]] = None,
session: Optional[Union[Session, CurlSession, httpx.Client]] = None,
) -> DRM_T:
"""
Convert HLS EXT-X-KEY data to an initialized DRM object.
@@ -1004,8 +995,8 @@ class HLS:
Raises a NotImplementedError if the key system is not supported.
"""
if not isinstance(session, (Session, CurlSession, type(None))):
raise TypeError(f"Expected session to be a {Session} or {CurlSession}, not {type(session)}")
if not isinstance(session, (Session, CurlSession, httpx.Client, type(None))):
raise TypeError(f"Expected session to be a {Session} or {CurlSession} or {httpx.Client}, not {type(session)}")
if not session:
session = Session()

View File

@@ -9,6 +9,7 @@ from functools import partial
from pathlib import Path
from typing import Any, Callable, Optional, Union
import httpx
import requests
from curl_cffi.requests import Session as CurlSession
from langcodes import Language, tag_is_valid
@@ -35,13 +36,13 @@ class ISM:
self.url = url
@classmethod
def from_url(cls, url: str, session: Optional[Union[Session, CurlSession]] = None, **kwargs: Any) -> "ISM":
def from_url(cls, url: str, session: Optional[Union[Session, CurlSession, httpx.Client]] = None, **kwargs: Any) -> "ISM":
if not url:
raise requests.URLRequired("ISM manifest URL must be provided")
if not session:
session = Session()
elif not isinstance(session, (Session, CurlSession)):
raise TypeError(f"Expected session to be a {Session} or {CurlSession}, not {session!r}")
elif not isinstance(session, (Session, CurlSession, httpx.Client)):
raise TypeError(f"Expected session to be a {Session} or {CurlSession} or {httpx.Client}, not {session!r}")
res = session.get(url, **kwargs)
if res.url != url:
url = res.url

View File

@@ -4,6 +4,7 @@ from __future__ import annotations
from typing import Optional, Union
import httpx
import m3u8
from curl_cffi.requests import Session as CurlSession
from requests import Session
@@ -16,7 +17,7 @@ def parse(
master: m3u8.M3U8,
language: str,
*,
session: Optional[Union[Session, CurlSession]] = None,
session: Optional[Union[Session, CurlSession, httpx.Client]] = None,
) -> Tracks:
"""Parse a variant playlist to ``Tracks`` with basic information, defer DRM loading."""
tracks = HLS(master, session=session).to_tracks(language)

View File

@@ -65,9 +65,13 @@ class Attachment:
path = None
else:
try:
session = session or requests.Session()
response = session.get(url, stream=True)
response.raise_for_status()
if session is None:
with requests.Session() as session:
response = session.get(url, stream=True)
response.raise_for_status()
else:
response = session.get(url, stream=True)
response.raise_for_status()
config.directories.temp.mkdir(parents=True, exist_ok=True)
download_path.parent.mkdir(parents=True, exist_ok=True)
@@ -80,7 +84,9 @@ class Attachment:
raise ValueError(f"Failed to download attachment from URL: {e}")
if path is not None and not isinstance(path, (str, Path)):
raise ValueError("The attachment path must be provided.")
raise ValueError(
f"Invalid attachment path type: expected str or Path, got {type(path).__name__}."
)
if path is not None:
path = Path(path)

View File

@@ -13,6 +13,7 @@ from typing import Any, Callable, Iterable, Optional, Union
from uuid import UUID
from zlib import crc32
import httpx
from curl_cffi.requests import Session as CurlSession
from langcodes import Language
from requests import Session
@@ -613,8 +614,8 @@ class Track:
raise TypeError(f"Expected url to be a {str}, not {type(url)}")
if not isinstance(byte_range, (str, type(None))):
raise TypeError(f"Expected byte_range to be a {str}, not {type(byte_range)}")
if not isinstance(session, (Session, CurlSession, type(None))):
raise TypeError(f"Expected session to be a {Session} or {CurlSession}, not {type(session)}")
if not isinstance(session, (Session, CurlSession, httpx.Client, type(None))):
raise TypeError(f"Expected session to be a {Session} or {CurlSession} or {httpx.Client}, not {type(session)}")
if not url:
if self.descriptor != self.Descriptor.URL:

40
uv.lock generated
View File

@@ -608,6 +608,28 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" },
]
[[package]]
name = "h2"
version = "4.3.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "hpack" },
{ name = "hyperframe" },
]
sdist = { url = "https://files.pythonhosted.org/packages/1d/17/afa56379f94ad0fe8defd37d6eb3f89a25404ffc71d4d848893d270325fc/h2-4.3.0.tar.gz", hash = "sha256:6c59efe4323fa18b47a632221a1888bd7fde6249819beda254aeca909f221bf1", size = 2152026, upload-time = "2025-08-23T18:12:19.778Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/69/b2/119f6e6dcbd96f9069ce9a2665e0146588dc9f88f29549711853645e736a/h2-4.3.0-py3-none-any.whl", hash = "sha256:c438f029a25f7945c69e0ccf0fb951dc3f73a5f6412981daee861431b70e2bdd", size = 61779, upload-time = "2025-08-23T18:12:17.779Z" },
]
[[package]]
name = "hpack"
version = "4.1.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/2c/48/71de9ed269fdae9c8057e5a4c0aa7402e8bb16f2c6e90b3aa53327b113f8/hpack-4.1.0.tar.gz", hash = "sha256:ec5eca154f7056aa06f196a557655c5b009b382873ac8d1e66e79e87535f1dca", size = 51276, upload-time = "2025-01-22T21:44:58.347Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/07/c6/80c95b1b2b94682a72cbdbfb85b81ae2daffa4291fbfa1b1464502ede10d/hpack-4.1.0-py3-none-any.whl", hash = "sha256:157ac792668d995c657d93111f46b4535ed114f0c9c8d672271bbec7eae1b496", size = 34357, upload-time = "2025-01-22T21:44:56.92Z" },
]
[[package]]
name = "httpcore"
version = "1.0.9"
@@ -636,6 +658,20 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" },
]
[package.optional-dependencies]
http2 = [
{ name = "h2" },
]
[[package]]
name = "hyperframe"
version = "6.1.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/02/e7/94f8232d4a74cc99514c13a9f995811485a6903d48e5d952771ef6322e30/hyperframe-6.1.0.tar.gz", hash = "sha256:f630908a00854a7adeabd6382b43923a4c4cd4b821fcb527e6ab9e15382a3b08", size = 26566, upload-time = "2025-01-22T21:41:49.302Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/48/30/47d0bf6072f7252e6521f3447ccfa40b421b6824517f82854703d0f5a98b/hyperframe-6.1.0-py3-none-any.whl", hash = "sha256:b03380493a519fce58ea5af42e4a42317bf9bd425596f7a0835ffce80f1a42e5", size = 13007, upload-time = "2025-01-22T21:41:47.295Z" },
]
[[package]]
name = "identify"
version = "2.6.16"
@@ -1642,7 +1678,7 @@ dependencies = [
{ name = "curl-cffi" },
{ name = "filelock" },
{ name = "fonttools" },
{ name = "httpx" },
{ name = "httpx", extra = ["http2"] },
{ name = "jsonpickle" },
{ name = "langcodes" },
{ name = "language-data" },
@@ -1700,7 +1736,7 @@ requires-dist = [
{ name = "curl-cffi", specifier = ">=0.7.0b4,<0.14" },
{ name = "filelock", specifier = ">=3.20.3,<4" },
{ name = "fonttools", specifier = ">=4.60.2,<5" },
{ name = "httpx", specifier = ">=0.28.1,<0.29" },
{ name = "httpx", extras = ["http2"], specifier = ">=0.28.1,<0.29" },
{ name = "jsonpickle", specifier = ">=3.0.4,<5" },
{ name = "langcodes", specifier = ">=3.4.0,<4" },
{ name = "language-data", specifier = ">=1.4.0" },