11 Commits

Author SHA1 Message Date
rlaphoenix
37d466b9a8 Update Changelog for v1.5.1 2022-10-23 15:20:59 +01:00
rlaphoenix
05b6753aa6 Bump to v1.5.1 2022-10-23 15:20:49 +01:00
rlaphoenix
ada7cb009e Cdm: Improve reliability of computing some License Signatures 2022-10-23 15:07:15 +01:00
rlaphoenix
7c91f2c59a PSSH: Dump the same version as the loaded data
Currently, even though self.version would be 0, it would dump as a version=1 box with key_IDs set to data (where possible).

This is because pymp4 sets the version to 1 if key_IDs is set with data, as that would make it a v1 PSSH box. So effectively .dump() and .dumps() forces a v1 box as output even if you loaded or created a v0 box.

Fixes #16
2022-10-13 11:21:47 +01:00
rlaphoenix
eaa26399e0 Cdm: Reduce maximum concurrent sessions to 16
It seems 16 is the more common limit on moderm OEM Crypto API systems (at least L1). It's also a more reasonable limit.

This also encourages people to .close() their session more. It also makes it quicker to notice if a codebase is forgetting to do a .close() call somewhere as you will reach the limit faster and easier now.

In normal use cases, a limit of 16 sessions will not be a problem as long as the sessions are being closed correctly.
2022-09-28 07:54:09 +01:00
rlaphoenix
74f960aeba Store Service Certificate in session as SignedDrmCertificate
This is for less effort to use the Service Certificate later on. We have no reason to keep the SignedMessage shell as it's just a way to send it as a message from License Acquisition APIs.
2022-09-28 07:46:52 +01:00
rlaphoenix
42b825dcd5 Cdm: Add parsing error handlers to Service Cert DrmCertificates 2022-09-28 07:37:17 +01:00
rlaphoenix
fa00bbd8e4 Cdm: Fix acquisition of provider_id when removing a service cert
The logic of parsing the session's stored service cert to get the provider_id was wrong. It assumed it was a SignedDrmCertificate, when in reality it was a SignedMessage containing a SignedDrmCertificate.

It would also panic if you try to remove a certificate when none was set.
2022-09-28 06:49:41 +01:00
rlaphoenix
a4c6f98650 Add import path shortcuts for Classes
This is so you don't have to do e.g., `from pywidevine.pssh import PSSH` and instead can do `from  pywidevine import PSSH`. You can still do it the other way, but now you have the choice.
2022-09-28 06:40:52 +01:00
rlaphoenix
24297d577e PSSH: Initialize System IDs via UUIDs hex arg
This is just to lower the overall character count for the same end result.
2022-09-28 06:36:26 +01:00
rlaphoenix
e90371922c PSSH: Add support for Key IDs of lengths other than 16 bytes
This is required for cases like Google's testing DASH manifests, e.g., 'tears' MPD. It assumes the Key ID as a number, which can support up to 16 bytes in this fashion (therefore technically 15 in our scenario as 16 byte Key_IDs can load normally).

Fixes #13
2022-09-28 06:21:28 +01:00
7 changed files with 83 additions and 43 deletions

View File

@@ -5,6 +5,35 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [1.5.1] - 2022-10-23
### Added
- Added import path shortcuts in the `__init__.py` package constructor to all the user classes. Now you can do e.g.,
`from pywidevine import PSSH` instead of `from pywidevine.pssh import PSSH`. You can still do it both ways.
- Improved error handling and sanitization checks when parsing some Service Certificates in `set_service_certificate()`.
### Changed
- Maximum concurrent Cdm sessions are now set to 16 as it seems tto be a more common limit on more up-to-date CDMs,
including Android's OEMCrypto Library. This also helps encourage people to close their sessions when they are no
longer required.
- Service Certificates are now stored in the session as a `SignedDrmCertificate`. This is to keep the signature with
the stored Certificate for use by the user if necessary. It also reduces code repetition relating to the usage of the
signature.
### Fixed
- Improved reliability of computing License Signatures. Some license messages when parsed would be slightly different
when re-serialized with `SerializeToString()`, therefore the computed signature would have always mismatched.
- Added support for Key IDs that are integer values. Effectively all values are now considered to be a UUID as 16 bytes
(in hex or bytes) or an integer value with support for up to 16 bytes. All integer values are converted to a UUID and
are loaded big-endian.
- Fixed acquisition of the Certificate's provider_id within `set_service_certificate()` in some edge cases, but also
when you try to remove the certificate by setting it to `None`.
- PSSH now dumps in the same version the PSSH was loaded or created in. Previously it would always dump as a v1 PSSH
box due to a cascading check in pymp4. It now also honors the currently set version in the case it gets overridden.
## [1.5.0] - 2022-09-24
With just one change this brings along a reduced dependency tree, smoother experience across different platforms, and
@@ -296,6 +325,7 @@ This release is primarily a maintenance release for `serve` functionality but so
Initial Release.
[1.5.1]: https://github.com/rlaphoenix/pywidevine/releases/tag/v1.5.1
[1.5.0]: https://github.com/rlaphoenix/pywidevine/releases/tag/v1.5.0
[1.4.4]: https://github.com/rlaphoenix/pywidevine/releases/tag/v1.4.4
[1.4.3]: https://github.com/rlaphoenix/pywidevine/releases/tag/v1.4.3

