module separation + fix provisioning response decrypt

This commit is contained in:
hyugogirubato
2025-06-14 16:06:02 +02:00
parent d27758b7dc
commit 8ed3eccf01
5 changed files with 840 additions and 677 deletions
+274
View File
@@ -0,0 +1,274 @@
import json
from enum import Enum
from cryptography.hazmat.backends import default_backend
from cryptography.hazmat.primitives import cmac
from cryptography.hazmat.primitives.asymmetric.padding import OAEP, MGF1
from cryptography.hazmat.primitives.ciphers import Cipher
from cryptography.hazmat.primitives.ciphers.algorithms import AES
from cryptography.hazmat.primitives.ciphers.modes import CBC
from cryptography.hazmat.primitives.hashes import SHA1
from cryptography.hazmat.primitives.padding import PKCS7
from keydive.drm.modules.client import Client
from keydive.drm.protocol.license_pb2 import SignedProvisioningMessage, ProvisioningResponse, ClientIdentification
from keydive.utils import b64dec, dumps
class OEMCrypto_ProvisioningMethod(Enum):
"""
Enum representing different OEMCrypto provisioning methods.
"""
ProvisioningError = 0 # Device cannot be provisioned.
DrmCertificate = 1 # Device has baked-in DRM certificate (level 3 only).
Keybox = 2 # Device has factory-installed unique keybox.
OEMCertificate = 3 # Device has factory-installed OEM certificate.
def ContentKeySession_GenerateDerivedKeys(enc_key_base: bytes, key: bytes) -> bytes:
"""
Derives a single CMAC-based encryption key from the given base key.
This is a minimal implementation of key derivation used in Widevine's content key session setup.
It returns only the 'enc' key from the full provisioning/session derivation pipeline.
Args:
enc_key_base (bytes): Context string for encryption key derivation (e.g., from provisioning nonce or session context).
key (bytes): Base AES key (e.g., device AES key or session key).
Returns:
bytes: Derived encryption key.
"""
cipher = cmac.CMAC(
algorithm=AES(key),
backend=default_backend()
)
cipher.update(b'\x01' + enc_key_base)
return cipher.finalize()
class Provisioning(Client):
def __unwrap_rsa_key(self, key: bytes, iv: bytes, enc_data: bytes) -> bytes:
"""
Attempts to decrypt an RSA private key blob using AES-CBC and PKCS7 unpadding.
This method is used in Widevine provisioning flows to unwrap the encrypted RSA device key
from the provisioning response using a derived or session AES key.
Args:
key (bytes): The AES key used to decrypt the RSA private key (128/256-bit).
iv (bytes): Initialization Vector used during the AES encryption.
enc_data (bytes): Encrypted RSA private key data (typically from the provisioning response).
Returns:
bytes: The unwrapped (decrypted) RSA private key if successful, otherwise an empty byte string.
"""
dec_data = b''
try:
# Initialize AES-CBC cipher with the session key and provided IV
cipher = Cipher(
algorithm=AES(key),
mode=CBC(iv),
backend=default_backend()
)
decryptor = cipher.decryptor()
dec_padded_data = decryptor.update(enc_data) + decryptor.finalize()
# Remove PKCS7 padding to obtain the original private key bytes
unpadder = PKCS7(AES.block_size).unpadder()
dec_data = unpadder.update(dec_padded_data) + unpadder.finalize()
except Exception as e:
if key:
self.logger.debug('Failed to decrypt RSA private key using AES key: %s', key.hex())
# Return decrypted RSA private key (if successful); otherwise, return empty bytes
return dec_data
def set_provisioning_method(self, data: bytes) -> None:
"""
Determines and logs the provisioning method used by the Content Decryption Module (CDM).
This method decodes the given byte data to an integer, which maps to an
OEMCrypto_ProvisioningMethod enumeration value. It helps identify the provisioning
mechanism in use and logs relevant diagnostic information, especially when L1
provisioning appears to be disabled improperly.
Args:
data (bytes): UTF-8 encoded string representing an integer corresponding to
an OEMCrypto_ProvisioningMethod enum value.
Exception:
Catches and logs all exceptions encountered during decoding or enum conversion.
"""
try:
# Decode bytes to UTF-8 string, convert to int, and map to provisioning method enum
method = OEMCrypto_ProvisioningMethod(int(data.decode('utf-8')))
if method == OEMCrypto_ProvisioningMethod.Keybox and self.disabler:
# Warn user if L1 provisioning is enabled but disabling procedure incomplete
self.logger.warning(
'L1 provisioning deactivation appears incomplete. '
'Consider using a web dump or forcibly terminating the process to ensure proper disabling.'
)
else:
# Log the provisioning method name for informational purposes
self.logger.debug('Receive provisioning method: %s', method.name)
except Exception as e:
# Log any errors during decoding or mapping to enum with debug severity
self.logger.debug('Unable to parse provisioning method: %s', e)
def set_provisioning_response(self, data: bytes) -> None:
"""
Parses and applies a provisioning response from the Google Widevine provisioning service.
Supports both Keybox-based provisioning and Provisioning 3.0 OTA PKI formats.
This method extracts and decrypts the device RSA private key using known AES session keys,
which may be retrieved from keyboxes or OEM provisioning keys. It also supports
setting up the device certificate and updating client identification.
Args:
data (bytes): JSON-encoded provisioning response as received from the provisioning server.
Exception:
Catches and logs all exceptions raised during parsing, decryption, or client ID setup.
"""
try:
# Extract and decode the base64-encoded signed provisioning message from the JSON response
b64_signed_data = json.loads(data.split(b'\x00')[0])['signedResponse']
signed_data = b64dec(b64_signed_data, safe=True)
# Parse the SignedProvisioningMessage protobuf
signed_response = SignedProvisioningMessage()
signed_response.ParseFromString(signed_data)
# Extract the ProvisioningResponse payload embedded in the signed message
provisioning_response = ProvisioningResponse()
provisioning_response.ParseFromString(signed_response.message)
# Gather all known AES session keys from stored keyboxes (OEM Keybox keys)
session_enc_keys = [k.device_aes_key for k in self._keybox.values() if k.device_aes_key]
# Optionally, add additional AES keys extracted via reverse engineering/TEE exploit (if any)
session_enc_keys += self._device_aes_key
if provisioning_response.wrapping_key:
# OTA PKI-Based provisioning (Provisioning 3.0)
self.logger.info(
'Received OTA provisioning response: \n\n%s\n',
dumps({
'signature': {'type': 'RSASSA-PSS', 'data': signed_response.signature},
'nonce': provisioning_response.nonce,
'wrapping_key': provisioning_response.wrapping_key
}, beauty=True)
)
# Attempt to decrypt the AES wrapping key using all stored OEM private RSA keys
for oem_cert_priv_key in self._private_key.values():
try:
session_enc_key = oem_cert_priv_key.decrypt(
ciphertext=provisioning_response.wrapping_key,
padding=OAEP(
mgf=MGF1(algorithm=SHA1()),
algorithm=SHA1(),
label=None
)
)
session_enc_keys.append(session_enc_key)
except Exception as e:
self.logger.debug('Unable to decrypt OTA session key: %s', e)
else:
# Keybox-based provisioning (Provisioning 2.0)
self.logger.info(
'Receive Keybox provisioning response: \n\n%s\n',
dumps({
'signature': {'type': 'HMAC-SHA256', 'data': signed_response.signature},
'nonce': provisioning_response.nonce
}, beauty=True)
)
# Derive session encryption keys and map each original AES key to its derived encryption key
key_pairs = {
key: ContentKeySession_GenerateDerivedKeys(self._context, key) if self._context else None
for key in session_enc_keys
}
# Attempt to decrypt the device RSA private key using all gathered AES session keys
for aes_key, derived_key in key_pairs.items():
iv = provisioning_response.device_rsa_key_iv
enc_data = provisioning_response.device_rsa_key
# Attempt to decrypt using the derived key (from ContentKeySession_GenerateDerivedKeys)
dec_data = self.__unwrap_rsa_key(derived_key, iv, enc_data)
if dec_data:
self.logger.info('Provisioning from AES key derivation: %s', derived_key.hex())
# Try to find the corresponding Keybox which doesn't yet have an AES key assigned
keybox = next((k for k in self._keybox.values() if k.stable_id and k.device_id and not k.device_aes_key), None)
if keybox:
# Check if system_id is available to ensure the Keybox is valid and useful
if keybox.keybox_info.get('system_id'):
# Link this AES key back to the Keybox for future use
keybox.device_aes_key = aes_key
self.logger.info(
'Completed keybox with corresponding AES key:\n\n%s\n',
dumps(keybox.keybox_info, beauty=True)
)
# Update the stored keybox with the completed information
self._keybox[keybox.stable_id] = keybox
# Set the now-decrypted RSA private key as the active OEM private key
self.set_private_key(dec_data, None)
# Attempt to decrypt the RSA key using the raw session AES key (not derived)
dec_data = self.__unwrap_rsa_key(aes_key, iv, enc_data)
if dec_data:
self.logger.info('Provisioning from OTA AES key: %s', aes_key.hex())
# Set decrypted RSA private key from OTA flow
self.set_private_key(dec_data, None)
# If no decryption succeeded, OEM private key remains unset
# At this point, OTA provisioning using Provisioning 3.0 (PKI-based) might apply
# But this flow assumes Provisioning 2.0 (Keybox-based), so no further action here
# If a provisioning context exists, update the ClientIdentification with new capabilities and certificates
if self._provisioning:
"""
client_capabilities {
client_token: true
session_token: true
max_hdcp_version: HDCP_V2_2
oem_crypto_api_version: 15
anti_rollback_usage_table: false
srm_version: 0
can_update_srm: false
supported_certificate_key_type: RSA_2048
analog_output_capabilities: ANALOG_OUTPUT_NONE
can_disable_analog_output: false
}
"""
# Update client capabilities with typical fields
client_capabilities = self._provisioning.client_capabilities
client_capabilities.session_token = True
client_capabilities.max_hdcp_version = ClientIdentification.ClientCapabilities.HdcpVersion.HDCP_NONE
client_capabilities.anti_rollback_usage_table = False
client_capabilities.can_update_srm = False
# Construct a new ClientIdentification token with the provisioned device certificate
client_id = ClientIdentification(
type=ClientIdentification.TokenType.DRM_DEVICE_CERTIFICATE,
token=provisioning_response.device_certificate,
client_info=self._provisioning.client_info,
provider_client_token=self._provisioning.provider_client_token,
license_counter=self._provisioning.license_counter,
client_capabilities=client_capabilities,
vmp_data=self._provisioning.vmp_data,
device_credentials=self._provisioning.device_credentials
)
# Register the updated client identification token
self.set_client_id(client_id)
except Exception as e:
# Log any unexpected error encountered during the provisioning response handling
self.logger.debug('Unable to process provisioning response: %s', e)