2022-08-04 03:15:37 +00:00
|
|
|
from __future__ import annotations
|
|
|
|
|
2022-07-20 13:41:42 +00:00
|
|
|
import base64
|
2022-07-30 03:44:52 +00:00
|
|
|
import binascii
|
2022-07-20 13:41:42 +00:00
|
|
|
import random
|
|
|
|
import subprocess
|
|
|
|
import sys
|
|
|
|
import time
|
|
|
|
from pathlib import Path
|
2023-09-19 11:05:41 +00:00
|
|
|
from typing import Optional, Union
|
2022-07-20 13:41:42 +00:00
|
|
|
from uuid import UUID
|
|
|
|
|
|
|
|
from Crypto.Cipher import AES, PKCS1_OAEP
|
2023-09-19 11:05:41 +00:00
|
|
|
from Crypto.Hash import CMAC, HMAC, SHA1, SHA256
|
2022-07-20 13:41:42 +00:00
|
|
|
from Crypto.PublicKey import RSA
|
|
|
|
from Crypto.Random import get_random_bytes
|
|
|
|
from Crypto.Signature import pss
|
|
|
|
from Crypto.Util import Padding
|
|
|
|
from google.protobuf.message import DecodeError
|
|
|
|
|
2023-11-08 22:38:32 +00:00
|
|
|
from pywidevine.device import Device, DeviceTypes
|
2023-09-19 11:05:41 +00:00
|
|
|
from pywidevine.exceptions import (InvalidContext, InvalidInitData, InvalidLicenseMessage, InvalidLicenseType,
|
|
|
|
InvalidSession, NoKeysLoaded, SignatureMismatch, TooManySessions)
|
2022-07-20 13:41:42 +00:00
|
|
|
from pywidevine.key import Key
|
2023-09-19 11:05:41 +00:00
|
|
|
from pywidevine.license_protocol_pb2 import (ClientIdentification, DrmCertificate, EncryptedClientIdentification,
|
2023-11-08 22:08:31 +00:00
|
|
|
License, LicenseRequest, LicenseType, SignedDrmCertificate,
|
|
|
|
SignedMessage)
|
2022-07-20 13:41:42 +00:00
|
|
|
from pywidevine.pssh import PSSH
|
2022-07-30 03:44:52 +00:00
|
|
|
from pywidevine.session import Session
|
|
|
|
from pywidevine.utils import get_binary_path
|
2022-07-20 13:41:42 +00:00
|
|
|
|
|
|
|
|
|
|
|
class Cdm:
|
2023-11-08 20:38:38 +00:00
|
|
|
uuid = UUID(bytes=b"\xed\xef\x8b\xa9\x79\xd6\x4a\xce\xa3\xc8\x27\xdc\xd5\x1d\x21\xed")
|
2022-07-20 13:41:42 +00:00
|
|
|
urn = f"urn:uuid:{uuid}"
|
|
|
|
key_format = urn
|
|
|
|
service_certificate_challenge = b"\x08\x04"
|
2023-11-09 12:23:31 +00:00
|
|
|
common_privacy_cert = (
|
|
|
|
# Used by Google's production license server (license.google.com)
|
|
|
|
# Not publicly accessible directly, but a lot of services have their own gateways to it
|
|
|
|
"CAUSxwUKwQIIAxIQFwW5F8wSBIaLBjM6L3cqjBiCtIKSBSKOAjCCAQoCggEBAJntWzsyfateJO/DtiqVtZhSCtW8yzdQPgZFuBTYdrjfQFEE"
|
|
|
|
"Qa2M462xG7iMTnJaXkqeB5UpHVhYQCOn4a8OOKkSeTkwCGELbxWMh4x+Ib/7/up34QGeHleB6KRfRiY9FOYOgFioYHrc4E+shFexN6jWfM3r"
|
|
|
|
"M3BdmDoh+07svUoQykdJDKR+ql1DghjduvHK3jOS8T1v+2RC/THhv0CwxgTRxLpMlSCkv5fuvWCSmvzu9Vu69WTi0Ods18Vcc6CCuZYSC4NZ"
|
|
|
|
"7c4kcHCCaA1vZ8bYLErF8xNEkKdO7DevSy8BDFnoKEPiWC8La59dsPxebt9k+9MItHEbzxJQAZyfWgkCAwEAAToUbGljZW5zZS53aWRldmlu"
|
|
|
|
"ZS5jb20SgAOuNHMUtag1KX8nE4j7e7jLUnfSSYI83dHaMLkzOVEes8y96gS5RLknwSE0bv296snUE5F+bsF2oQQ4RgpQO8GVK5uk5M4PxL/C"
|
|
|
|
"CpgIqq9L/NGcHc/N9XTMrCjRtBBBbPneiAQwHL2zNMr80NQJeEI6ZC5UYT3wr8+WykqSSdhV5Cs6cD7xdn9qm9Nta/gr52u/DLpP3lnSq8x2"
|
|
|
|
"/rZCR7hcQx+8pSJmthn8NpeVQ/ypy727+voOGlXnVaPHvOZV+WRvWCq5z3CqCLl5+Gf2Ogsrf9s2LFvE7NVV2FvKqcWTw4PIV9Sdqrd+QLeF"
|
|
|
|
"Hd/SSZiAjjWyWOddeOrAyhb3BHMEwg2T7eTo/xxvF+YkPj89qPwXCYcOxF+6gjomPwzvofcJOxkJkoMmMzcFBDopvab5tDQsyN9UPLGhGC98"
|
|
|
|
"X/8z8QSQ+spbJTYLdgFenFoGq47gLwDS6NWYYQSqzE3Udf2W7pzk4ybyG4PHBYV3s4cyzdq8amvtE/sNSdOKReuHpfQ=")
|
|
|
|
staging_privacy_cert = (
|
|
|
|
# Used by Google's staging license server (staging.google.com)
|
|
|
|
# This can be publicly accessed without authentication using https://cwip-shaka-proxy.appspot.com/no_auth
|
|
|
|
"CAUSxQUKvwIIAxIQKHA0VMAI9jYYredEPbbEyBiL5/mQBSKOAjCCAQoCggEBALUhErjQXQI/zF2V4sJRwcZJtBd82NK+7zVbsGdD3mYePSq8"
|
|
|
|
"MYK3mUbVX9wI3+lUB4FemmJ0syKix/XgZ7tfCsB6idRa6pSyUW8HW2bvgR0NJuG5priU8rmFeWKqFxxPZmMNPkxgJxiJf14e+baq9a1Nuip+"
|
|
|
|
"FBdt8TSh0xhbWiGKwFpMQfCB7/+Ao6BAxQsJu8dA7tzY8U1nWpGYD5LKfdxkagatrVEB90oOSYzAHwBTK6wheFC9kF6QkjZWt9/v70JIZ2fz"
|
|
|
|
"PvYoPU9CVKtyWJOQvuVYCPHWaAgNRdiTwryi901goMDQoJk87wFgRwMzTDY4E5SGvJ2vJP1noH+a2UMCAwEAAToSc3RhZ2luZy5nb29nbGUu"
|
|
|
|
"Y29tEoADmD4wNSZ19AunFfwkm9rl1KxySaJmZSHkNlVzlSlyH/iA4KrvxeJ7yYDa6tq/P8OG0ISgLIJTeEjMdT/0l7ARp9qXeIoA4qprhM19"
|
|
|
|
"ccB6SOv2FgLMpaPzIDCnKVww2pFbkdwYubyVk7jei7UPDe3BKTi46eA5zd4Y+oLoG7AyYw/pVdhaVmzhVDAL9tTBvRJpZjVrKH1lexjOY9Dv"
|
|
|
|
"1F/FJp6X6rEctWPlVkOyb/SfEJwhAa/K81uDLyiPDZ1Flg4lnoX7XSTb0s+Cdkxd2b9yfvvpyGH4aTIfat4YkF9Nkvmm2mU224R1hx0WjocL"
|
|
|
|
"sjA89wxul4TJPS3oRa2CYr5+DU4uSgdZzvgtEJ0lksckKfjAF0K64rPeytvDPD5fS69eFuy3Tq26/LfGcF96njtvOUA4P5xRFtICogySKe6W"
|
|
|
|
"nCUZcYMDtQ0BMMM1LgawFNg4VA+KDCJ8ABHg9bOOTimO0sswHrRWSWX1XF15dXolCk65yEqz5lOfa2/fVomeopkU")
|
2022-07-29 21:14:48 +00:00
|
|
|
root_signed_cert = SignedDrmCertificate()
|
|
|
|
root_signed_cert.ParseFromString(base64.b64decode(
|
|
|
|
"CpwDCAASAQAY3ZSIiwUijgMwggGKAoIBgQC0/jnDZZAD2zwRlwnoaM3yw16b8udNI7EQ24dl39z7nzWgVwNTTPZtNX2meNuzNtI/nECplSZy"
|
|
|
|
"f7i+Zt/FIZh4FRZoXS9GDkPLioQ5q/uwNYAivjQji6tTW3LsS7VIaVM+R1/9Cf2ndhOPD5LWTN+udqm62SIQqZ1xRdbX4RklhZxTmpfrhNfM"
|
|
|
|
"qIiCIHAmIP1+QFAn4iWTb7w+cqD6wb0ptE2CXMG0y5xyfrDpihc+GWP8/YJIK7eyM7l97Eu6iR8nuJuISISqGJIOZfXIbBH/azbkdDTKjDOx"
|
|
|
|
"+biOtOYS4AKYeVJeRTP/Edzrw1O6fGAaET0A+9K3qjD6T15Id1sX3HXvb9IZbdy+f7B4j9yCYEy/5CkGXmmMOROtFCXtGbLynwGCDVZEiMg1"
|
|
|
|
"7B8RsyTgWQ035Ec86kt/lzEcgXyUikx9aBWE/6UI/Rjn5yvkRycSEbgj7FiTPKwS0ohtQT3F/hzcufjUUT4H5QNvpxLoEve1zqaWVT94tGSC"
|
|
|
|
"UNIzX5ECAwEAARKAA1jx1k0ECXvf1+9dOwI5F/oUNnVKOGeFVxKnFO41FtU9v0KG9mkAds2T9Hyy355EzUzUrgkYU0Qy7OBhG+XaE9NVxd0a"
|
|
|
|
"y5AeflvG6Q8in76FAv6QMcxrA4S9IsRV+vXyCM1lQVjofSnaBFiC9TdpvPNaV4QXezKHcLKwdpyywxXRESYqI3WZPrl3IjINvBoZwdVlkHZV"
|
|
|
|
"dA8OaU1fTY8Zr9/WFjGUqJJfT7x6Mfiujq0zt+kw0IwKimyDNfiKgbL+HIisKmbF/73mF9BiC9yKRfewPlrIHkokL2yl4xyIFIPVxe9enz2F"
|
|
|
|
"RXPia1BSV0z7kmxmdYrWDRuu8+yvUSIDXQouY5OcCwEgqKmELhfKrnPsIht5rvagcizfB0fbiIYwFHghESKIrNdUdPnzJsKlVshWTwApHQh7"
|
|
|
|
"evuVicPumFSePGuUBRMS9nG5qxPDDJtGCHs9Mmpoyh6ckGLF7RC5HxclzpC5bc3ERvWjYhN0AqdipPpV2d7PouaAdFUGSdUCDA=="
|
|
|
|
))
|
|
|
|
root_cert = DrmCertificate()
|
|
|
|
root_cert.ParseFromString(root_signed_cert.drm_certificate)
|
2022-07-20 13:41:42 +00:00
|
|
|
|
2022-09-28 06:54:09 +00:00
|
|
|
MAX_NUM_OF_SESSIONS = 16
|
2022-07-20 13:41:42 +00:00
|
|
|
|
2022-08-04 03:15:37 +00:00
|
|
|
def __init__(
|
|
|
|
self,
|
2023-11-08 22:38:32 +00:00
|
|
|
device_type: Union[DeviceTypes, str],
|
2022-08-04 03:15:37 +00:00
|
|
|
system_id: int,
|
|
|
|
security_level: int,
|
|
|
|
client_id: ClientIdentification,
|
|
|
|
rsa_key: RSA.RsaKey
|
|
|
|
):
|
2022-07-30 03:44:52 +00:00
|
|
|
"""Initialize a Widevine Content Decryption Module (CDM)."""
|
2022-08-04 03:15:37 +00:00
|
|
|
if not device_type:
|
|
|
|
raise ValueError("Device Type must be provided")
|
2022-08-04 07:26:41 +00:00
|
|
|
if isinstance(device_type, str):
|
2023-11-08 22:38:32 +00:00
|
|
|
device_type = DeviceTypes[device_type]
|
|
|
|
if not isinstance(device_type, DeviceTypes):
|
|
|
|
raise TypeError(f"Expected device_type to be a {DeviceTypes!r} not {device_type!r}")
|
2022-08-04 03:15:37 +00:00
|
|
|
|
|
|
|
if not system_id:
|
|
|
|
raise ValueError("System ID must be provided")
|
|
|
|
if not isinstance(system_id, int):
|
|
|
|
raise TypeError(f"Expected system_id to be a {int} not {system_id!r}")
|
|
|
|
|
|
|
|
if not security_level:
|
|
|
|
raise ValueError("Security Level must be provided")
|
|
|
|
if not isinstance(security_level, int):
|
|
|
|
raise TypeError(f"Expected security_level to be a {int} not {security_level!r}")
|
|
|
|
|
|
|
|
if not client_id:
|
|
|
|
raise ValueError("Client ID must be provided")
|
|
|
|
if not isinstance(client_id, ClientIdentification):
|
|
|
|
raise TypeError(f"Expected client_id to be a {ClientIdentification} not {client_id!r}")
|
|
|
|
|
|
|
|
if not rsa_key:
|
|
|
|
raise ValueError("RSA Key must be provided")
|
|
|
|
if not isinstance(rsa_key, RSA.RsaKey):
|
|
|
|
raise TypeError(f"Expected rsa_key to be a {RSA.RsaKey} not {rsa_key!r}")
|
|
|
|
|
|
|
|
self.device_type = device_type
|
|
|
|
self.system_id = system_id
|
|
|
|
self.security_level = security_level
|
|
|
|
self.__client_id = client_id
|
|
|
|
|
|
|
|
self.__signer = pss.new(rsa_key)
|
|
|
|
self.__decrypter = PKCS1_OAEP.new(rsa_key)
|
2022-07-30 03:44:52 +00:00
|
|
|
|
2022-08-06 11:29:58 +00:00
|
|
|
self.__sessions: dict[bytes, Session] = {}
|
2022-07-30 03:44:52 +00:00
|
|
|
|
2022-08-04 03:15:37 +00:00
|
|
|
@classmethod
|
|
|
|
def from_device(cls, device: Device) -> Cdm:
|
|
|
|
"""Initialize a Widevine CDM from a Widevine Device (.wvd) file."""
|
|
|
|
return cls(
|
|
|
|
device_type=device.type,
|
|
|
|
system_id=device.system_id,
|
|
|
|
security_level=device.security_level,
|
|
|
|
client_id=device.client_id,
|
|
|
|
rsa_key=device.private_key
|
|
|
|
)
|
|
|
|
|
2022-07-30 03:44:52 +00:00
|
|
|
def open(self) -> bytes:
|
2022-07-20 13:41:42 +00:00
|
|
|
"""
|
|
|
|
Open a Widevine Content Decryption Module (CDM) session.
|
|
|
|
|
2022-07-30 03:44:52 +00:00
|
|
|
Raises:
|
|
|
|
TooManySessions: If the session cannot be opened as limit has been reached.
|
2022-07-20 13:41:42 +00:00
|
|
|
"""
|
2022-08-06 11:29:58 +00:00
|
|
|
if len(self.__sessions) > self.MAX_NUM_OF_SESSIONS:
|
2022-07-30 03:44:52 +00:00
|
|
|
raise TooManySessions(f"Too many Sessions open ({self.MAX_NUM_OF_SESSIONS}).")
|
2022-07-20 13:41:42 +00:00
|
|
|
|
2022-08-21 21:37:28 +00:00
|
|
|
session = Session(len(self.__sessions) + 1)
|
2022-08-06 11:29:58 +00:00
|
|
|
self.__sessions[session.id] = session
|
2022-07-20 13:41:42 +00:00
|
|
|
|
2022-07-30 03:44:52 +00:00
|
|
|
return session.id
|
2022-07-20 13:41:42 +00:00
|
|
|
|
2022-07-30 03:44:52 +00:00
|
|
|
def close(self, session_id: bytes) -> None:
|
|
|
|
"""
|
|
|
|
Close a Widevine Content Decryption Module (CDM) session.
|
2022-07-20 13:41:42 +00:00
|
|
|
|
2022-07-30 03:44:52 +00:00
|
|
|
Parameters:
|
|
|
|
session_id: Session identifier.
|
|
|
|
|
|
|
|
Raises:
|
|
|
|
InvalidSession: If the Session identifier is invalid.
|
|
|
|
"""
|
2022-08-06 11:29:58 +00:00
|
|
|
session = self.__sessions.get(session_id)
|
2022-07-30 03:44:52 +00:00
|
|
|
if not session:
|
|
|
|
raise InvalidSession(f"Session identifier {session_id!r} is invalid.")
|
2022-08-06 11:29:58 +00:00
|
|
|
del self.__sessions[session_id]
|
2022-07-20 13:41:42 +00:00
|
|
|
|
2023-11-08 20:52:03 +00:00
|
|
|
def set_service_certificate(self, session_id: bytes, certificate: Optional[Union[bytes, str]]) -> Optional[str]:
|
2022-07-20 13:41:42 +00:00
|
|
|
"""
|
|
|
|
Set a Service Privacy Certificate for Privacy Mode. (optional but recommended)
|
|
|
|
|
|
|
|
The Service Certificate is used to encrypt Client IDs in Licenses. This is also
|
|
|
|
known as Privacy Mode and may be required for some services or for some devices.
|
|
|
|
Chrome CDM requires it as of the enforcement of VMP (Verified Media Path).
|
2022-07-29 21:14:48 +00:00
|
|
|
|
|
|
|
We reject direct DrmCertificates as they do not have signature verification and
|
|
|
|
cannot be verified. You must provide a SignedDrmCertificate or a SignedMessage
|
|
|
|
containing a SignedDrmCertificate.
|
|
|
|
|
|
|
|
Parameters:
|
2022-07-30 03:44:52 +00:00
|
|
|
session_id: Session identifier.
|
2022-07-29 21:14:48 +00:00
|
|
|
certificate: SignedDrmCertificate (or SignedMessage containing one) in Base64
|
|
|
|
or Bytes form obtained from the Service. Some services have their own,
|
2022-08-04 04:22:51 +00:00
|
|
|
but most use the common privacy cert, (common_privacy_cert). If None, it
|
|
|
|
will remove the current certificate.
|
2022-07-29 21:14:48 +00:00
|
|
|
|
|
|
|
Raises:
|
2022-07-30 03:44:52 +00:00
|
|
|
InvalidSession: If the Session identifier is invalid.
|
2022-07-29 21:14:48 +00:00
|
|
|
DecodeError: If the certificate could not be parsed as a SignedDrmCertificate
|
|
|
|
nor a SignedMessage containing a SignedDrmCertificate.
|
2022-07-30 03:44:52 +00:00
|
|
|
SignatureMismatch: If the Signature of the SignedDrmCertificate does not
|
|
|
|
match the underlying DrmCertificate.
|
2022-07-29 21:14:48 +00:00
|
|
|
|
2022-07-30 01:50:22 +00:00
|
|
|
Returns the Service Provider ID of the verified DrmCertificate if successful.
|
2023-11-08 20:52:03 +00:00
|
|
|
If certificate is None, it will return the now-unset certificate's Provider ID,
|
|
|
|
or None if no certificate was set yet.
|
2022-07-20 13:41:42 +00:00
|
|
|
"""
|
2022-08-06 11:29:58 +00:00
|
|
|
session = self.__sessions.get(session_id)
|
2022-07-30 03:44:52 +00:00
|
|
|
if not session:
|
|
|
|
raise InvalidSession(f"Session identifier {session_id!r} is invalid.")
|
|
|
|
|
2022-08-04 04:22:51 +00:00
|
|
|
if certificate is None:
|
2022-09-28 05:49:41 +00:00
|
|
|
if session.service_certificate:
|
|
|
|
drm_certificate = DrmCertificate()
|
2022-09-28 06:43:36 +00:00
|
|
|
drm_certificate.ParseFromString(session.service_certificate.drm_certificate)
|
2022-09-28 05:49:41 +00:00
|
|
|
provider_id = drm_certificate.provider_id
|
|
|
|
else:
|
|
|
|
provider_id = None
|
2022-08-04 04:22:51 +00:00
|
|
|
session.service_certificate = None
|
2022-09-28 05:49:41 +00:00
|
|
|
return provider_id
|
2022-08-04 04:22:51 +00:00
|
|
|
|
2022-07-20 13:41:42 +00:00
|
|
|
if isinstance(certificate, str):
|
2022-07-30 03:44:52 +00:00
|
|
|
try:
|
|
|
|
certificate = base64.b64decode(certificate) # assuming base64
|
|
|
|
except binascii.Error:
|
|
|
|
raise DecodeError("Could not decode certificate string as Base64, expected bytes.")
|
|
|
|
elif not isinstance(certificate, bytes):
|
|
|
|
raise DecodeError(f"Expecting Certificate to be bytes, not {certificate!r}")
|
2022-07-20 13:41:42 +00:00
|
|
|
|
|
|
|
signed_message = SignedMessage()
|
2022-07-21 16:26:14 +00:00
|
|
|
signed_drm_certificate = SignedDrmCertificate()
|
2022-09-28 06:37:17 +00:00
|
|
|
drm_certificate = DrmCertificate()
|
2022-07-21 16:26:14 +00:00
|
|
|
|
2022-07-30 01:44:34 +00:00
|
|
|
try:
|
2022-07-21 16:26:14 +00:00
|
|
|
signed_message.ParseFromString(certificate)
|
2023-12-06 16:00:52 +00:00
|
|
|
if all(
|
2023-12-06 15:36:27 +00:00
|
|
|
# See https://github.com/devine-dl/pywidevine/issues/41
|
2023-12-06 16:00:52 +00:00
|
|
|
bytes(chunk) == signed_message.SerializeToString()
|
|
|
|
for chunk in zip(*[iter(certificate)] * len(signed_message.SerializeToString()))
|
2023-12-06 15:36:27 +00:00
|
|
|
):
|
2022-07-30 01:44:34 +00:00
|
|
|
signed_drm_certificate.ParseFromString(signed_message.msg)
|
|
|
|
else:
|
2022-07-29 21:14:48 +00:00
|
|
|
signed_drm_certificate.ParseFromString(certificate)
|
2022-07-30 01:44:34 +00:00
|
|
|
if signed_drm_certificate.SerializeToString() != certificate:
|
2022-09-10 20:19:27 +00:00
|
|
|
raise DecodeError("partial parse")
|
|
|
|
except DecodeError as e:
|
2022-07-30 01:44:34 +00:00
|
|
|
# could be a direct unsigned DrmCertificate, but reject those anyway
|
2022-09-10 20:19:27 +00:00
|
|
|
raise DecodeError(f"Could not parse certificate as a SignedDrmCertificate, {e}")
|
2022-07-29 21:14:48 +00:00
|
|
|
|
|
|
|
try:
|
|
|
|
pss. \
|
|
|
|
new(RSA.import_key(self.root_cert.public_key)). \
|
|
|
|
verify(
|
|
|
|
msg_hash=SHA1.new(signed_drm_certificate.drm_certificate),
|
|
|
|
signature=signed_drm_certificate.signature
|
|
|
|
)
|
|
|
|
except (ValueError, TypeError):
|
2022-07-30 03:44:52 +00:00
|
|
|
raise SignatureMismatch("Signature Mismatch on SignedDrmCertificate, rejecting certificate")
|
2022-09-28 06:37:17 +00:00
|
|
|
|
|
|
|
try:
|
2022-07-21 16:26:14 +00:00
|
|
|
drm_certificate.ParseFromString(signed_drm_certificate.drm_certificate)
|
2022-09-28 06:37:17 +00:00
|
|
|
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}")
|
|
|
|
|
2022-09-28 06:43:36 +00:00
|
|
|
# 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
|
2022-09-28 06:37:17 +00:00
|
|
|
return drm_certificate.provider_id
|
2022-07-30 03:44:52 +00:00
|
|
|
|
2022-09-28 06:43:36 +00:00
|
|
|
def get_service_certificate(self, session_id: bytes) -> Optional[SignedDrmCertificate]:
|
2022-09-10 20:25:13 +00:00
|
|
|
"""
|
|
|
|
Get the currently set Service Privacy Certificate of the Session.
|
|
|
|
|
|
|
|
Parameters:
|
|
|
|
session_id: Session identifier.
|
|
|
|
|
|
|
|
Raises:
|
|
|
|
InvalidSession: If the Session identifier is invalid.
|
|
|
|
|
|
|
|
Returns the Service Certificate if one is set, otherwise None.
|
|
|
|
"""
|
|
|
|
session = self.__sessions.get(session_id)
|
|
|
|
if not session:
|
|
|
|
raise InvalidSession(f"Session identifier {session_id!r} is invalid.")
|
|
|
|
|
|
|
|
return session.service_certificate
|
|
|
|
|
2022-07-30 03:44:52 +00:00
|
|
|
def get_license_challenge(
|
|
|
|
self,
|
|
|
|
session_id: bytes,
|
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.
2022-08-05 07:08:15 +00:00
|
|
|
pssh: PSSH,
|
2023-11-08 22:08:31 +00:00
|
|
|
license_type: str = "STREAMING",
|
2022-07-30 03:44:52 +00:00
|
|
|
privacy_mode: bool = True
|
|
|
|
) -> bytes:
|
2022-07-20 13:41:42 +00:00
|
|
|
"""
|
2022-07-30 03:44:52 +00:00
|
|
|
Get a License Request (Challenge) to send to a License Server.
|
2022-07-20 13:41:42 +00:00
|
|
|
|
|
|
|
Parameters:
|
2022-07-30 03:44:52 +00:00
|
|
|
session_id: Session identifier.
|
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.
2022-08-05 07:08:15 +00:00
|
|
|
pssh: PSSH Object to get the init data from.
|
2023-11-08 22:08:31 +00:00
|
|
|
license_type: Type of License you wish to exchange, often `STREAMING`.
|
|
|
|
- "STREAMING": Normal one-time-use license.
|
|
|
|
- "OFFLINE": Offline-use licence, usually for Downloaded content.
|
|
|
|
- "AUTOMATIC": License type decision is left to provider.
|
2022-07-20 13:41:42 +00:00
|
|
|
privacy_mode: Encrypt the Client ID using the Privacy Certificate. If the
|
|
|
|
privacy certificate is not set yet, this does nothing.
|
|
|
|
|
2022-07-30 03:44:52 +00:00
|
|
|
Raises:
|
|
|
|
InvalidSession: If the Session identifier is invalid.
|
|
|
|
InvalidInitData: If the Init Data (or PSSH box) provided is invalid.
|
|
|
|
InvalidLicenseType: If the type_ parameter value is not a License Type. It
|
|
|
|
must be a LicenseType enum, or a string/int representing the enum's keys
|
|
|
|
or values.
|
|
|
|
|
2022-07-20 13:41:42 +00:00
|
|
|
Returns a SignedMessage containing a LicenseRequest message. It's signed with
|
|
|
|
the Private Key of the device provision.
|
|
|
|
"""
|
2022-08-06 11:29:58 +00:00
|
|
|
session = self.__sessions.get(session_id)
|
2022-07-30 03:44:52 +00:00
|
|
|
if not session:
|
|
|
|
raise InvalidSession(f"Session identifier {session_id!r} is invalid.")
|
|
|
|
|
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.
2022-08-05 07:08:15 +00:00
|
|
|
if not pssh:
|
|
|
|
raise InvalidInitData("A pssh must be provided.")
|
|
|
|
if not isinstance(pssh, PSSH):
|
|
|
|
raise InvalidInitData(f"Expected pssh to be a {PSSH}, not {pssh!r}")
|
2022-07-30 03:44:52 +00:00
|
|
|
|
2023-11-08 22:08:31 +00:00
|
|
|
if not isinstance(license_type, str):
|
|
|
|
raise InvalidLicenseType(f"Expected license_type to be a {str}, not {license_type!r}")
|
|
|
|
if license_type not in LicenseType.keys():
|
|
|
|
raise InvalidLicenseType(
|
|
|
|
f"Invalid license_type value of '{license_type}'. "
|
|
|
|
f"Available values: {LicenseType.keys()}"
|
|
|
|
)
|
2022-07-21 00:12:28 +00:00
|
|
|
|
2023-11-08 22:38:32 +00:00
|
|
|
if self.device_type == DeviceTypes.ANDROID:
|
2022-09-03 18:43:31 +00:00
|
|
|
# OEMCrypto's request_id seems to be in AES CTR Counter block form with no suffix
|
|
|
|
# Bytes 5-8 does not seem random, in real tests they have been consecutive \x00 or \xFF
|
|
|
|
# Real example: A0DCE548000000000500000000000000
|
|
|
|
request_id = (get_random_bytes(4) + (b"\x00" * 4)) # (?)
|
|
|
|
request_id += session.number.to_bytes(8, "little") # counter
|
|
|
|
# as you can see in the real example, it is stored as uppercase hex and re-encoded
|
|
|
|
# it's really 16 bytes of data, but it's stored as a 32-char HEX string (32 bytes)
|
|
|
|
request_id = request_id.hex().upper().encode()
|
2022-08-21 21:39:26 +00:00
|
|
|
else:
|
|
|
|
request_id = get_random_bytes(16)
|
2022-07-30 03:22:35 +00:00
|
|
|
|
2023-11-08 22:08:31 +00:00
|
|
|
license_request = LicenseRequest(
|
|
|
|
client_id=(
|
|
|
|
self.__client_id
|
|
|
|
) if not (session.service_certificate and privacy_mode) else None,
|
|
|
|
encrypted_client_id=self.encrypt_client_id(
|
2022-08-04 03:15:37 +00:00
|
|
|
client_id=self.__client_id,
|
2022-07-30 03:44:52 +00:00
|
|
|
service_certificate=session.service_certificate
|
2023-11-08 22:08:31 +00:00
|
|
|
) if session.service_certificate and privacy_mode else None,
|
|
|
|
content_id=LicenseRequest.ContentIdentification(
|
|
|
|
widevine_pssh_data=LicenseRequest.ContentIdentification.WidevinePsshData(
|
|
|
|
pssh_data=[pssh.init_data], # either a WidevineCencHeader or custom data
|
|
|
|
license_type=license_type,
|
|
|
|
request_id=request_id
|
|
|
|
)
|
|
|
|
),
|
|
|
|
type="NEW",
|
|
|
|
request_time=int(time.time()),
|
|
|
|
protocol_version="VERSION_2_1",
|
|
|
|
key_control_nonce=random.randrange(1, 2 ** 31),
|
|
|
|
).SerializeToString()
|
2022-07-20 13:41:42 +00:00
|
|
|
|
2023-11-08 22:08:31 +00:00
|
|
|
signed_license_request = SignedMessage(
|
|
|
|
type="LICENSE_REQUEST",
|
|
|
|
msg=license_request,
|
|
|
|
signature=self.__signer.sign(SHA1.new(license_request))
|
|
|
|
).SerializeToString()
|
2022-07-20 13:41:42 +00:00
|
|
|
|
2023-11-08 22:08:31 +00:00
|
|
|
session.context[request_id] = self.derive_context(license_request)
|
2022-07-20 13:41:42 +00:00
|
|
|
|
2023-11-08 22:08:31 +00:00
|
|
|
return signed_license_request
|
2022-07-20 13:41:42 +00:00
|
|
|
|
2022-07-30 03:44:52 +00:00
|
|
|
def parse_license(self, session_id: bytes, license_message: Union[SignedMessage, bytes, str]) -> None:
|
|
|
|
"""
|
|
|
|
Load Keys from a License Message from a License Server Response.
|
|
|
|
|
2022-07-30 04:13:30 +00:00
|
|
|
License Messages can only be loaded a single time. An InvalidContext error will
|
|
|
|
be raised if you attempt to parse a License Message more than once.
|
|
|
|
|
2022-07-30 03:44:52 +00:00
|
|
|
Parameters:
|
|
|
|
session_id: Session identifier.
|
|
|
|
license_message: A SignedMessage containing a License message.
|
|
|
|
|
|
|
|
Raises:
|
|
|
|
InvalidSession: If the Session identifier is invalid.
|
|
|
|
InvalidLicenseMessage: The License message could not be decoded as a Signed
|
|
|
|
Message or License message.
|
|
|
|
InvalidContext: If the Session has no Context Data. This is likely to happen
|
|
|
|
if the License Challenge was not made by this CDM instance, or was not
|
|
|
|
by this CDM at all. It could also happen if the Session is closed after
|
|
|
|
calling parse_license but not before it got the context data.
|
|
|
|
SignatureMismatch: If the Signature of the License SignedMessage does not
|
|
|
|
match the underlying License.
|
|
|
|
"""
|
2022-08-06 11:29:58 +00:00
|
|
|
session = self.__sessions.get(session_id)
|
2022-07-30 03:44:52 +00:00
|
|
|
if not session:
|
|
|
|
raise InvalidSession(f"Session identifier {session_id!r} is invalid.")
|
|
|
|
|
2022-07-20 13:41:42 +00:00
|
|
|
if not license_message:
|
2022-07-30 03:44:52 +00:00
|
|
|
raise InvalidLicenseMessage("Cannot parse an empty license_message")
|
2022-07-20 13:41:42 +00:00
|
|
|
|
|
|
|
if isinstance(license_message, str):
|
2022-07-30 03:44:52 +00:00
|
|
|
try:
|
|
|
|
license_message = base64.b64decode(license_message)
|
|
|
|
except (binascii.Error, binascii.Incomplete) as e:
|
|
|
|
raise InvalidLicenseMessage(f"Could not decode license_message as Base64, {e}")
|
|
|
|
|
2022-07-20 13:41:42 +00:00
|
|
|
if isinstance(license_message, bytes):
|
|
|
|
signed_message = SignedMessage()
|
|
|
|
try:
|
|
|
|
signed_message.ParseFromString(license_message)
|
2022-09-05 11:35:06 +00:00
|
|
|
if signed_message.SerializeToString() != license_message:
|
|
|
|
raise DecodeError(license_message)
|
2022-07-30 03:44:52 +00:00
|
|
|
except DecodeError as e:
|
|
|
|
raise InvalidLicenseMessage(f"Could not parse license_message as a SignedMessage, {e}")
|
2022-07-20 13:41:42 +00:00
|
|
|
license_message = signed_message
|
2022-07-30 03:44:52 +00:00
|
|
|
|
2022-07-20 13:41:42 +00:00
|
|
|
if not isinstance(license_message, SignedMessage):
|
2022-07-30 03:44:52 +00:00
|
|
|
raise InvalidLicenseMessage(f"Expecting license_response to be a SignedMessage, got {license_message!r}")
|
2022-07-20 13:41:42 +00:00
|
|
|
|
2023-11-08 22:08:31 +00:00
|
|
|
if license_message.type != SignedMessage.MessageType.Value("LICENSE"):
|
2022-07-30 03:44:52 +00:00
|
|
|
raise InvalidLicenseMessage(
|
2022-07-24 20:07:00 +00:00
|
|
|
f"Expecting a LICENSE message, not a "
|
|
|
|
f"'{SignedMessage.MessageType.Name(license_message.type)}' message."
|
|
|
|
)
|
|
|
|
|
2022-07-20 13:41:42 +00:00
|
|
|
licence = License()
|
|
|
|
licence.ParseFromString(license_message.msg)
|
|
|
|
|
2022-07-30 03:44:52 +00:00
|
|
|
context = session.context.get(licence.id.request_id)
|
2022-07-21 00:12:28 +00:00
|
|
|
if not context:
|
2022-07-30 03:44:52 +00:00
|
|
|
raise InvalidContext("Cannot parse a license message without first making a license request")
|
2022-07-21 00:12:28 +00:00
|
|
|
|
2022-08-04 03:15:37 +00:00
|
|
|
enc_key, mac_key_server, _ = self.derive_keys(
|
|
|
|
*context,
|
|
|
|
key=self.__decrypter.decrypt(license_message.session_key)
|
|
|
|
)
|
2022-07-20 13:41:42 +00:00
|
|
|
|
2022-11-01 01:40:44 +00:00
|
|
|
# 1. 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 schema
|
|
|
|
# 2. The oemcrypto_core_message (unknown purpose) is part of the signature algorithm starting with
|
|
|
|
# OEM Crypto API v16 and if available, must be prefixed when HMAC'ing a signature.
|
2022-10-23 14:07:15 +00:00
|
|
|
|
2022-07-30 02:13:58 +00:00
|
|
|
computed_signature = HMAC. \
|
2022-07-20 13:41:42 +00:00
|
|
|
new(mac_key_server, digestmod=SHA256). \
|
2022-11-01 01:40:44 +00:00
|
|
|
update(license_message.oemcrypto_core_message or b""). \
|
2022-10-23 14:07:15 +00:00
|
|
|
update(license_message.msg). \
|
2022-07-20 13:41:42 +00:00
|
|
|
digest()
|
|
|
|
|
2022-07-30 02:13:58 +00:00
|
|
|
if license_message.signature != computed_signature:
|
2022-07-30 03:44:52 +00:00
|
|
|
raise SignatureMismatch("Signature Mismatch on License Message, rejecting license")
|
2022-07-20 13:41:42 +00:00
|
|
|
|
2022-07-30 03:44:52 +00:00
|
|
|
session.keys = [
|
2022-07-20 13:41:42 +00:00
|
|
|
Key.from_key_container(key, enc_key)
|
|
|
|
for key in licence.key
|
|
|
|
]
|
|
|
|
|
2022-07-30 04:13:30 +00:00
|
|
|
del session.context[licence.id.request_id]
|
|
|
|
|
2022-08-06 07:44:25 +00:00
|
|
|
def get_keys(self, session_id: bytes, type_: Optional[Union[int, str]] = None) -> list[Key]:
|
|
|
|
"""
|
|
|
|
Get Keys from the loaded License message.
|
|
|
|
|
|
|
|
Parameters:
|
|
|
|
session_id: Session identifier.
|
|
|
|
type_: (optional) Key Type to filter by and return.
|
|
|
|
|
|
|
|
Raises:
|
|
|
|
InvalidSession: If the Session identifier is invalid.
|
|
|
|
TypeError: If the provided type_ is an unexpected value type.
|
|
|
|
ValueError: If the provided type_ is not a valid Key Type.
|
|
|
|
"""
|
2022-08-06 11:29:58 +00:00
|
|
|
session = self.__sessions.get(session_id)
|
2022-08-06 07:44:25 +00:00
|
|
|
if not session:
|
|
|
|
raise InvalidSession(f"Session identifier {session_id!r} is invalid.")
|
|
|
|
|
|
|
|
try:
|
|
|
|
if isinstance(type_, str):
|
|
|
|
type_ = License.KeyContainer.KeyType.Value(type_)
|
|
|
|
elif isinstance(type_, int):
|
|
|
|
License.KeyContainer.KeyType.Name(type_) # only test
|
|
|
|
elif type_ is not None:
|
|
|
|
raise TypeError(f"Expected type_ to be a {License.KeyContainer.KeyType} or int, not {type_!r}")
|
|
|
|
except ValueError as e:
|
|
|
|
raise ValueError(f"Could not parse type_ as a {License.KeyContainer.KeyType}, {e}")
|
|
|
|
|
|
|
|
return [
|
|
|
|
key
|
|
|
|
for key in session.keys
|
|
|
|
if not type_ or key.type == License.KeyContainer.KeyType.Name(type_)
|
|
|
|
]
|
|
|
|
|
2022-07-30 03:44:52 +00:00
|
|
|
def decrypt(
|
|
|
|
self,
|
|
|
|
session_id: bytes,
|
|
|
|
input_file: Union[Path, str],
|
|
|
|
output_file: Union[Path, str],
|
|
|
|
temp_dir: Optional[Union[Path, str]] = None,
|
|
|
|
exists_ok: bool = False
|
2023-11-08 21:23:05 +00:00
|
|
|
) -> int:
|
2022-07-20 13:41:42 +00:00
|
|
|
"""
|
|
|
|
Decrypt a Widevine-encrypted file using Shaka-packager.
|
|
|
|
Shaka-packager is much more stable than mp4decrypt.
|
|
|
|
|
2022-07-30 03:44:52 +00:00
|
|
|
Parameters:
|
|
|
|
session_id: Session identifier.
|
|
|
|
input_file: File to be decrypted with Session's currently loaded keys.
|
|
|
|
output_file: Location to save decrypted file.
|
|
|
|
temp_dir: Directory to store temporary data while decrypting.
|
|
|
|
exists_ok: Allow overwriting the output_file if it exists.
|
|
|
|
|
2022-07-20 13:41:42 +00:00
|
|
|
Raises:
|
2022-07-30 03:44:52 +00:00
|
|
|
ValueError: If the input or output paths have not been supplied or are
|
|
|
|
invalid.
|
|
|
|
FileNotFoundError: If the input file path does not exist.
|
|
|
|
FileExistsError: If the output file path already exists. Ignored if exists_ok
|
|
|
|
is set to True.
|
|
|
|
NoKeysLoaded: No License was parsed for this Session, No Keys available.
|
|
|
|
EnvironmentError: If the shaka-packager executable could not be found.
|
|
|
|
subprocess.CalledProcessError: If the shaka-packager call returned a non-zero
|
|
|
|
exit code.
|
2022-07-20 13:41:42 +00:00
|
|
|
"""
|
2022-07-30 03:44:52 +00:00
|
|
|
if not input_file:
|
2022-07-20 13:41:42 +00:00
|
|
|
raise ValueError("Cannot decrypt nothing, specify an input path")
|
2022-07-30 03:44:52 +00:00
|
|
|
if not output_file:
|
2022-07-20 13:41:42 +00:00
|
|
|
raise ValueError("Cannot decrypt nowhere, specify an output path")
|
|
|
|
|
2022-07-30 03:44:52 +00:00
|
|
|
if not isinstance(input_file, (Path, str)):
|
|
|
|
raise ValueError(f"Expecting input_file to be a Path or str, got {input_file!r}")
|
|
|
|
if not isinstance(output_file, (Path, str)):
|
|
|
|
raise ValueError(f"Expecting output_file to be a Path or str, got {output_file!r}")
|
|
|
|
if not isinstance(temp_dir, (Path, str)) and temp_dir is not None:
|
|
|
|
raise ValueError(f"Expecting temp_dir to be a Path or str, got {temp_dir!r}")
|
|
|
|
|
|
|
|
input_file = Path(input_file)
|
|
|
|
output_file = Path(output_file)
|
2023-11-08 21:25:28 +00:00
|
|
|
temp_dir_ = Path(temp_dir) if temp_dir else None
|
2022-07-30 03:44:52 +00:00
|
|
|
|
|
|
|
if not input_file.is_file():
|
|
|
|
raise FileNotFoundError(f"Input file does not exist, {input_file}")
|
|
|
|
if output_file.is_file() and not exists_ok:
|
|
|
|
raise FileExistsError(f"Output file already exists, {output_file}")
|
|
|
|
|
2022-08-06 11:29:58 +00:00
|
|
|
session = self.__sessions.get(session_id)
|
2022-07-30 03:44:52 +00:00
|
|
|
if not session:
|
|
|
|
raise InvalidSession(f"Session identifier {session_id!r} is invalid.")
|
|
|
|
|
|
|
|
if not session.keys:
|
|
|
|
raise NoKeysLoaded("No Keys are loaded yet, cannot decrypt")
|
|
|
|
|
2022-07-20 13:41:42 +00:00
|
|
|
platform = {"win32": "win", "darwin": "osx"}.get(sys.platform, sys.platform)
|
|
|
|
executable = get_binary_path("shaka-packager", f"packager-{platform}", f"packager-{platform}-x64")
|
|
|
|
if not executable:
|
|
|
|
raise EnvironmentError("Shaka Packager executable not found but is required")
|
|
|
|
|
|
|
|
args = [
|
2022-07-30 03:44:52 +00:00
|
|
|
f"input={input_file},stream=0,output={output_file}",
|
|
|
|
"--enable_raw_key_decryption",
|
|
|
|
"--keys", ",".join([
|
|
|
|
label
|
|
|
|
for i, key in enumerate(session.keys)
|
|
|
|
for label in [
|
|
|
|
f"label=1_{i}:key_id={key.kid.hex}:key={key.key.hex()}",
|
|
|
|
# some services need the KID blanked, e.g., Apple TV+
|
|
|
|
f"label=2_{i}:key_id={'0' * 32}:key={key.key.hex()}"
|
2022-07-20 13:41:42 +00:00
|
|
|
]
|
2022-07-30 03:44:52 +00:00
|
|
|
if key.type == "CONTENT"
|
2022-07-23 15:26:09 +00:00
|
|
|
])
|
2022-07-20 13:41:42 +00:00
|
|
|
]
|
|
|
|
|
2023-11-08 21:25:28 +00:00
|
|
|
if temp_dir_:
|
|
|
|
temp_dir_.mkdir(parents=True, exist_ok=True)
|
|
|
|
args.extend(["--temp_dir", str(temp_dir_)])
|
2022-07-20 13:41:42 +00:00
|
|
|
|
2023-11-08 21:23:05 +00:00
|
|
|
return subprocess.check_call([executable, *args])
|
2022-07-20 13:41:42 +00:00
|
|
|
|
|
|
|
@staticmethod
|
|
|
|
def encrypt_client_id(
|
|
|
|
client_id: ClientIdentification,
|
2022-09-28 06:43:36 +00:00
|
|
|
service_certificate: Union[SignedDrmCertificate, DrmCertificate],
|
2023-11-08 21:25:28 +00:00
|
|
|
key: Optional[bytes] = None,
|
|
|
|
iv: Optional[bytes] = None
|
2022-07-20 13:41:42 +00:00
|
|
|
) -> EncryptedClientIdentification:
|
|
|
|
"""Encrypt the Client ID with the Service's Privacy Certificate."""
|
|
|
|
privacy_key = key or get_random_bytes(16)
|
|
|
|
privacy_iv = iv or get_random_bytes(16)
|
|
|
|
|
2022-07-31 12:29:38 +00:00
|
|
|
if isinstance(service_certificate, SignedDrmCertificate):
|
|
|
|
drm_certificate = DrmCertificate()
|
|
|
|
drm_certificate.ParseFromString(service_certificate.drm_certificate)
|
|
|
|
service_certificate = drm_certificate
|
2022-07-20 13:41:42 +00:00
|
|
|
if not isinstance(service_certificate, DrmCertificate):
|
2022-07-21 16:26:14 +00:00
|
|
|
raise ValueError(f"Expecting Service Certificate to be a DrmCertificate, not {service_certificate!r}")
|
2022-07-20 13:41:42 +00:00
|
|
|
|
2023-11-08 22:08:31 +00:00
|
|
|
encrypted_client_id = EncryptedClientIdentification(
|
|
|
|
provider_id=service_certificate.provider_id,
|
|
|
|
service_certificate_serial_number=service_certificate.serial_number,
|
|
|
|
encrypted_client_id=AES.
|
|
|
|
new(privacy_key, AES.MODE_CBC, privacy_iv).
|
|
|
|
encrypt(Padding.pad(client_id.SerializeToString(), 16)),
|
|
|
|
encrypted_client_id_iv=privacy_iv,
|
|
|
|
encrypted_privacy_key=PKCS1_OAEP.
|
|
|
|
new(RSA.importKey(service_certificate.public_key)).
|
2022-07-20 13:41:42 +00:00
|
|
|
encrypt(privacy_key)
|
2023-11-08 22:08:31 +00:00
|
|
|
)
|
2022-07-20 13:41:42 +00:00
|
|
|
|
2023-11-08 22:08:31 +00:00
|
|
|
return encrypted_client_id
|
2022-07-20 13:41:42 +00:00
|
|
|
|
|
|
|
@staticmethod
|
2022-07-20 21:25:57 +00:00
|
|
|
def derive_context(message: bytes) -> tuple[bytes, bytes]:
|
|
|
|
"""Returns 2 Context Data used for computing the AES Encryption and HMAC Keys."""
|
|
|
|
|
|
|
|
def _get_enc_context(msg: bytes) -> bytes:
|
|
|
|
label = b"ENCRYPTION"
|
|
|
|
key_size = 16 * 8 # 128-bit
|
|
|
|
return label + b"\x00" + msg + key_size.to_bytes(4, "big")
|
|
|
|
|
|
|
|
def _get_mac_context(msg: bytes) -> bytes:
|
|
|
|
label = b"AUTHENTICATION"
|
|
|
|
key_size = 32 * 8 * 2 # 512-bit
|
|
|
|
return label + b"\x00" + msg + key_size.to_bytes(4, "big")
|
|
|
|
|
|
|
|
return _get_enc_context(message), _get_mac_context(message)
|
|
|
|
|
|
|
|
@staticmethod
|
|
|
|
def derive_keys(enc_context: bytes, mac_context: bytes, key: bytes) -> tuple[bytes, bytes, bytes]:
|
2022-07-20 13:41:42 +00:00
|
|
|
"""
|
|
|
|
Returns 3 keys derived from the input message.
|
|
|
|
Key can either be a pre-provision device aes key, provision key, or a session key.
|
|
|
|
|
|
|
|
For provisioning:
|
|
|
|
- enc: aes key used for unwrapping RSA key out of response
|
|
|
|
- mac_key_server: hmac-sha256 key used for verifying provisioning response
|
|
|
|
- mac_key_client: hmac-sha256 key used for signing provisioning request
|
|
|
|
|
|
|
|
When used with a session key:
|
|
|
|
- enc: decrypting content and other keys
|
|
|
|
- mac_key_server: verifying response
|
|
|
|
- mac_key_client: renewals
|
|
|
|
|
|
|
|
With key as pre-provision device key, it can be used to provision and get an
|
|
|
|
RSA device key and token/cert with key as session key (OAEP wrapped with the
|
|
|
|
post-provision RSA device key), it can be used to decrypt content and signing
|
|
|
|
keys and verify licenses.
|
|
|
|
"""
|
|
|
|
|
|
|
|
def _derive(session_key: bytes, context: bytes, counter: int) -> bytes:
|
2022-07-30 03:44:52 +00:00
|
|
|
return CMAC. \
|
|
|
|
new(session_key, ciphermod=AES). \
|
2022-07-20 13:41:42 +00:00
|
|
|
update(counter.to_bytes(1, "big") + context). \
|
|
|
|
digest()
|
|
|
|
|
|
|
|
enc_key = _derive(key, enc_context, 1)
|
|
|
|
mac_key_server = _derive(key, mac_context, 1)
|
|
|
|
mac_key_server += _derive(key, mac_context, 2)
|
|
|
|
mac_key_client = _derive(key, mac_context, 3)
|
|
|
|
mac_key_client += _derive(key, mac_context, 4)
|
|
|
|
|
|
|
|
return enc_key, mac_key_server, mac_key_client
|
|
|
|
|
|
|
|
|
2023-11-08 22:56:37 +00:00
|
|
|
__all__ = ("Cdm",)
|