View File

@@ -4,7 +4,7 @@ build-backend = "poetry.core.masonry.api"
[tool.poetry]
name = "pywidevine"
version = "1.5.0"
version = "1.5.1"
description = "Widevine CDM (Content Decryption Module) implementation in Python."
authors = ["rlaphoenix <rlaphoenix@pm.me>"]
license = "GPL-3.0-only"

View File

@@ -1 +1,9 @@
__version__ = "1.5.0"
from .cdm import *
from .device import *
from .key import *
from .pssh import *
from .remotecdm import *
from .session import *
__version__ = "1.5.1"

View File

@@ -62,7 +62,7 @@ class Cdm:
root_cert = DrmCertificate()
root_cert.ParseFromString(root_signed_cert.drm_certificate)
MAX_NUM_OF_SESSIONS = 50 # most common limit
MAX_NUM_OF_SESSIONS = 16
def __init__(
self,
@@ -185,10 +185,14 @@ class Cdm:
raise InvalidSession(f"Session identifier {session_id!r} is invalid.")
if certificate is None:
drm_certificate = DrmCertificate()
drm_certificate.ParseFromString(session.service_certificate.drm_certificate)
if session.service_certificate:
drm_certificate = DrmCertificate()
drm_certificate.ParseFromString(session.service_certificate.drm_certificate)
provider_id = drm_certificate.provider_id
else:
provider_id = None
session.service_certificate = None
return drm_certificate.provider_id
return provider_id
if isinstance(certificate, str):
try:
@@ -200,6 +204,7 @@ class Cdm:
signed_message = SignedMessage()
signed_drm_certificate = SignedDrmCertificate()
drm_certificate = DrmCertificate()
try:
signed_message.ParseFromString(certificate)
@@ -209,10 +214,6 @@ class Cdm:
signed_drm_certificate.ParseFromString(certificate)
if signed_drm_certificate.SerializeToString() != certificate:
raise DecodeError("partial parse")
# Craft a SignedMessage as it's stored as a SignedMessage
signed_message.Clear()
signed_message.msg = signed_drm_certificate.SerializeToString()
# we don't need to sign this message, this is normal
except DecodeError as e:
# could be a direct unsigned DrmCertificate, but reject those anyway
raise DecodeError(f"Could not parse certificate as a SignedDrmCertificate, {e}")
@@ -226,13 +227,20 @@ class Cdm:
)
except (ValueError, TypeError):
raise SignatureMismatch("Signature Mismatch on SignedDrmCertificate, rejecting certificate")
else:
session.service_certificate = signed_message
drm_certificate = DrmCertificate()
drm_certificate.ParseFromString(signed_drm_certificate.drm_certificate)
return drm_certificate.provider_id
def get_service_certificate(self, session_id: bytes) -> Optional[SignedMessage]:
try:
drm_certificate.ParseFromString(signed_drm_certificate.drm_certificate)
if drm_certificate.SerializeToString() != signed_drm_certificate.drm_certificate:
raise DecodeError("partial parse")
except DecodeError as e:
raise DecodeError(f"Could not parse signed certificate's message as a DrmCertificate, {e}")
# must be stored as a SignedDrmCertificate as the signature needs to be kept for RemoteCdm
# if we store as DrmCertificate (no signature) then RemoteCdm cannot verify the Certificate
session.service_certificate = signed_drm_certificate
return drm_certificate.provider_id
def get_service_certificate(self, session_id: bytes) -> Optional[SignedDrmCertificate]:
"""
Get the currently set Service Privacy Certificate of the Session.
@@ -405,9 +413,12 @@ class Cdm:
key=self.__decrypter.decrypt(license_message.session_key)
)
# explicitly use the original `license_message.msg` instead of a re-serializing from `licence`
# as some differences may end up in the output due to differences in the proto
computed_signature = HMAC. \
new(mac_key_server, digestmod=SHA256). \
update(licence.SerializeToString()). \
update(license_message.msg). \
digest()
if license_message.signature != computed_signature:
@@ -541,7 +552,7 @@ class Cdm:
@staticmethod
def encrypt_client_id(
client_id: ClientIdentification,
service_certificate: Union[SignedMessage, SignedDrmCertificate, DrmCertificate],
service_certificate: Union[SignedDrmCertificate, DrmCertificate],
key: bytes = None,
iv: bytes = None
) -> EncryptedClientIdentification:
@@ -549,10 +560,6 @@ class Cdm:
privacy_key = key or get_random_bytes(16)
privacy_iv = iv or get_random_bytes(16)
if isinstance(service_certificate, SignedMessage):
signed_drm_certificate = SignedDrmCertificate()
signed_drm_certificate.ParseFromString(service_certificate.msg)
service_certificate = signed_drm_certificate
if isinstance(service_certificate, SignedDrmCertificate):
drm_certificate = DrmCertificate()
drm_certificate.ParseFromString(service_certificate.drm_certificate)

View File

@@ -19,8 +19,8 @@ class PSSH:
"""PSSH-related utilities. Somewhat Widevine-biased."""
class SystemId:
Widevine = UUID(bytes=b"\xed\xef\x8b\xa9\x79\xd6\x4a\xce\xa3\xc8\x27\xdc\xd5\x1d\x21\xed")
PlayReady = UUID(bytes=b"\x9a\x04\xf0\x79\x98\x40\x42\x86\xab\x92\xe6\x5b\xe0\x88\x5f\x95")
Widevine = UUID(hex="edef8ba979d64acea3c827dcd51d21ed")
PlayReady = UUID(hex="9a04f07998404286ab92e65be0885f95")
def __init__(self, data: Union[Container, str, bytes], strict: bool = False):
"""
@@ -199,7 +199,11 @@ class PSSH:
cenc_header.ParseFromString(self.init_data)
return [
# the key_ids value may or may not be hex underlying
UUID(bytes=key_id) if len(key_id) == 16 else UUID(hex=key_id.decode())
(
UUID(bytes=key_id) if len(key_id) == 16 else # normal
UUID(hex=key_id.decode()) if len(key_id) == 32 else # stored as hex
UUID(int=int.from_bytes(key_id, "big")) # assuming as number
)
for key_id in cenc_header.key_ids
]
@@ -231,7 +235,7 @@ class PSSH:
version=self.version,
flags=self.flags,
system_ID=self.system_id,
key_IDs=self.key_ids,
key_IDs=self.key_ids if self.version == 1 and self.key_ids else None,
init_data=self.init_data
))

