Cdm: Rework init_data param to expect PSSH object

A by product of this change is dropped support for providing a PSSH or init data directly in any form, that includes base64.

You must now provide it as a PSSH object, e.g., `cdm.get_license_challenge(session_id, PSSH("AAAAW3Bzc2...CSEQyAA=="))`

The idea behind this is to simplify the amount of places where parsing of PSSH and Init Data to a minimal amount. The codebase is getting quite annoying with the constant jumps and places where it needs to test for base64 strings, hex strings, bytes, and direct parsed PSSH boxes or WidevinePsshData. That's a ridiculous amount of code just to take in a pssh/init data, especially when the full pssh box will eventually be discarded/unused by the Cdm, as it just cares about the init data.

Client code should pass any PSSH value they get into a PSSH object appropriately, and then store it as such, instead of as a string or bytes. This makes it overall more powerful thanks to the ability to also access the underlying PSSH data more easily with this change.

It also helps to increase contrast between a compliant Widevine Cenc Header or PSSH Box, and arbitrary data (e.g., Netflix WidevineExchange's init data) because of how you initialize the PSSH.

It also allows the user to more accurately trace the underlying final parse of the PSSH value, instead of looking at it being pinged between multiple functions.

RemoteCdm now also sends the PSSH/init_data in full box form now, the serve API will be able to handle both scenarios but in edge cases providing the full box may be the difference between a working License Request and not.
This commit is contained in:
rlaphoenix 2022-08-05 08:08:15 +01:00
parent 2a87d55e20
commit 27a701aaea
4 changed files with 25 additions and 19 deletions

View File

@ -235,7 +235,7 @@ class Cdm:
def get_license_challenge( def get_license_challenge(
self, self,
session_id: bytes, session_id: bytes,
init_data: Union[Container, bytes, str], pssh: PSSH,
type_: Union[int, str] = LicenseType.STREAMING, type_: Union[int, str] = LicenseType.STREAMING,
privacy_mode: bool = True privacy_mode: bool = True
) -> bytes: ) -> bytes:
@ -244,8 +244,7 @@ class Cdm:
Parameters: Parameters:
session_id: Session identifier. session_id: Session identifier.
init_data: Widevine Cenc Header (Init Data) or a Protection System Specific pssh: PSSH Object to get the init data from.
Header Box to take the init data from.
type_: Type of License you wish to exchange, often `STREAMING`. The `OFFLINE` type_: Type of License you wish to exchange, often `STREAMING`. The `OFFLINE`
Licenses are for Offline licensing of Downloaded content. Licenses are for Offline licensing of Downloaded content.
privacy_mode: Encrypt the Client ID using the Privacy Certificate. If the privacy_mode: Encrypt the Client ID using the Privacy Certificate. If the
@ -265,12 +264,10 @@ class Cdm:
if not session: if not session:
raise InvalidSession(f"Session identifier {session_id!r} is invalid.") raise InvalidSession(f"Session identifier {session_id!r} is invalid.")
if not init_data: if not pssh:
raise InvalidInitData("The init_data must not be empty.") raise InvalidInitData("A pssh must be provided.")
try: if not isinstance(pssh, PSSH):
init_data = PSSH(init_data).init_data raise InvalidInitData(f"Expected pssh to be a {PSSH}, not {pssh!r}")
except (ValueError, binascii.Error, DecodeError) as e:
raise InvalidInitData(str(e))
try: try:
if isinstance(type_, int): if isinstance(type_, int):
@ -290,7 +287,9 @@ class Cdm:
license_request.protocol_version = ProtocolVersion.Value("VERSION_2_1") license_request.protocol_version = ProtocolVersion.Value("VERSION_2_1")
license_request.key_control_nonce = random.randrange(1, 2 ** 31) license_request.key_control_nonce = random.randrange(1, 2 ** 31)
license_request.content_id.widevine_pssh_data.pssh_data.append(init_data) # pssh_data may be either a WidevineCencHeader or custom data
# we have to assume the pssh.init_data value is valid, we cannot test
license_request.content_id.widevine_pssh_data.pssh_data.append(pssh.init_data)
license_request.content_id.widevine_pssh_data.license_type = type_ license_request.content_id.widevine_pssh_data.license_type = type_
license_request.content_id.widevine_pssh_data.request_id = request_id license_request.content_id.widevine_pssh_data.request_id = request_id

View File

@ -13,6 +13,7 @@ from pywidevine import __version__
from pywidevine.cdm import Cdm from pywidevine.cdm import Cdm
from pywidevine.device import Device from pywidevine.device import Device
from pywidevine.license_protocol_pb2 import LicenseType, FileHashes from pywidevine.license_protocol_pb2 import LicenseType, FileHashes
from pywidevine.pssh import PSSH
@click.group(invoke_without_command=True) @click.group(invoke_without_command=True)
@ -62,6 +63,9 @@ def license_(device: Path, pssh: str, server: str, type_: str, privacy: bool):
""" """
log = logging.getLogger("license") log = logging.getLogger("license")
# prepare pssh
pssh = PSSH(pssh)
# load device # load device
device = Device.load(device) device = Device.load(device)
log.info(f"[+] Loaded Device ({device.system_id} L{device.security_level})") log.info(f"[+] Loaded Device ({device.system_id} L{device.security_level})")

View File

@ -153,7 +153,7 @@ class RemoteCdm(Cdm):
def get_license_challenge( def get_license_challenge(
self, self,
session_id: bytes, session_id: bytes,
init_data: Union[Container, bytes, str], pssh: PSSH,
type_: Union[int, str] = LicenseType.STREAMING, type_: Union[int, str] = LicenseType.STREAMING,
privacy_mode: bool = True privacy_mode: bool = True
) -> bytes: ) -> bytes:
@ -161,12 +161,10 @@ class RemoteCdm(Cdm):
if not session: if not session:
raise InvalidSession(f"Session identifier {session_id!r} is invalid.") raise InvalidSession(f"Session identifier {session_id!r} is invalid.")
if not init_data: if not pssh:
raise InvalidInitData("The init_data must not be empty.") raise InvalidInitData("A pssh must be provided.")
try: if not isinstance(pssh, PSSH):
init_data = PSSH(init_data).init_data raise InvalidInitData(f"Expected pssh to be a {PSSH}, not {pssh!r}")
except (ValueError, binascii.Error, DecodeError) as e:
raise InvalidInitData(str(e))
try: try:
if isinstance(type_, int): if isinstance(type_, int):
@ -184,7 +182,7 @@ class RemoteCdm(Cdm):
url=f"{self.host}/{self.device_name}/challenge/{type_}", url=f"{self.host}/{self.device_name}/challenge/{type_}",
json={ json={
"session_id": session_id.hex(), "session_id": session_id.hex(),
"init_data": base64.b64encode(init_data).decode() "init_data": pssh.dumps()
} }
) )
if r.status_code != 200: if r.status_code != 200:

View File

@ -3,6 +3,8 @@ import sys
from pathlib import Path from pathlib import Path
from typing import Optional, Union from typing import Optional, Union
from pywidevine.pssh import PSSH
try: try:
from aiohttp import web from aiohttp import web
except ImportError: except ImportError:
@ -210,10 +212,13 @@ async def challenge(request: web.Request) -> web.Response:
"message": "No Service Certificate set but Privacy Mode is Enforced." "message": "No Service Certificate set but Privacy Mode is Enforced."
}, status=403) }, status=403)
# get init data
init_data = PSSH(body["init_data"])
# get challenge # get challenge
license_request = cdm.get_license_challenge( license_request = cdm.get_license_challenge(
session_id=session_id, session_id=session_id,
init_data=body["init_data"], pssh=init_data,
type_=LicenseType.Value(request.match_info["license_type"]), type_=LicenseType.Value(request.match_info["license_type"]),
privacy_mode=True privacy_mode=True
) )