View File

@@ -143,7 +143,7 @@ class RemoteCdm(Cdm):
return r["provider_id"]
def get_service_certificate(self, session_id: bytes) -> Optional[SignedMessage]:
def get_service_certificate(self, session_id: bytes) -> Optional[SignedDrmCertificate]:
r = self.__session.post(
url=f"{self.host}/{self.device_name}/get_service_certificate",
json={
@@ -159,21 +159,12 @@ class RemoteCdm(Cdm):
return None
service_certificate = base64.b64decode(service_certificate)
signed_message = SignedMessage()
signed_drm_certificate = SignedDrmCertificate()
try:
signed_message.ParseFromString(service_certificate)
if signed_message.SerializeToString() == service_certificate:
signed_drm_certificate.ParseFromString(signed_message.msg)
else:
signed_drm_certificate.ParseFromString(service_certificate)
if signed_drm_certificate.SerializeToString() != service_certificate:
raise DecodeError("partial parse")
# Craft a SignedMessage as it's stored as a SignedMessage
signed_message.Clear()
signed_message.msg = signed_drm_certificate.SerializeToString()
# we don't need to sign this message, this is normal
signed_drm_certificate.ParseFromString(service_certificate)
if signed_drm_certificate.SerializeToString() != service_certificate:
raise DecodeError("partial parse")
except DecodeError as e:
# could be a direct unsigned DrmCertificate, but reject those anyway
raise DecodeError(f"Could not parse certificate as a SignedDrmCertificate, {e}")
@@ -187,8 +178,8 @@ class RemoteCdm(Cdm):
)
except (ValueError, TypeError):
raise SignatureMismatch("Signature Mismatch on SignedDrmCertificate, rejecting certificate")
else:
return signed_message
return signed_drm_certificate
def get_license_challenge(
self,

View File

@@ -3,13 +3,13 @@ from typing import Optional
from Crypto.Random import get_random_bytes
from pywidevine.key import Key
from pywidevine.license_protocol_pb2 import SignedMessage
from pywidevine.license_protocol_pb2 import SignedDrmCertificate
class Session:
def __init__(self, number: int):
self.number = number
self.id = get_random_bytes(16)
self.service_certificate: Optional[SignedMessage] = None
self.service_certificate: Optional[SignedDrmCertificate] = None
self.context: dict[bytes, tuple[bytes, bytes]] = {}
self.keys: list[Key] = []